diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index d72a7a01..243e9cb1 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -7,7 +7,7 @@ Generated by `bun run scenarios:build`. Edits here are overwritten; change the s | | conversation | voices | acts | | --- | --- | --- | --- | -| [S-1](S-1.md) | A hunch that is not yet an experiment | Researcher | 15 | +| [S-1](S-1.md) | A hunch that is not yet an experiment | Researcher | 16 | | [S-3](S-3.md) | Significant by the primary test, untrustworthy by its own robustness checks | Researcher | 8 | | [S-3b](S-3b.md) | The same design with nothing downstream | Researcher | 9 | | [S-3c](S-3c.md) | The check was wrong, not the result | Researcher | 11 | @@ -15,38 +15,25 @@ Generated by `bun run scenarios:build`. Edits here are overwritten; change the s | [S-5](S-5.md) | contradiction or dissociation? | Researcher | 10 | | [S-7](S-7.md) | locked design, then feasibility finds a mechanical defect | Researcher, Agent | 14 | | [S-8](S-8.md) | don't spend the whole budget discovering the pipeline is broken | Researcher, Agent | 11 | -| [S-9](S-9.md) | the artefact survived; its provenance didn't | Researcher | 7 | +| [S-9](S-9.md) | the artefact survived; its provenance didn't | Researcher | 9 | | [S-9b](S-9b.md) | was this a rebuild, or new work? | Researcher | 7 | -| [S-9c](S-9c.md) | two parts, one name | Researcher | 5 | | [S-9d](S-9d.md) | resting on one thing, or two? | Researcher | 5 | -| [S-9e](S-9e.md) | reproducing nothing | Researcher | 3 | | [S-10](S-10.md) | Rerunning is not reproducing | Researcher | 6 | -| [S-10b](S-10b.md) | The same inputs, in a different order | Researcher | 5 | -| [S-10c](S-10c.md) | Which input changed? | Researcher | 6 | -| [S-10d](S-10d.md) | The order a run read its inputs in | Researcher | 6 | -| [S-10e](S-10e.md) | The same record, read twice by one run | Researcher | 5 | | [S-11](S-11.md) | The analysis was wrong; the observations were fine | Researcher, Reviewer | 17 | -| [S-11c](S-11c.md) | Nothing found is not nothing there | Researcher | 7 | -| [S-11d](S-11d.md) | A stage cannot read a stage | Researcher | 6 | | [S-11e](S-11e.md) | A replacement that consumes the output it invalidated | Researcher, Reviewer | 7 | | [S-11f](S-11f.md) | A computed input, asked about by the reads that touch inputs | Researcher | 6 | | [S-11g](S-11g.md) | A replacement that addresses only some of a run's conclusions | Researcher, Reviewer | 8 | -| [S-12](S-12.md) | The numbers are right; the sentence about them is wrong | Researcher | 8 | -| [S-12b](S-12b.md) | Two revision chains that pass through one sentence | Researcher | 12 | | [S-14](S-14.md) | Deliberately leaving something unresolved | Researcher | 5 | | [S-17](S-17.md) | Does the guard actually guard? | Researcher | 3 | | [S-18](S-18.md) | Scratch work that unexpectedly mattered | Researcher | 5 | | [S-18b](S-18b.md) | A negative result that somebody vouched for | Researcher | 6 | | [S-19](S-19.md) | promoted, closed, and the agreed check never run | Researcher | 7 | -| [S-20](S-20.md) | a finding that settles the proposition neither way | Researcher | 5 | | [S-21](S-21.md) | a finding drawn across findings | Researcher | 14 | | [S-22](S-22.md) | a check decided by measurement says so | Researcher | 6 | | [S-23](S-23.md) | prespecified is not promoted | Researcher | 6 | -| [S-24](S-24.md) | a mistaken act taken back | Researcher | 2 | -| [S-24b](S-24b.md) | walking back a tree of mistakes | Researcher | 12 | | [S-25](S-25.md) | one rule judged four times | Researcher | 20 | | [S-26](S-26.md) | work nobody is doing | Researcher | 5 | -| [S-27](S-27.md) | Why explains every kind | Researcher | 11 | +| [S-27](S-27.md) | Why explains every kind | Researcher | 10 | | [S-28](S-28.md) | A hunch became a question | Researcher | 2 | | [S-29](S-29.md) | The note came after the question | Researcher | 2 | | [S-30](S-30.md) | Fixed before the first run | Researcher | 4 | diff --git a/docs/scenarios/S-1.md b/docs/scenarios/S-1.md index c5c75f52..dbf659dc 100644 --- a/docs/scenarios/S-1.md +++ b/docs/scenarios/S-1.md @@ -2,7 +2,7 @@ A researcher has a vague idea about what a learned topology is doing. Before anything is run, the record says what is already established, what is still open and what has never been tested, and the hunch is narrowed into a question that could be answered. -15 acts, one voice. +16 acts, one voice. ```mermaid sequenceDiagram @@ -26,7 +26,8 @@ sequenceDiagram R-->>Researcher: COMP_11 Researcher->>R: conclude the internal response is more than a nonlinear smear Researcher->>R: pose x2 - Researcher->>R: sharpen the vague form is not testable this one names what would count … + Researcher->>R: note + Researcher->>R: pose do different inputs map to reproducibly different internal resp… ``` ## The acts, in order @@ -47,4 +48,5 @@ sequenceDiagram | 12 | Researcher | `conclude` | the internal response is more than a nonlinear smear | `COMP_11` | | 13 | Researcher | `pose` | does the learned topology help on an external task? | `Q_13` | | 14 | Researcher | `pose` | is the learned topology doing something computationally interesting? | `Q_14` | -| 15 | Researcher | `sharpen` | the vague form is not testable; this one names what would count as an answer | `Q_15` | +| 15 | Researcher | `note` | | `NOTE_15` | +| 16 | Researcher | `pose` | do different inputs map to reproducibly different internal responses? | `Q_16` | diff --git a/docs/scenarios/S-10b.md b/docs/scenarios/S-10b.md deleted file mode 100644 index 7553505f..00000000 --- a/docs/scenarios/S-10b.md +++ /dev/null @@ -1,26 +0,0 @@ -# S-10b — The same inputs, in a different order - -An alignment subtracts one series from the other, so which input came first is part of what the run was. The record keeps both series either way, and nothing in it tells the two orders apart. - -5 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry is the second series shifted relative to the first? - Researcher->>R: recordObservations x2 - Researcher->>R: recordAnalysis pairwise-alignment - R-->>Researcher: COMP_4 - Researcher->>R: conclude the second series is shifted relative to the first -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | is the second series shifted relative to the first? | `LOE_1` | -| 2 | Researcher | `recordObservations` | baseline trace | `ART_2` | -| 3 | Researcher | `recordObservations` | comparison trace | `ART_3` | -| 4 | Researcher | `recordAnalysis` | pairwise-alignment | `COMP_4` | -| 5 | Researcher | `conclude` | the second series is shifted relative to the first | `COMP_4` | diff --git a/docs/scenarios/S-10c.md b/docs/scenarios/S-10c.md deleted file mode 100644 index 327a41ad..00000000 --- a/docs/scenarios/S-10c.md +++ /dev/null @@ -1,29 +0,0 @@ -# S-10c — Which input changed? - -The original control series was lost and the finding was re-checked against a regenerated one with the same name. The record reports two differences, and the reader has to be able to say which series each of them is about. - -6 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the effect hold against the control? - Researcher->>R: recordObservations the original series - Researcher->>R: recordAnalysis effect-test - R-->>Researcher: COMP_3 - Researcher->>R: conclude the effect holds against the control - Researcher->>R: recordObservations regenerated from an inferred algorithm - Researcher->>R: reverify effect-test, re-run -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the effect hold against the control? | `LOE_1` | -| 2 | Researcher | `recordObservations` | the original series | `ART_2` | -| 3 | Researcher | `recordAnalysis` | effect-test | `COMP_3` | -| 4 | Researcher | `conclude` | the effect holds against the control | `COMP_3` | -| 5 | Researcher | `recordObservations` | regenerated from an inferred algorithm | `ART_5` | -| 6 | Researcher | `reverify` | effect-test, re-run | `COMP_6` | diff --git a/docs/scenarios/S-10d.md b/docs/scenarios/S-10d.md deleted file mode 100644 index 62e075c9..00000000 --- a/docs/scenarios/S-10d.md +++ /dev/null @@ -1,28 +0,0 @@ -# S-10d — The order a run read its inputs in - -A run takes the difference between two series, and a re-run reads the same two records the other way round. The same records are on both sides, so nothing differs, and the record still shows the order each run read them in. - -6 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry do the two series differ? - Researcher->>R: recordObservations x2 - Researcher->>R: recordAnalysis difference of the two series - R-->>Researcher: COMP_4 - Researcher->>R: conclude the two series differ in magnitude - Researcher->>R: reverify difference of the two series -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | do the two series differ? | `LOE_1` | -| 2 | Researcher | `recordObservations` | twelve points | `ART_2` | -| 3 | Researcher | `recordObservations` | twelve points | `ART_3` | -| 4 | Researcher | `recordAnalysis` | difference of the two series | `COMP_4` | -| 5 | Researcher | `conclude` | the two series differ in magnitude | `COMP_4` | -| 6 | Researcher | `reverify` | difference of the two series | `COMP_6` | diff --git a/docs/scenarios/S-10e.md b/docs/scenarios/S-10e.md deleted file mode 100644 index 9ce87169..00000000 --- a/docs/scenarios/S-10e.md +++ /dev/null @@ -1,27 +0,0 @@ -# S-10e — The same record, read twice by one run - -A null test puts one series on both sides of a difference, and a re-run reads it only once. The record keeps how many times each run read the series, so the two are not reported as having read the same thing. - -5 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the series differ from itself? - Researcher->>R: recordObservations twelve points - Researcher->>R: recordAnalysis difference of the two series - R-->>Researcher: COMP_3 - Researcher->>R: conclude the series does not differ from itself - Researcher->>R: reverify difference of the two series -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the series differ from itself? | `LOE_1` | -| 2 | Researcher | `recordObservations` | twelve points | `ART_2` | -| 3 | Researcher | `recordAnalysis` | difference of the two series | `COMP_3` | -| 4 | Researcher | `conclude` | the series does not differ from itself | `COMP_3` | -| 5 | Researcher | `reverify` | difference of the two series | `COMP_5` | diff --git a/docs/scenarios/S-11.md b/docs/scenarios/S-11.md index 6b25d25e..72d1c472 100644 --- a/docs/scenarios/S-11.md +++ b/docs/scenarios/S-11.md @@ -1,6 +1,6 @@ # S-11 — The analysis was wrong; the observations were fine -A reviewer finds the analysis does not implement the null it claims. The observations stand; the analysis is replaced, and only the conclusion that moved is marked as changed. +A reviewer finds the analysis does not implement the null it claims. The observations stand; the analysis is run again correctly, and each new conclusion names the one it replaces. 17 acts, Researcher and Reviewer. @@ -15,8 +15,8 @@ sequenceDiagram R-->>Researcher: COMP_3 Researcher->>R: conclude x6 R-->>Researcher: COMP_3 - Reviewer->>R: recordReview bootstrap is centred on the observed effect it does not impleme… - Researcher->>R: replaceAnalysis sign-flip-permutation + Reviewer->>R: conclude the bootstrap implements the intended null + Researcher->>R: recordAnalysis sign-flip-permutation R-->>Researcher: COMP_11 Researcher->>R: conclude x6 R-->>Researcher: COMP_11 @@ -24,15 +24,12 @@ sequenceDiagram ## What moved -`labkit why COMP_11` answers: +`labkit why-supported CLM_13` answers: -**COMP_11** is a revision of COMP_3. +**T beats rewired** — supported, held as exploratory. -- because bootstrap is centred on the observed effect; it does not implement the intended null -- because T beats rewired: p = 0.002 (bootstrap) → p = 0.049 (sign-flip permutation) - -- **changed** T beats rewired: p = 0.002 (bootstrap) → p = 0.049 (sign-flip permutation) -- **restated** unchanged: T beats lattice; T beats curr_random; lattice beats curr_random; rewired beats curr_random; T beats static +- **supported by** p = 0.049 (sign-flip permutation) +- **superseded** p = 0.002 (bootstrap) — superseded by "p = 0.049 (sign-flip permutation)" ## The acts, in order @@ -47,8 +44,8 @@ sequenceDiagram | 7 | Researcher | `conclude` | lattice beats curr_random | `COMP_3` | | 8 | Researcher | `conclude` | rewired beats curr_random | `COMP_3` | | 9 | Researcher | `conclude` | T beats static | `COMP_3` | -| 10 | Reviewer | `recordReview` | bootstrap is centred on the observed effect; it does not implement the intended… | `REV_10` | -| 11 | Researcher | `replaceAnalysis` | sign-flip-permutation | `COMP_11` | +| 10 | Reviewer | `conclude` | the bootstrap implements the intended null | `COMP_3` | +| 11 | Researcher | `recordAnalysis` | sign-flip-permutation | `COMP_11` | | 12 | Researcher | `conclude` | T beats lattice | `COMP_11` | | 13 | Researcher | `conclude` | T beats rewired | `COMP_11` | | 14 | Researcher | `conclude` | T beats curr_random | `COMP_11` | diff --git a/docs/scenarios/S-11c.md b/docs/scenarios/S-11c.md deleted file mode 100644 index 3bd378b5..00000000 --- a/docs/scenarios/S-11c.md +++ /dev/null @@ -1,32 +0,0 @@ -# S-11c — Nothing found is not nothing there - -Raw sensor data is calibrated, and the calibrated series is re-entered by hand as fresh observations before the trend analysis reads it. Asking what depends on the raw series reaches only the first stage, and the answer says it is a lower bound rather than a complete list. - -7 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the response trend upward with dose? - Researcher->>R: recordObservations eleven dose levels, uncalibrated - Researcher->>R: recordAnalysis calibrate - R-->>Researcher: COMP_3 - Researcher->>R: conclude the calibration is stable across the run - Researcher->>R: recordObservations eleven dose levels, calibrated - Researcher->>R: recordAnalysis dose-response-fit - R-->>Researcher: COMP_6 - Researcher->>R: conclude the response trends upward with dose -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the response trend upward with dose? | `LOE_1` | -| 2 | Researcher | `recordObservations` | eleven dose levels, uncalibrated | `ART_2` | -| 3 | Researcher | `recordAnalysis` | calibrate | `COMP_3` | -| 4 | Researcher | `conclude` | the calibration is stable across the run | `COMP_3` | -| 5 | Researcher | `recordObservations` | eleven dose levels, calibrated | `ART_5` | -| 6 | Researcher | `recordAnalysis` | dose-response-fit | `COMP_6` | -| 7 | Researcher | `conclude` | the response trends upward with dose | `COMP_6` | diff --git a/docs/scenarios/S-11d.md b/docs/scenarios/S-11d.md deleted file mode 100644 index c3842926..00000000 --- a/docs/scenarios/S-11d.md +++ /dev/null @@ -1,30 +0,0 @@ -# S-11d — A stage cannot read a stage - -A raw series came off an instrument whose settings were never logged, it is calibrated, and the trend analysis reads the calibration's output directly. The calibration reports itself unreproducible because what it rests on cannot be checked. - -6 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the response trend upward with dose? - Researcher->>R: recordObservations eleven dose levels, instrument settings not logged - Researcher->>R: recordAnalysis calibrate - R-->>Researcher: COMP_3 - Researcher->>R: conclude the calibration is stable - Researcher->>R: recordAnalysis dose-response-fit - R-->>Researcher: COMP_5 - Researcher->>R: conclude the response trends upward with dose -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the response trend upward with dose? | `LOE_1` | -| 2 | Researcher | `recordObservations` | eleven dose levels, instrument settings not logged | `ART_2` | -| 3 | Researcher | `recordAnalysis` | calibrate | `COMP_3` | -| 4 | Researcher | `conclude` | the calibration is stable | `COMP_3` | -| 5 | Researcher | `recordAnalysis` | dose-response-fit | `COMP_5` | -| 6 | Researcher | `conclude` | the response trends upward with dose | `COMP_5` | diff --git a/docs/scenarios/S-11e.md b/docs/scenarios/S-11e.md index bd4b7bd5..ead5de0e 100644 --- a/docs/scenarios/S-11e.md +++ b/docs/scenarios/S-11e.md @@ -14,8 +14,8 @@ sequenceDiagram Researcher->>R: recordAnalysis unadjusted comparison R-->>Researcher: COMP_3 Researcher->>R: conclude the treatment shortens recovery - Reviewer->>R: recordReview unadjusted for baseline severity - Researcher->>R: replaceAnalysis severity-adjusted comparison + Reviewer->>R: note + Researcher->>R: recordAnalysis severity-adjusted comparison R-->>Researcher: COMP_6 Researcher->>R: conclude the treatment shortens recovery ``` @@ -27,7 +27,7 @@ sequenceDiagram **the treatment shortens recovery** — supported, held as exploratory. - **supported by** one day shorter, adjusted -- **superseded** three days shorter — unadjusted for baseline severity +- **superseded** three days shorter — superseded by "one day shorter, adjusted" ## The acts, in order @@ -37,6 +37,6 @@ sequenceDiagram | 2 | Researcher | `recordObservations` | sixty patients, two arms | `ART_2` | | 3 | Researcher | `recordAnalysis` | unadjusted comparison | `COMP_3` | | 4 | Researcher | `conclude` | the treatment shortens recovery | `COMP_3` | -| 5 | Reviewer | `recordReview` | unadjusted for baseline severity | `REV_5` | -| 6 | Researcher | `replaceAnalysis` | severity-adjusted comparison | `COMP_6` | +| 5 | Reviewer | `note` | | `NOTE_5` | +| 6 | Researcher | `recordAnalysis` | severity-adjusted comparison | `COMP_6` | | 7 | Researcher | `conclude` | the treatment shortens recovery | `COMP_6` | diff --git a/docs/scenarios/S-11g.md b/docs/scenarios/S-11g.md index 62bf6841..6460d0a7 100644 --- a/docs/scenarios/S-11g.md +++ b/docs/scenarios/S-11g.md @@ -15,8 +15,8 @@ sequenceDiagram R-->>Researcher: COMP_3 Researcher->>R: conclude x2 R-->>Researcher: COMP_3 - Reviewer->>R: recordReview raw-scale aggregation is untrustworthy for the stochastic-contr… - Researcher->>R: keep log-scale re-aggregation + Reviewer->>R: note + Researcher->>R: recordAnalysis log-scale re-aggregation R-->>Researcher: COMP_7 Researcher->>R: conclude T differs from the current-random control ``` @@ -30,6 +30,6 @@ sequenceDiagram | 3 | Researcher | `recordAnalysis` | raw-scale aggregation | `COMP_3` | | 4 | Researcher | `conclude` | T differs from the current-random control | `COMP_3` | | 5 | Researcher | `conclude` | T differs from the lattice control | `COMP_3` | -| 6 | Reviewer | `recordReview` | raw-scale aggregation is untrustworthy for the stochastic-control comparisons | `REV_6` | -| 7 | Researcher | `keep` | log-scale re-aggregation | `COMP_7` | +| 6 | Reviewer | `note` | | `NOTE_6` | +| 7 | Researcher | `recordAnalysis` | log-scale re-aggregation | `COMP_7` | | 8 | Researcher | `conclude` | T differs from the current-random control | `COMP_7` | diff --git a/docs/scenarios/S-12.md b/docs/scenarios/S-12.md deleted file mode 100644 index 21bcabc2..00000000 --- a/docs/scenarios/S-12.md +++ /dev/null @@ -1,34 +0,0 @@ -# S-12 — The numbers are right; the sentence about them is wrong - -Two cohorts reach the same conclusion, and the wording of that conclusion overstates what the measurements show. The sentence is narrowed; every finding underneath it still stands. - -8 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the encoding preferentially preserve discriminative signal? - Researcher->>R: recordObservations signal amplitude before and after encoding, both signal types, … - Researcher->>R: recordAnalysis attenuation-ratio - R-->>Researcher: COMP_3 - Researcher->>R: conclude the encoding preferentially preserves discriminative signal - Researcher->>R: recordObservations signal amplitude before and after encoding, both signal types, … - Researcher->>R: recordAnalysis attenuation-ratio - R-->>Researcher: COMP_6 - Researcher->>R: conclude the encoding preferentially preserves discriminative signal - Researcher->>R: reinterpret discriminative signal attenuates less than non-discriminative s… -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the encoding preferentially preserve discriminative signal? | `LOE_1` | -| 2 | Researcher | `recordObservations` | signal amplitude before and after encoding, both signal types, cohort A | `ART_2` | -| 3 | Researcher | `recordAnalysis` | attenuation-ratio | `COMP_3` | -| 4 | Researcher | `conclude` | the encoding preferentially preserves discriminative signal | `COMP_3` | -| 5 | Researcher | `recordObservations` | signal amplitude before and after encoding, both signal types, cohort B | `ART_5` | -| 6 | Researcher | `recordAnalysis` | attenuation-ratio | `COMP_6` | -| 7 | Researcher | `conclude` | the encoding preferentially preserves discriminative signal | `COMP_6` | -| 8 | Researcher | `reinterpret` | discriminative signal attenuates less than non-discriminative signal | `CLM_8` | diff --git a/docs/scenarios/S-12b.md b/docs/scenarios/S-12b.md deleted file mode 100644 index e31fc32f..00000000 --- a/docs/scenarios/S-12b.md +++ /dev/null @@ -1,40 +0,0 @@ -# S-12b — Two revision chains that pass through one sentence - -Two unrelated lines of enquiry are each narrowed until they read as the same sentence, and the record keeps them apart as two separate claims. - -12 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the effect hold? - Researcher->>R: recordObservations measured - Researcher->>R: recordAnalysis fit - R-->>Researcher: COMP_3 - Researcher->>R: conclude the effect holds - Researcher->>R: reinterpret x2 - Researcher->>R: openEnquiry does the instrument drift? - Researcher->>R: recordObservations measured - Researcher->>R: recordAnalysis fit - R-->>Researcher: COMP_9 - Researcher->>R: conclude the instrument drifts - Researcher->>R: reinterpret x2 -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the effect hold? | `LOE_1` | -| 2 | Researcher | `recordObservations` | measured | `ART_2` | -| 3 | Researcher | `recordAnalysis` | fit | `COMP_3` | -| 4 | Researcher | `conclude` | the effect holds | `COMP_3` | -| 5 | Researcher | `reinterpret` | the effect holds under condition X | `CLM_5` | -| 6 | Researcher | `reinterpret` | the effect holds under condition X in subgroup Y | `CLM_6` | -| 7 | Researcher | `openEnquiry` | does the instrument drift? | `LOE_7` | -| 8 | Researcher | `recordObservations` | measured | `ART_8` | -| 9 | Researcher | `recordAnalysis` | fit | `COMP_9` | -| 10 | Researcher | `conclude` | the instrument drifts | `COMP_9` | -| 11 | Researcher | `reinterpret` | the effect holds under condition X | `CLM_11` | -| 12 | Researcher | `reinterpret` | the instrument drifts above 40 degrees | `CLM_12` | diff --git a/docs/scenarios/S-20.md b/docs/scenarios/S-20.md deleted file mode 100644 index b64aa913..00000000 --- a/docs/scenarios/S-20.md +++ /dev/null @@ -1,36 +0,0 @@ -# S-20 — a finding that settles the proposition neither way - -A re-analysis narrows the disagreement between three tests without resolving it, and the researcher records the claim as undecided rather than calling it either way. - -5 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does T differ from the rewiring control? - Researcher->>R: recordObservations T and the rewiring control, ten seeds - Researcher->>R: recordAnalysis log-scale re-aggregation - R-->>Researcher: COMP_3 - Researcher->>R: conclude T differs from the rewiring control - Researcher->>R: isUndecided EV_4 -``` - -## What moved - -`labkit why CLM_4` answers: - -**CLM_4** is undecided — the findings settle this neither way. - -- because NOT resolved: primary (p=0.037) and sign-flip (p=0.041) still say significant, median (p=0.084) still says not -- narrowed from v1 but not closed; per pre-commitment, no further transformation attempted, reported as genuinely inconclusive at n=10/25 seeds. - - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does T differ from the rewiring control? | `LOE_1` | -| 2 | Researcher | `recordObservations` | T and the rewiring control, ten seeds | `ART_2` | -| 3 | Researcher | `recordAnalysis` | log-scale re-aggregation | `COMP_3` | -| 4 | Researcher | `conclude` | T differs from the rewiring control | `COMP_3` | -| 5 | Researcher | `isUndecided` | EV_4 | `CLM_4` | diff --git a/docs/scenarios/S-24.md b/docs/scenarios/S-24.md deleted file mode 100644 index 73b080e6..00000000 --- a/docs/scenarios/S-24.md +++ /dev/null @@ -1,21 +0,0 @@ -# S-24 — a mistaken act taken back - -A question entered twice by accident is taken back, and the act that undoes it names every handle it retracted. - -2 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: pose does the pruning schedule move convergence, typed twice by acci… - R-->>Researcher: Q_1 - Researcher->>R: undo duplicate entry, wrong wording -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `pose` | does the pruning schedule move convergence, typed twice by accident | `Q_1` | -| 2 | Researcher | `undo` | duplicate entry, wrong wording | `Q_1` | diff --git a/docs/scenarios/S-24b.md b/docs/scenarios/S-24b.md deleted file mode 100644 index 87ae35bf..00000000 --- a/docs/scenarios/S-24b.md +++ /dev/null @@ -1,41 +0,0 @@ -# S-24b — walking back a tree of mistakes - -A whole line of work built on a mistaken question is taken back one act at a time, newest first, and the promotion goes with it. - -12 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: pose does the coating slow corrosion? - R-->>Researcher: Q_1 - Researcher->>R: pursue Q_1 - R-->>Researcher: LOE_2 - Researcher->>R: recordObservations mass loss per coupon - R-->>Researcher: ART_3 - Researcher->>R: recordAnalysis mass-loss comparison - R-->>Researcher: COMP_4 - Researcher->>R: conclude the coating slows corrosion - R-->>Researcher: COMP_4 - Researcher->>R: isConfirmed the check passed - R-->>Researcher: CLM_5 - Researcher->>R: undo x6 -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `pose` | does the coating slow corrosion? | `Q_1` | -| 2 | Researcher | `pursue` | Q_1 | `LOE_2` | -| 3 | Researcher | `recordObservations` | mass loss per coupon | `ART_3` | -| 4 | Researcher | `recordAnalysis` | mass-loss comparison | `COMP_4` | -| 5 | Researcher | `conclude` | the coating slows corrosion | `COMP_4` | -| 6 | Researcher | `isConfirmed` | the check passed | `CLM_5` | -| 7 | Researcher | `undo` | the whole arc was a mistake | `CLM_5` | -| 8 | Researcher | `undo` | the whole arc was a mistake | `COMP_4` | -| 9 | Researcher | `undo` | the whole arc was a mistake | `COMP_4` | -| 10 | Researcher | `undo` | the whole arc was a mistake | `ART_3` | -| 11 | Researcher | `undo` | the whole arc was a mistake | `LOE_2` | -| 12 | Researcher | `undo` | the whole arc was a mistake | `Q_1` | diff --git a/docs/scenarios/S-27.md b/docs/scenarios/S-27.md index de65d802..6789e65b 100644 --- a/docs/scenarios/S-27.md +++ b/docs/scenarios/S-27.md @@ -1,8 +1,8 @@ # S-27 — Why explains every kind -One ordinary arc of work — a question, observations, an analysis, a check, a gate, a note, a review and a decision — and asking why of each of them gets an answer rather than a refusal. +One ordinary arc of work — a question, observations, an analysis, a check, a gate, a note and a decision — and asking why of each of them gets an answer rather than a refusal. -11 acts, one voice. +10 acts, one voice. ```mermaid sequenceDiagram @@ -19,7 +19,6 @@ sequenceDiagram Researcher->>R: declareGate Researcher->>R: evaluateCriterion Researcher->>R: note - Researcher->>R: recordReview the method is sound Researcher->>R: closeEnquiry ``` @@ -36,5 +35,4 @@ sequenceDiagram | 7 | Researcher | `declareGate` | | `GATE_7` | | 8 | Researcher | `evaluateCriterion` | | `CEVAL_8` | | 9 | Researcher | `note` | | `NOTE_9` | -| 10 | Researcher | `recordReview` | the method is sound | `REV_10` | -| 11 | Researcher | `closeEnquiry` | | `LOE_1` | +| 10 | Researcher | `closeEnquiry` | | `LOE_1` | diff --git a/docs/scenarios/S-5.md b/docs/scenarios/S-5.md index c7636d8b..d7fa7bc9 100644 --- a/docs/scenarios/S-5.md +++ b/docs/scenarios/S-5.md @@ -1,6 +1,6 @@ # S-5 — contradiction or dissociation? -Two stages of one programme assert the same sentence with opposite evidence. They turn out to be answering different questions, so this is a dissociation rather than a contradiction. +Two stages of one programme assert the same sentence with opposite evidence. Asked by its words, the sentence names two claims, and each answers about its own question. 10 acts, one voice. diff --git a/docs/scenarios/S-9.md b/docs/scenarios/S-9.md index 69e27f72..e1e79276 100644 --- a/docs/scenarios/S-9.md +++ b/docs/scenarios/S-9.md @@ -1,8 +1,8 @@ # S-9 — the artefact survived; its provenance didn't -A cached construction from an old study is rebuilt. Three of its four parts match their recorded hashes; the fourth has no hash at all, so nobody can check it, and that is not the same as its having come back different. +A cached construction from an old study has one part with no recorded hash, so nobody can check it. The researcher regenerates that part, and the question of what made the original stays open. -7 acts, one voice. +9 acts, one voice. ```mermaid sequenceDiagram @@ -13,6 +13,8 @@ sequenceDiagram Researcher->>R: recordAnalysis stage2-construction R-->>Researcher: COMP_6 Researcher->>R: conclude the accelerated path matches the reference + Researcher->>R: openEnquiry what generated the historical random control? + Researcher->>R: recordObservations randomised control series, regenerated from an inferred algorit… ``` ## The acts, in order @@ -26,3 +28,5 @@ sequenceDiagram | 5 | Researcher | `recordObservations` | randomised control series | `ART_5` | | 6 | Researcher | `recordAnalysis` | stage2-construction | `COMP_6` | | 7 | Researcher | `conclude` | the accelerated path matches the reference | `COMP_6` | +| 8 | Researcher | `openEnquiry` | what generated the historical random control? | `LOE_8` | +| 9 | Researcher | `recordObservations` | randomised control series, regenerated from an inferred algorithm | `ART_9` | diff --git a/docs/scenarios/S-9b.md b/docs/scenarios/S-9b.md index 1c952347..96c0644c 100644 --- a/docs/scenarios/S-9b.md +++ b/docs/scenarios/S-9b.md @@ -13,10 +13,10 @@ sequenceDiagram Researcher->>R: recordAnalysis stage2-construction R-->>Researcher: COMP_3 Researcher->>R: conclude the accelerated path matches the reference - Researcher->>R: recordObservations control series, second pass + Researcher->>R: recordObservations randomised control series for stage 3, generated afresh Researcher->>R: recordAnalysis stage2-construction, second control R-->>Researcher: COMP_6 - Researcher->>R: conclude the second control agrees + Researcher->>R: conclude the accelerated path matches the reference ``` ## The acts, in order @@ -27,6 +27,6 @@ sequenceDiagram | 2 | Researcher | `recordObservations` | randomised control series | `ART_2` | | 3 | Researcher | `recordAnalysis` | stage2-construction | `COMP_3` | | 4 | Researcher | `conclude` | the accelerated path matches the reference | `COMP_3` | -| 5 | Researcher | `recordObservations` | control series, second pass | `ART_5` | +| 5 | Researcher | `recordObservations` | randomised control series for stage 3, generated afresh | `ART_5` | | 6 | Researcher | `recordAnalysis` | stage2-construction, second control | `COMP_6` | -| 7 | Researcher | `conclude` | the second control agrees | `COMP_6` | +| 7 | Researcher | `conclude` | the accelerated path matches the reference | `COMP_6` | diff --git a/docs/scenarios/S-9c.md b/docs/scenarios/S-9c.md deleted file mode 100644 index a45b008b..00000000 --- a/docs/scenarios/S-9c.md +++ /dev/null @@ -1,26 +0,0 @@ -# S-9c — two parts, one name - -One analysis reads two control series recorded under the same name. On a rebuild one matches and one differs, and the report keeps them apart because it identifies parts by reference rather than by name. - -5 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry do the two controls agree? - Researcher->>R: recordObservations x2 - Researcher->>R: recordAnalysis compare-controls - R-->>Researcher: COMP_4 - Researcher->>R: conclude the controls agree -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | do the two controls agree? | `LOE_1` | -| 2 | Researcher | `recordObservations` | the historical series | `ART_2` | -| 3 | Researcher | `recordObservations` | regenerated from an inferred algorithm | `ART_3` | -| 4 | Researcher | `recordAnalysis` | compare-controls | `COMP_4` | -| 5 | Researcher | `conclude` | the controls agree | `COMP_4` | diff --git a/docs/scenarios/S-9e.md b/docs/scenarios/S-9e.md deleted file mode 100644 index 83f34ad5..00000000 --- a/docs/scenarios/S-9e.md +++ /dev/null @@ -1,23 +0,0 @@ -# S-9e — reproducing nothing - -A pure simulation read none of the programme's own data. Asked whether it reproduces, the answer is no: nothing was rebuilt because there was nothing to rebuild. - -3 acts, one voice. - -```mermaid -sequenceDiagram - actor Researcher - participant R as LabKit - Researcher->>R: openEnquiry does the simulation converge? - Researcher->>R: recordAnalysis pure-sim - R-->>Researcher: COMP_2 - Researcher->>R: conclude the simulation converges -``` - -## The acts, in order - -| | who | act | said | recorded | -| --- | --- | --- | --- | --- | -| 1 | Researcher | `openEnquiry` | does the simulation converge? | `LOE_1` | -| 2 | Researcher | `recordAnalysis` | pure-sim | `COMP_2` | -| 3 | Researcher | `conclude` | the simulation converges | `COMP_2` | diff --git a/packages/app-cli/views/analysis.ts b/packages/app-cli/views/analysis.ts deleted file mode 100644 index 15e7df2a..00000000 --- a/packages/app-cli/views/analysis.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Analyses: what rests on them, what re-checked them, how they were read. - */ - -import type { - DependencyReport, - InterpretationHistory, - ReproducibilityReport, - ReproductionReport, - Revision, -} from "@labkit/core-domain"; -import type { Palette } from "../palette"; -import { bullets, partLine } from "./format"; - -export function renderAffects(report: DependencyReport, p: Palette): string { - return [ - p.heading("Claims that would be affected"), - // Id and wording both. A person reading this needs the sentence; a person - // acting on it needs the handle every other command takes. - bullets( - report.claims.map((c) => `(${c.claim}) ${c.asserts}`), - "none found", - ), - "", - p.heading("Lines of enquiry"), - bullets( - report.enquiries.map((e) => `(${e.enquiry}) ${e.pursuing}`), - "none found", - ), - "", - p.heading("Routes walked"), - bullets( - report.routesWalked.map((r) => p.quiet(r)), - "", - ), - "", - p.provisional("This is a lower bound, not a finding of independence: anything"), - p.provisional("connected by a route not listed above is absent from these lists"), - p.provisional("and is not thereby unaffected."), - ].join("\n"); -} - -/** - * A re-run, against what its original read. - */ -export function renderReproduction(report: ReproductionReport, p: Palette): string { - const verdict = report.conclusion === "agrees" ? p.settled : p.contested; - return [ - `${p.heading(report.verificationMethod)} ${`(${report.verification})`}`, - ` re-checking ${`(${report.of})`} ${report.ofMethod}`, - ` the two runs' findings ${verdict(report.conclusion)} — this ${verdict(report.bearing)} confidence`, - "", - p.heading("The re-run read"), - bullets( - report.verificationRead.map((a) => partLine(a, p)), - p.untested("nothing on the record"), - ), - "", - p.heading("The original read"), - bullets( - report.ofRead.map((a) => partLine(a, p)), - p.untested("nothing on the record"), - ), - report.differs.length - ? `\n${p.contested("Differing")}\n${bullets( - report.differs.map((d) => `${partLine(d.what, p)} — ${p.contested(d.standing)}`), - "", - )}` - : `\n${p.settled("Nothing differs in what the two runs read.")}`, - "", - p.provisional("This does not say the original was reproduced. Whether reading the same"), - p.provisional("records is the same execution depends on what the method does, and the"), - p.provisional("record does not know that."), - ].join("\n"); -} - -/** - * Whether an analysis can be accounted for from what it read. - */ -export function renderReproducibility(report: ReproducibilityReport, p: Palette): string { - return [ - `${report.analysis} — ${report.reproducible ? p.settled("accounted for") : p.contested("not accounted for")}`, - "", - p.settled("Rebuilt and identical"), - bullets( - report.exact.map((a) => partLine(a, p)), - p.untested("nothing"), - ), - "", - p.contested("Rebuilt and different"), - bullets( - report.differing.map((a) => partLine(a, p)), - p.untested("nothing"), - ), - "", - // Provisional, not contested: the record declining to answer is not the - // same as answering no, which is the distinction this bucket exists for. - p.provisional("Unverifiable (the record kept no hash, so nothing can be said either way)"), - bullets( - report.unverifiable.map((a) => partLine(a, p)), - p.untested("nothing"), - ), - "", - p.untested("Not rebuilt"), - bullets( - report.notRebuilt.map((a) => partLine(a, p)), - p.untested("nothing"), - ), - ].join("\n"); -} - -/** - * How a claim's current reading was arrived at. - */ -export function renderInterpretation(history: InterpretationHistory, p: Palette): string { - const revision = (r: Revision): string => - [ - r.revision, - ` ${p.provisional("withdrew")}: ${r.previously.map((c) => `"${c.asserts}" ${`(${c.claim})`}`).join("; ")}`, - ` now claims: "${r.nowClaims.asserts}" ${`(${r.nowClaims.claim})`}`, - ` because: ${r.reason}`, - r.restingOnTheOldReading.length - ? ` ${p.contested("resting on the old reading")}: ${r.restingOnTheOldReading - .map((q) => `"${q.asks}" ${`(${q.question})`}`) - .join("; ")}` - : "", - ] - .filter(Boolean) - .join("\n"); - return [ - `${p.heading(`Now claims "${history.nowClaims.asserts}"`)} ${`(${history.nowClaims.claim})`}`, - "", - p.heading("Originally"), - bullets( - history.originally.map((c) => `${`(${c.claim})`} ${c.asserts}`), - p.untested("nothing was withdrawn to reach this reading"), - ), - "", - p.heading("Revisions"), - history.revisions.length - ? history.revisions.map(revision).join("\n\n") - : ` ${p.untested("none — this reading has not been narrowed")}`, - ].join("\n"); -} diff --git a/packages/app-cli/views/enquiry.ts b/packages/app-cli/views/enquiry.ts deleted file mode 100644 index 2ef40f2d..00000000 --- a/packages/app-cli/views/enquiry.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Questions and the lines of enquiry under them. - */ - -import type { EnquiryRef, EnquiryStatus, QuestionOrigin, QuestionRef } from "@labkit/core-domain"; -import type { Palette } from "../palette"; -import { bullets } from "./format"; - -/** An enquiry's standing, separate from the standing of its question. */ -export function renderEnquiry(status: EnquiryStatus, p: Palette): string { - const q = status.question; - const standing = status.open - ? q?.acceptedBecause - ? p.provisional("open — its question is accepted as unresolved") - : p.untested("open") - : p.settled(`closed — ${status.closure}`); - return [ - `${p.heading(status.pursuing)} ${`(${status.enquiry})`}`, - ` ${standing}`, - status.contributed.length - ? ` produced ${status.contributed.length} finding${status.contributed.length === 1 ? "" : "s"}` - : ` ${p.untested("has produced nothing yet")}`, - status.bearing ? ` the answer ${status.bearing} the question` : "", - status.restsOn ? ` resting on ${status.restsOn} work` : "", - "", - q ? `Pursuing "${q.asks}" ${`(${q.question})`}` : p.untested("Pursuing nothing on the record"), - q?.acceptedBecause ? ` accepted because: ${q.acceptedBecause}` : "", - q?.reopensIf ? ` reopens if: ${q.reopensIf}` : "", - q?.acceptedInLightOf?.length - ? ` -The question's acceptance rests on -${bullets( - q.acceptedInLightOf.map((e) => `(${e.evidence}) ${e.states}`), - "", -)}` - : "", - status.contributed.length - ? ` -This enquiry's findings -${bullets( - status.contributed.map((e) => `(${e.evidence}) ${e.states}`), - "", -)}` - : "", - status.evidence.length - ? ` -This enquiry's closure rests on -${bullets( - status.evidence.map((e) => `(${e.evidence}) ${e.states}`), - "", -)}` - : "", - ] - .filter(Boolean) - .join("\n"); -} - -export function renderPursuits(enquiries: EnquiryRef[], question: QuestionRef, p: Palette): string { - return [ - p.heading(`Lines of enquiry pursuing ${question}`), - bullets( - enquiries.map((e) => e), - p.untested("none — the question is on the books and nothing has been started on it"), - ), - "", - p.quiet("`labkit enquiry ` says whether one is still open and what it has produced."), - ].join("\n"); -} - -/** - * Where a question came from. - */ -export function renderOrigin( - origin: QuestionOrigin | null, - question: QuestionRef, - p: Palette, -): string { - if (!origin) return [`${question} was posed directly.`].join("\n"); - if (origin.kind === "noted") - return [`${question} came out of a note ${`(${origin.from})`}`, ` "${origin.said}"`, ""].join( - "\n", - ); - return [ - `${question} narrowed "${origin.said}" ${`(${origin.from})`}`, - ` because: ${origin.reason}`, - "", - p.heading("Known at that moment"), - bullets( - origin.knownAtTheTime.map((f) => `${`(${f.evidence})`} ${f.states}`), - p.untested("nothing"), - ), - "", - p.quiet("As it stood at the sharpening. Later evidence is not here."), - ].join("\n"); -} diff --git a/packages/app-cli/views/format.ts b/packages/app-cli/views/format.ts index d1dcafeb..31ff57a4 100644 --- a/packages/app-cli/views/format.ts +++ b/packages/app-cli/views/format.ts @@ -2,7 +2,7 @@ * The shared shapes every view is built out of. */ -import type { IdentifiedArtefact, QuestionStanding } from "@labkit/core-domain"; +import type { QuestionStanding } from "@labkit/core-domain"; import type { Palette } from "../palette"; export function bullets(items: string[], empty: string): string { @@ -16,13 +16,6 @@ export function questionLines(questions: QuestionStanding[]): string[] { return questions.map((q) => `${`(${q.question})`} ${q.asks}`); } -export function partLine(a: IdentifiedArtefact, p: Palette): string { - // `invalidated` is contested rather than quiet: the record has actively - // withdrawn this part, which is a finding and not an absence. - const flag = a.invalidated ? ` ${p.contested("invalidated")}` : ""; - return `${`(${a.part})`} ${a.name}${flag}`; -} - /** * A row of aligned columns, with the prose last. * diff --git a/packages/app-cli/views/gates.ts b/packages/app-cli/views/gates.ts index 79122d2a..ffd4b250 100644 --- a/packages/app-cli/views/gates.ts +++ b/packages/app-cli/views/gates.ts @@ -2,138 +2,9 @@ * Gates, the conditions bound to them, and the work they protect. */ -import type { - AmendmentRecord, - ConditionHistory, - CheckStatus, - CriterionRef, - DesignHistory, - GateRef, - GateStatus, - ListedGate, - ListedWork, - TaskContract, -} from "@labkit/core-domain"; +import type { ListedGate, ListedWork } from "@labkit/core-domain"; import type { Palette } from "../palette"; -import { bullets, relativeAge, rows } from "./format"; - -/** - * A gate, itemised per condition. - */ -export function renderGate(status: GateStatus, p: Palette): string { - // The verdict's own sentence is not here -- see `DecidingEvaluation`. The - // handle is, so a reader can reach it. - const check = (c: CheckStatus): string[] => { - const about = c.decidedBy?.about ? ` about ${c.decidedBy.about}` : ""; - const decided = c.decidedBy - ? ` decided ${c.decidedBy.outcome === "pass" ? "passed" : "failed"}${about} ${p.quiet(c.decidedBy.at)} ${`(${c.decidedBy.evaluation})`}` - : ""; - return [c.state, `(${c.criterion})`, `${c.proposition}${decided}`]; - }; - return [ - `${status.gate} — ${status.state}${status.everFailed ? ` ${p.contested("(has failed at least once)")}` : ""}`, - ` consequence: ${status.consequence}`, - status.closure - ? ` closed by ${status.closure.decision}\n because: ${status.closure.because}` - : "", - "", - `${p.heading("Conditions by state")}\n${bullets( - rows( - (Object.entries(status.counts) as [CheckStatus["state"], number][]) - .filter(([, n]) => n > 0) - .map(([s, n]) => [s, String(n)]), - ), - "none", - )}`, - "", - p.heading("Conditions"), - bullets(rows(status.checks.map(check)), "none"), - status.unmet.length - ? `\nNot currently met\n${bullets( - rows(status.unmet.map((u) => [`(${u.criterion})`, u.requires])), - "", - )}` - : "", - status.gating.length - ? `\nGating\n${bullets(rows(status.gating.map((w) => [`(${w.work})`, w.objective])), "")}` - : "", - ] - .filter(Boolean) - .join("\n"); -} - -export function renderCriteria(criteria: CriterionRef[], gate: GateRef, p: Palette): string { - return [ - p.heading(`Conditions governing ${gate}`), - bullets( - criteria.map((c) => c), - p.untested("none — this gate is bound to no prespecified condition"), - ), - "", - p.quiet("`labkit gate` gives the same conditions with their wording and standing."), - ].join("\n"); -} - -/** - * How a gate's conditions reached their current wording. - */ -export function renderDesign(history: DesignHistory, p: Palette): string { - const amendment = (a: AmendmentRecord): string => - [ - `${`(${a.amendment})`} ${a.nature}`, - ` was: ${a.replaced.requires}`, - ` now: ${a.nowRequires.requires}`, - ` because: ${a.reason}`, - a.citing.length ? ` citing: ${a.citing.map((f) => f.states).join("; ")}` : "", - a.rerun.length - ? ` ${p.contested("needs re-running")}: ${a.rerun.map((w) => `${`(${w.work})`} ${w.objective}`).join("; ")}` - : "", - ] - .filter(Boolean) - .join("\n"); - const condition = (c: ConditionHistory): string => - [ - `${c.criterion}`, - ` originally: ${c.originally.requires}`, - ` now requires: ${c.nowRequires.requires}`, - "", - c.amendments.length - ? c.amendments - .map((a) => - amendment(a) - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ) - .join("\n\n") - : ` ${p.untested("not amended — the condition still reads as it was first stated")}`, - ].join("\n"); - return [ - history.gate, - "", - p.heading("Conditions"), - history.conditions.map(condition).join("\n\n"), - ].join("\n"); -} - -/** - * A planned piece of work. - */ -export function renderContract(contract: TaskContract, p: Palette): string { - return [ - `${p.heading(contract.objective)} ${`(${contract.work})`}`, - ` meeting it means: ${contract.acceptance}`, - ...(contract.addressing - ? [ - ` addressing: ${contract.addressing.enquiry} "${contract.addressing.pursuing}"`, - ` pursuing: ${contract.addressing.question} "${contract.addressing.asks}"`, - ] - : []), - "", - p.heading("May read (not enforced)"), - bullets(contract.mayRead, p.untested("nothing named")), - ].join("\n"); -} +import { relativeAge, rows } from "./format"; /** * Every gate, one per line, with its state. diff --git a/packages/app-cli/views/knowledge.ts b/packages/app-cli/views/knowledge.ts index e4c3cb83..a4b3b262 100644 --- a/packages/app-cli/views/knowledge.ts +++ b/packages/app-cli/views/knowledge.ts @@ -6,13 +6,8 @@ import type { AcceptedQuestion, AnsweredQuestion, ConcludedClaim, - ConflictSide, - ConflictVerdict, Explanation, - How, - HistoricalSurvey, KnowledgeSurvey, - QuestionStanding, SearchGroup, SupportExplanation, Verdict, @@ -83,27 +78,6 @@ export function renderKnown(survey: KnowledgeSurvey, p: Palette): string { .replace(/\n+$/, ""); } -export function renderHistorical(survey: HistoricalSurvey, p: Palette): string { - const list = (qs: QuestionStanding[]) => bullets(questionLines(qs), "nothing"); - return [ - p.heading(`As of ${survey.at}:`), - "", - p.settled("Established (resolved on a confirmed finding)"), - list(survey.established), - "", - p.provisional("Provisional (resolved, but on unconfirmed work)"), - list(survey.provisional), - "", - p.provisional("Accepted as unresolved"), - list(survey.accepted), - "", - p.untested("Open"), - list(survey.open), - "", - p.quiet("A question posed after this instant is absent, not open."), - ].join("\n"); -} - /** * How each verdict reads on the page, and in which colour. */ @@ -317,47 +291,3 @@ export function renderSearch(groups: SearchGroup[], text: string, p: Palette): s .filter(Boolean) .join("\n"); } - -/** - * Whether two conclusions disagree. - */ -export function renderConflict(verdict: ConflictVerdict, p: Palette): string { - const side = (s: ConflictSide): string => - [ - `"${s.proposition}" ${`(${s.claim})`}`, - ` asking "${s.asks}" ${`(${s.question})`}`, - s.supportedBy.length - ? ` ${p.settled("supported by")}: ${s.supportedBy.map((f) => f.states).join("; ")}` - : "", - s.challengedBy.length - ? ` ${p.contested("challenged by")}: ${s.challengedBy.map((f) => f.states).join("; ")}` - : "", - ] - .filter(Boolean) - .join("\n"); - const verdictLine: Record = { - contradiction: p.contested("Contradiction — these disagree, and about the same thing."), - dissociation: p.provisional( - "Dissociation — these are about different things, so they do not disagree" + - (verdict.differsBy ? `; they differ by ${verdict.differsBy}.` : "."), - ), - corroboration: p.settled("Corroboration — these agree."), - }; - return [verdictLine[verdict.relation], "", verdict.sides.map(side).join("\n\n")].join("\n"); -} - -export function renderHow(how: How, p: Palette): string { - if (how.steps.length === 0) return p.untested("No steps."); - const lines = how.steps.map((s: How["steps"][number]) => { - let line = `${s.handle} ${s.what}`; - if (s.superseded) { - line += p.contested(" (superseded"); - if (s.successor) line += ` → ${s.successor}`; - line += ")"; - } - if (s.because) line += ` — ${s.because}`; - if (s.seq !== undefined) line += ` [seq ${s.seq}]`; - return line; - }); - return [p.heading(`How ${how.subject} — ${how.steps.length}`), ...lines].join("\n"); -} diff --git a/packages/app-cli/views/learned.ts b/packages/app-cli/views/learned.ts deleted file mode 100644 index 454a9578..00000000 --- a/packages/app-cli/views/learned.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * What the programme found out, under the question it was asked for. - */ - -import type { Learned } from "@labkit/core-domain"; -import type { Palette } from "../palette"; -import { rows } from "./format"; - -export function renderLearned(report: Learned, p: Palette): string { - if (report.questions.length === 0) return "nothing"; - const questions = report.questions - .filter((q) => q.found.length > 0) - .map((q) => - [ - `${q.question} ${q.asks}`, - ...rows( - q.found.flatMap((f) => [ - [` ${f.bearing === "challenges" ? "challenged" : "supported"}`, f.claim, f.asserts], - ["", ` ${f.finding}`, p.quiet(f.states)], - ]), - ), - ].join("\n"), - ); - const unanswered = report.questions.filter((q) => q.found.length === 0); - return [ - p.heading(`Learned — ${report.found} across ${questions.length} questions`), - "", - questions.join("\n\n"), - ...(unanswered.length - ? ["", p.untested(`Nothing found yet under ${unanswered.map((q) => q.question).join(", ")}`)] - : []), - ].join("\n"); -} diff --git a/packages/app-cli/vocabulary.ts b/packages/app-cli/vocabulary.ts index d98eb18f..94871958 100644 --- a/packages/app-cli/vocabulary.ts +++ b/packages/app-cli/vocabulary.ts @@ -22,8 +22,6 @@ export const READING: Readonly> = { answered: "settled", supported: "settled", supports: "settled", - agrees: "settled", - corroboration: "settled", confirmatory: "settled", "carried-out": "settled", observed: "settled", @@ -34,8 +32,6 @@ export const READING: Readonly> = { blocked: "contested", challenged: "contested", challenges: "contested", - disagrees: "contested", - contradiction: "contested", "standard-unmet": "contested", // Nothing has looked. @@ -50,8 +46,6 @@ export const READING: Readonly> = { incomplete: "untested", planned: "untested", waiting: "untested", - "unrecorded-in-the-original": "untested", - "not-used-by-the-re-run": "untested", // Answered, but qualified. provisional: "provisional", @@ -62,21 +56,15 @@ export const READING: Readonly> = { exploratory: "provisional", "drawn-across": "provisional", claimed: "provisional", - changed: "provisional", - sharpened: "provisional", - noted: "provisional", mechanical: "provisional", scientific: "provisional", prespecification: "provisional", - dissociation: "provisional", - raises: "provisional", - lowers: "provisional", }; /** * The words safe to colour wherever they appear. * - * The rest have a reading but are left alone: `no`, `pass`, `changed`, + * The rest have a reading but are left alone: `no`, `pass`, * `supports` and their like turn up in ordinary sentences, and painting one * red told the reader a gate's description was a verdict. */ diff --git a/packages/app-mcp/schemas.ts b/packages/app-mcp/schemas.ts index 34f1560e..1a77b522 100644 --- a/packages/app-mcp/schemas.ts +++ b/packages/app-mcp/schemas.ts @@ -9,24 +9,14 @@ export { whatHappened as whatHappenedSchema, domainEvent as domainEventSchema, knowledgeSurvey as knowledgeSurveySchema, - historicalSurvey as historicalSurveySchema, supportExplanation as supportExplanationSchema, - dependencyReport as dependencyReportSchema, enquiryQuestion as enquiryQuestionSchema, enquiryStatus as enquiryStatusSchema, enquiryInContext as enquiryInContextSchema, - designHistory as designHistorySchema, - interpretationHistory as interpretationHistorySchema, - reproductionReport as reproductionReportSchema, - questionOrigin as questionOriginSchema, - originOf as originOfSchema, taskContract as taskContractSchema, - criteriaGoverning as criteriaGoverningSchema, gateStatus as gateStatusSchema, criterionStanding as criterionStandingSchema, explanation as explanationSchema, - conflictVerdict as conflictVerdictSchema, - reproducibilityReport as reproducibilityReportSchema, questionRef as questionRefSchema, enquiryRef as enquiryRefSchema, observationsRef as observationsRefSchema, @@ -41,24 +31,16 @@ export { pursued as pursuedSchema, openedEnquiry as openedEnquirySchema, recordedObservations as recordedObservationsSchema, - sharpenedQuestion as sharpenedQuestionSchema, synthesised as synthesisedSchema, - recordedReview as recordedReviewSchema, closedEnquiry as closedEnquirySchema, stoppedWork as stoppedWorkSchema, - closedGate as closedGateSchema, plannedWork as plannedWorkSchema, statedCriterion as statedCriterionSchema, declaredGate as declaredGateSchema, evaluatedCriterion as evaluatedCriterionSchema, acceptedAsUnresolved as acceptedAsUnresolvedSchema, restated as restatedSchema, - undone as undoneSchema, - verificationReport as verificationReportSchema, amendmentReport as amendmentReportSchema, - replacementReport as replacementReportSchema, - reinterpretationReport as reinterpretationReportSchema, - pursuits as pursuitsSchema, registeredSession as registeredSessionSchema, gateList as gateListSchema, workList as workListSchema, diff --git a/packages/core-domain/commands.ts b/packages/core-domain/commands.ts index 0ff6824e..7dcccea4 100644 --- a/packages/core-domain/commands.ts +++ b/packages/core-domain/commands.ts @@ -47,13 +47,6 @@ function citedBasisString() { const bearing = z.enum(["supports", "challenges"]); const standing = z.enum(["exploratory", "confirmatory"]); -const conclusion = z.object({ - proposition: z.string(), - finding: z.string(), - bearing: bearing.optional(), - standing: standing.optional(), -}); - /** * `synthesise` — one finding drawn across others, running nothing new. */ @@ -106,14 +99,6 @@ export const noteSupersedesCommand = z.object({ export const noteCommand = z.union([noteSupersedesCommand, noteMintCommand]); export type NoteCommand = z.infer; -/** `sharpen` — narrow a question into a more precise one, recording why. */ -export const sharpenCommand = z.object({ - from: refString("question"), - into: z.string(), - because: z.string(), -}); -export type SharpenCommand = z.infer; - /** `recordObservations` — put measurement on the record, without analysing it. */ export const recordObservationsCommand = z.object({ enquiry: refString("enquiry"), @@ -135,13 +120,6 @@ export const recordAnalysisCommand = z.object({ }); export type RecordAnalysisCommand = z.infer; -/** `recordReview` — a verdict on an analysis, which a later retraction can rest on. */ -export const recordReviewCommand = z.object({ - of: refString("analysis"), - verdict: z.string(), -}); -export type RecordReviewCommand = z.infer; - /** `closeEnquiry` — answered, or abandoned when `answeredBy` is absent. */ export const closeEnquiryCommand = z.object({ enquiry: refString("enquiry"), @@ -149,13 +127,6 @@ export const closeEnquiryCommand = z.object({ }); export type CloseEnquiryCommand = z.infer; -/** Close one gate without pretending its checks passed. */ -export const closeGateCommand = z.object({ - gate: refString("gate"), - because: z.string(), -}); -export type CloseGateCommand = z.infer; - /** Planned work somebody decided not to do. */ export const stopWorkCommand = z.object({ work: refString("work"), @@ -199,16 +170,6 @@ export const evaluateCriterionCommand = z.object({ }); export type EvaluateCriterionCommand = z.infer; -/** `reverify` — re-run a historical analysis under current observations. Not reproduction (S-10). */ -export const reverifyCommand = z.object({ - historical: refString("analysis"), - enquiry: refString("enquiry").optional(), - method: z.string(), - under: z.array(inputRefString()), - concludes: conclusion, -}); -export type ReverifyCommand = z.infer; - /** `acceptAsUnresolved` — leave a question open on purpose, with the condition that reopens it (S-14). */ export const acceptAsUnresolvedCommand = z.object({ enquiry: refString("enquiry"), @@ -240,61 +201,6 @@ export const concludeCommand = z.object({ }); export type ConcludeCommand = z.infer; -/** - * One of a replacement's conclusions, and which earlier finding it stands in for. - */ -export const replacementConclusion = conclusion.extend({ - replacing: supersededRefString().optional(), -}); -export type ReplacementConclusion = z.infer; - -/** - * `keep` — revise an analysis by naming the conclusions that survive. - */ -export const keepCommand = z.object({ - keeping: z.array(refString("claim")), - because: refString("review"), - method: z.string(), - from: z.array(inputRefString()).optional(), -}); -export type KeepCommand = z.infer; - -/** - * `replaceAnalysis` — record a corrected analysis in place of a defective one, and the lineage - * between them. - */ -export const replaceAnalysisCommand = z.object({ - supersedes: refString("analysis"), - because: refString("review"), - method: z.string(), - from: z.array(inputRefString()).optional(), -}); -export type ReplaceAnalysisCommand = z.infer; - -/** `reinterpret` — narrow what a claim is taken to mean, without re-running anything. */ -export const reinterpretCommand = z.object({ - of: refString("claim"), - as: z.string(), - because: z.string(), -}); -export type ReinterpretCommand = z.infer; - -/** `promote` — move a finding from scratch to citable (S-18). */ -export const promoteCommand = z.object({ - claim: refString("claim"), - because: z.string(), -}); -export type PromoteCommand = z.infer; - -/** - * `isUndecided` — a finding that settles the proposition neither way. - */ -export const claimIsUndecidedCommand = z.object({ - claim: refString("claim"), - because: refString("evidence"), -}); -export type ClaimIsUndecidedCommand = z.infer; - /** * `isConfirmed` — a finding others may build on. */ @@ -304,41 +210,22 @@ export const claimIsConfirmedCommand = z.object({ }); export type ClaimIsConfirmedCommand = z.infer; -/** - * `undo` — takes back a mistaken act by naming the event it recorded. - */ -export const undoCommand = z.object({ - event: z.number(), - because: z.string(), -}); -export type UndoCommand = z.infer; - /** Every command the write surface takes. What an act was asked to do. */ export type Command = | AcceptAsUnresolvedCommand | AmendDesignCommand | CloseEnquiryCommand - | CloseGateCommand | ConcludeCommand | DeclareGateCommand | EvaluateCriterionCommand | ClaimIsConfirmedCommand - | ClaimIsUndecidedCommand - | KeepCommand | NoteCommand | OpenEnquiryCommand | PlanWorkCommand | PoseCommand - | PromoteCommand | PursueCommand | RecordAnalysisCommand | RecordObservationsCommand | StopWorkCommand - | RecordReviewCommand - | ReinterpretCommand - | ReplaceAnalysisCommand - | ReverifyCommand - | SharpenCommand | StateCriterionCommand - | SynthesiseCommand - | UndoCommand; + | SynthesiseCommand; diff --git a/packages/core-domain/core.ts b/packages/core-domain/core.ts index 06c7bcc5..2428f362 100644 --- a/packages/core-domain/core.ts +++ b/packages/core-domain/core.ts @@ -20,15 +20,12 @@ import type { ClaimRef, ClaimStanding, DecisionRef, - AnalysisRef, EnquiryRef, EvidenceRef, GateRef, - QuestionRef, WorkRef, ConfirmatoryResult, ReplacementClaim, - DecidedQuestion, GatedWork, Ref, } from "./report"; @@ -146,37 +143,6 @@ export class SessionCore { return found ? { kind: "direct", ...found } : undefined; } - /** What a claim asserts. */ - protected async assertedBy(claim: ClaimRef): Promise { - const rows = await this.graph.query( - `MATCH (c:Claim {natural_id: $id}) RETURN c`, - { c: vertexProps<{ name: string }>() }, - { id: claim }, - ); - return rows[0]?.c.name; - } - - /** The single finding by which an analysis concluded something about one proposition. */ - protected async findingFor( - analysis: AnalysisRef, - proposition: IndexedString, - ): Promise { - const rows = await this.graph.query( - `MATCH (:Computation {natural_id: $analysis})<-[:USES]-(u:EvidenceUnit)-[:PRODUCES]->(e:Evidence) - OPTIONAL MATCH (e)-[:SUPPORTS]->(sc:Claim {name: $proposition}) - OPTIONAL MATCH (e)-[:CHALLENGES]->(cc:Claim {name: $proposition}) - RETURN e, sc, cc`, - { - e: vertexProps<{ natural_id: string }>(), - sc: optional(vertexProps<{ name: string }>()), - cc: optional(vertexProps<{ name: string }>()), - }, - { analysis: analysis, proposition }, - ); - const found = rows.find((r) => r.sc !== null || r.cc !== null); - return found ? ref("evidence", found.e.natural_id) : undefined; - } - /** * Restricts a claim traversal to one line of enquiry, when the caller named * one. Empty when they did not — a sentence asserted in a single scope needs @@ -376,8 +342,8 @@ export class SessionCore { const acted = ref("decision", row.replaced.natural_id); if (!entry.by.includes(acted)) entry.by.push(acted); // By handle: two successors phrased alike are two records, and this list is what a - // refusal names. **It can be empty on a withdrawn claim.** `replaceAnalysis` supersedes a - // claim and mints the replacement's conclusions without pairing them, so the decision + // refusal names. **It can be empty on a withdrawn claim.** A recorded `replaceAnalysis` + // supersedes a claim without pairing it to the replacement's conclusions, so the decision // `MOTIVATES` the new *analysis* and no new claim. const next = row.successor; if (!next) continue; @@ -447,34 +413,6 @@ export class SessionCore { /** Questions closed on the strength of a proposition — what a reinterpretation puts at risk. */ - protected async decidedOnTheStrengthOf(scope: { - proposition: IndexedString; - enquiry?: EnquiryRef; - }): Promise { - // Keyed by id. Two identically-worded questions are two questions, and - // neither is resolvable by comparing text. - const asked = new Map(); - // Both bearings: a question can be settled "no" on a finding that - // challenges the proposition, and that closure rests on this reading just - // as much as a supporting one does. - for (const bearing of ["SUPPORTS", "CHALLENGES"] as const) { - const rows = await this.graph.query( - `MATCH (d:Decision)-[:BASED_ON]->(e:Evidence)-[:${bearing}]->(:Claim {name: $name}) - MATCH (u:EvidenceUnit)-[:PRODUCES]->(e) - ${this.withinScope(scope)} - MATCH (d)-[:CLOSES]->(loe:LineOfEnquiry)<-[:MOTIVATES]-(q:Question) - RETURN q`, - { q: vertexProps<{ name: string; natural_id: string }>() }, - { name: scope.proposition, ...this.scopeParams(scope) }, - ); - for (const row of rows) { - const question = ref("question", row.q.natural_id); - asked.set(question, { question, asks: row.q.name }); - } - } - return [...asked.values()].sort((a, b) => byHandle(a.question, b.question)); - } - protected async scopeOf( claim: ClaimRef, ): Promise<{ proposition: IndexedString; enquiry?: EnquiryRef }> { diff --git a/packages/core-domain/events.ts b/packages/core-domain/events.ts index e053fc52..317ebf66 100644 --- a/packages/core-domain/events.ts +++ b/packages/core-domain/events.ts @@ -200,7 +200,7 @@ export const touchedIn = (event: DomainEvent): string[] => [ ), ]; -/** Every handle an act retracted. `undo` writes these and nothing else. */ +/** Every handle an act retracted. Only an `undo` event carries these. */ export const retractedIn = (event: DomainEvent): string[] => event.changes.flatMap((c) => c.change === "NodePropsChanged" && (c.after as { retracted?: boolean }).retracted === true diff --git a/packages/core-domain/index.ts b/packages/core-domain/index.ts index 2ab96ee5..b001b572 100644 --- a/packages/core-domain/index.ts +++ b/packages/core-domain/index.ts @@ -7,7 +7,6 @@ export { WriteSurface, type ResearchWrites, type Operation, - type RetiredOperation, } from "./write"; export { SessionCore } from "./core"; export { openRecord } from "./open"; @@ -53,28 +52,17 @@ export { notesQuery, gateListQuery, workListQuery, - knownAtQuery, searchQuery, claimsAssertingQuery, - pursuitsOfQuery, - originOfQuery, gateStatusQuery, - criteriaGoverningQuery, - designHistoryQuery, contractForQuery, stoppedWorkQuery, enquiryStatusQuery, enquiryInContextQuery, whySupportedQuery, - interpretationHistoryQuery, - doTheseConflictQuery, - reproductionOfQuery, analysisRevisionQuery, - reproducibilityOfQuery, criterionStandingQuery, whyQuery, - howQuery, - whatDependsOnQuery, neighboursOfQuery, proseForQuery, reachableQuery, @@ -85,28 +73,17 @@ export type { NotesQuery, GateListQuery, WorkListQuery, - KnownAtQuery, SearchQuery, ClaimsAssertingQuery, - PursuitsOfQuery, - OriginOfQuery, GateStatusQuery, - CriteriaGoverningQuery, - DesignHistoryQuery, ContractForQuery, StoppedWorkQuery, EnquiryStatusQuery, EnquiryInContextQuery, WhySupportedQuery, - InterpretationHistoryQuery, - DoTheseConflictQuery, - ReproductionOfQuery, AnalysisRevisionQuery, - ReproducibilityOfQuery, CriterionStandingQuery, WhyQuery, - HowQuery, - WhatDependsOnQuery, NeighboursOfQuery, ProseForQuery, ReachableQuery, @@ -124,19 +101,9 @@ export type { AcceptedQuestion, AnsweredQuestion, KnowledgeSurvey, - HistoricalSurvey, - QuestionOrigin, EventPage, ListedNote, AmendmentReport, - AmendmentRecord, - ConditionHistory, - DesignHistory, - ReinterpretationReport, - Revision, - InterpretationHistory, - ConflictSide, - ConflictVerdict, TaskContract, Addressing, Conclusion, @@ -152,18 +119,11 @@ export type { Condition, ConclusionRef, ChangedConclusion, - ReplacementReport, UnaffectedRecord, - DependencyReport, IdentifiedArtefact, - ReproductionReport, - ReproducibilityReport, SupportExplanation, Verdict, GateStatus, - Learned, - LearnedFinding, - LearnedUnderQuestion, ListedAnalysis, ListedClaim, ListedCriterion, @@ -187,36 +147,22 @@ export type { EnquiryExplanation, GateExplanation, Standing, - How, - HowStep, } from "./report"; // Report codecs are the single runtime source for MCP and CLI output. export { claimsAsserting, search, notes, - how, - howStep, whatHappened, knowledgeSurvey, - historicalSurvey, supportExplanation, - dependencyReport, enquiryQuestion, enquiryStatus, enquiryInContext, - designHistory, - interpretationHistory, - reproductionReport, - questionOrigin, - originOf, taskContract, - criteriaGoverning, gateStatus, criterionStanding, explanation, - conflictVerdict, - reproducibilityReport, questionRef, enquiryRef, observationsRef, @@ -231,24 +177,16 @@ export { pursued, openedEnquiry, recordedObservations, - sharpenedQuestion, synthesised, - recordedReview, closedEnquiry, stoppedWork, - closedGate, plannedWork, statedCriterion, declaredGate, evaluatedCriterion, acceptedAsUnresolved, restated, - undone, - verificationReport, amendmentReport, - replacementReport, - reinterpretationReport, - pursuits, registeredSession, gateList, workList, @@ -263,21 +201,13 @@ export type { Command, PursueCommand, NoteCommand, - SharpenCommand, RecordObservationsCommand, RecordAnalysisCommand, - ReplacementConclusion, - RecordReviewCommand, CloseEnquiryCommand, PlanWorkCommand, DeclareGateCommand, EvaluateCriterionCommand, - ReverifyCommand, AcceptAsUnresolvedCommand, AmendDesignCommand, - ReplaceAnalysisCommand, - ReinterpretCommand, - ClaimIsUndecidedCommand, ClaimIsConfirmedCommand, - PromoteCommand, } from "./commands"; diff --git a/packages/core-domain/queries.ts b/packages/core-domain/queries.ts index 9ce4e368..d8f61d5a 100644 --- a/packages/core-domain/queries.ts +++ b/packages/core-domain/queries.ts @@ -39,11 +39,6 @@ export const workListQuery = z.object({ }); export type WorkListQuery = z.infer; -export const knownAtQuery = z.object({ - at: z.iso.datetime({ offset: true }), -}); -export type KnownAtQuery = z.infer; - export const searchQuery = z.object({ text: z.string(), }); @@ -54,31 +49,11 @@ export const claimsAssertingQuery = z.object({ }); export type ClaimsAssertingQuery = z.infer; -export const pursuitsOfQuery = z.object({ - question: refString("question"), -}); -export type PursuitsOfQuery = z.infer; - -export const originOfQuery = z.object({ - question: refString("question"), -}); -export type OriginOfQuery = z.infer; - export const gateStatusQuery = z.object({ gate: refString("gate"), }); export type GateStatusQuery = z.infer; -export const criteriaGoverningQuery = z.object({ - gate: refString("gate"), -}); -export type CriteriaGoverningQuery = z.infer; - -export const designHistoryQuery = z.object({ - gate: refString("gate"), -}); -export type DesignHistoryQuery = z.infer; - export const contractForQuery = z.object({ work: refString("work"), }); @@ -104,33 +79,11 @@ export const whySupportedQuery = z.object({ }); export type WhySupportedQuery = z.infer; -export const interpretationHistoryQuery = z.object({ - claim: refString("claim"), -}); -export type InterpretationHistoryQuery = z.infer; - -export const doTheseConflictQuery = z.object({ - a: refString("claim"), - b: refString("claim"), -}); -export type DoTheseConflictQuery = z.infer; - -export const reproductionOfQuery = z.object({ - verification: refString("analysis"), -}); -export type ReproductionOfQuery = z.infer; - export const analysisRevisionQuery = z.object({ analysis: refString("analysis"), }); export type AnalysisRevisionQuery = z.infer; -export const reproducibilityOfQuery = z.object({ - analysis: refString("analysis"), - rebuilt: z.array(z.object({ part: refString("observations"), hash: z.string() })), -}); -export type ReproducibilityOfQuery = z.infer; - export const criterionStandingQuery = z.object({ criterion: refString("criterion"), }); @@ -148,17 +101,6 @@ export const resourceQuery = z.object({ }); export type ResourceQuery = z.infer; -export const howQuery = z.object({ - subject: z.string(), - since: z.number().int().optional(), -}); -export type HowQuery = z.infer; - -export const whatDependsOnQuery = z.object({ - subject: z.string(), -}); -export type WhatDependsOnQuery = z.infer; - export const neighboursOfQuery = z.object({ subject: anyRefString(), }); diff --git a/packages/core-domain/read/blocked.ts b/packages/core-domain/read/blocked.ts index 70a4d83b..76ead8af 100644 --- a/packages/core-domain/read/blocked.ts +++ b/packages/core-domain/read/blocked.ts @@ -3,15 +3,9 @@ import type { TenantGraph } from "@labkit/core-db/graph"; import { SessionCore } from "../core"; import { byHandle, ref } from "../report"; import type { - AmendmentRecord, BlockedWork, CheckStatus, - CitedFinding, - Condition, - ConditionHistory, CriterionRef, - DesignHistory, - EvidenceRef, GateStatus, GatedWork, ListedGate, @@ -22,8 +16,6 @@ import type { } from "../report"; import type { ContractForQuery, - CriteriaGoverningQuery, - DesignHistoryQuery, GateListQuery, GateStatusQuery, StoppedWorkQuery, @@ -170,107 +162,6 @@ export class BlockedGroup extends SessionCore { }; } - /** - * Which criterion governs this gate? - */ - async criteriaGoverning({ gate }: CriteriaGoverningQuery): Promise { - const rows = await this.graph.query( - `MATCH (c:Criterion)-[:GOVERNS]->(:Gate {natural_id: $id}) RETURN c`, - { c: vertexProps<{ natural_id: string }>() }, - { id: gate }, - ); - return rows.map((r) => ref("criterion", r.c.natural_id)); - } - - /** - * A locked design and everything that has happened to it, oldest first. - */ - async designHistory({ gate }: DesignHistoryQuery): Promise { - const governing = await this.graph.query( - `MATCH (c:Criterion)-[:GOVERNS]->(:Gate {natural_id: $id}) RETURN c`, - { - c: vertexProps<{ natural_id: string; proposition: string }>(), - }, - { id: gate }, - ); - if (governing.length === 0) throw new Error(`${gate} is governed by no condition`); - - // A condition an amendment withdrew still `GOVERNS` the gate -- that is how - // the original stays readable. What is in force is what nothing changed. - const withdrawn = await this.graph.query( - `MATCH (:Decision)-[:SUPERSEDES]->(c:Criterion)-[:GOVERNS]->(:Gate {natural_id: $id}) RETURN c`, - { c: vertexProps<{ natural_id: string }>() }, - { id: gate }, - ); - const gone = new Set(withdrawn.map((r) => r.c.natural_id)); - - const rerun = await this.workGatedBy([gate]); - const confirmatory = await this.confirmatoryResultsBehind([gate]); - const nature = confirmatory.length > 0 ? ("scientific" as const) : ("mechanical" as const); - - const conditions: ConditionHistory[] = []; - for (const row of governing) { - if (gone.has(row.c.natural_id)) continue; - const criterion = ref("criterion", row.c.natural_id); - const inForce: Condition = { criterion, requires: row.c.proposition }; - const chain = await this.amendmentChain(inForce); - conditions.push({ - originally: chain[0]?.replaced ?? inForce, - nowRequires: inForce, - criterion, - amendments: chain.map((step) => ({ ...step, rerun, nature })), - }); - } - return { gate, conditions }; - } - - /** - * The amendments that led to one condition, oldest first. - */ - private async amendmentChain( - condition: Condition, - ): Promise>> { - const steps: Array> = []; - let nowRequires = condition; - for (;;) { - const rows = await this.graph.query( - `MATCH (d:Decision)-[:MOTIVATES]->(:Criterion {natural_id: $id}) - MATCH (d)-[:SUPERSEDES]->(was:Criterion) - OPTIONAL MATCH (d)-[:BASED_ON]->(e:Evidence) - RETURN d, was, e`, - { - d: vertexProps<{ natural_id: string; reason: string }>(), - was: vertexProps<{ natural_id: string; proposition: string }>(), - e: optional(vertexProps<{ statement: string } & Identified>()), - }, - { id: nowRequires.criterion }, - ); - const first = rows[0]; - if (!first) break; - - // By id: two citations can say the same sentence and be two findings. - const citing = new Map(); - for (const row of rows) { - if (!row.e) continue; - const evidence = ref("evidence", row.e.natural_id); - citing.set(evidence, { evidence, states: row.e.statement }); - } - const replaced: Condition = { - criterion: ref("criterion", first.was.natural_id), - requires: first.was.proposition, - }; - steps.push({ - amendment: ref("decision", first.d.natural_id), - replaced, - nowRequires, - reason: first.d.reason, - citing: [...citing.values()].sort((a, b) => byHandle(a.evidence, b.evidence)), - }); - nowRequires = replaced; - } - return steps.reverse(); - } - /** * May this gate be relied on, and on what evidence? */ diff --git a/packages/core-domain/read/explain.ts b/packages/core-domain/read/explain.ts index c51a013a..70ded918 100644 --- a/packages/core-domain/read/explain.ts +++ b/packages/core-domain/read/explain.ts @@ -570,10 +570,9 @@ async function explainEnquiry(self: ReadSurface, subject: string): Promise { - const rows = await this.graph.query( - `MATCH (:Question {natural_id: $id})-[:MOTIVATES]->(loe:LineOfEnquiry) RETURN loe`, - { loe: vertexProps<{ natural_id: string }>() }, - { id: question }, - ); - return rows.map((r) => ref("enquiry", r.loe.natural_id) as EnquiryRef); - } - - /** - * Where a question came from, if it came from sharpening an earlier one. - */ - async originOf({ question }: OriginOfQuery): Promise { - // Its own MATCH, because AGE has no edge alternation and the two origins do - // not share a shape: a note gave rise to the question directly, a sharpening - // did it through the decision that recorded why. - const noted = await this.graph.query( - `MATCH (n:Note)-[:MOTIVATES]->(:Question {natural_id: $id}) RETURN n`, - { n: vertexProps<{ natural_id: string; text: string }>() }, - { id: question }, - ); - if (noted.length > 0) { - const note = noted[0]!.n; - return { - kind: "noted", - from: ref("note", note.natural_id), - said: note.text, - reason: null, - knownAtTheTime: [], - }; - } - - const rows = await this.graph.query( - `MATCH (d:Decision)-[:MOTIVATES]->(:Question {natural_id: $id}) - MATCH (d)-[:SHARPENS]->(from:Question) - RETURN d, from AS origin`, - { - d: vertexProps<{ natural_id: string; reason: string }>(), - origin: vertexProps<{ natural_id: string; name: string }>(), - }, - { id: question }, - ); - if (rows.length === 0) return null; - - const row = rows[0]!; - const knew = await this.graph.query( - `MATCH (:Decision {natural_id: $id})-[:BASED_ON]->(e:Evidence) RETURN e`, - { e: vertexProps<{ statement: string } & Identified>() }, - { id: row.d.natural_id }, - ); - - return { - kind: "sharpened", - from: ref("question", row.origin.natural_id), - said: row.origin.name, - reason: row.d.reason, - knownAtTheTime: dedupeById( - knew.map((r) => ({ - evidence: ref("evidence", r.e.natural_id), - states: r.e.statement, - })), - (f) => f.evidence, - ).sort((a, b) => byHandle(a.evidence, b.evidence)), - }; - } - /** * Claims asserting a proposition — the **one** place wording is resolved. */ diff --git a/packages/core-domain/read/index.ts b/packages/core-domain/read/index.ts index 5d7db21e..d1bd57d1 100644 --- a/packages/core-domain/read/index.ts +++ b/packages/core-domain/read/index.ts @@ -4,36 +4,19 @@ import { vertexProps } from "@labkit/core-db/cypher"; import { createdIn, edgesIn } from "../events"; -import type { - EnquiryRef, - Learned, - ListedAnalysis, - ListedClaim, - ListedCriterion, - ListedEnquiry, -} from "../report"; +import type { ListedAnalysis, ListedClaim, ListedCriterion, ListedEnquiry } from "../report"; import type { AnalysisRevision, AnyRef, ConcludedClaim, - ConflictVerdict, - CriterionRef, CriterionStanding, - DependencyReport, - DesignHistory, EnquiryInContext, EnquiryStatus, Explanation, - How, GateStatus, - HistoricalSurvey, - InterpretationHistory, KnowledgeSurvey, ListedGate, ListedWork, - QuestionOrigin, - ReproducibilityReport, - ReproductionReport, SearchGroup, EventPage, ListedNote, @@ -52,30 +35,19 @@ import type { AnalysisRevisionQuery, ClaimsAssertingQuery, ContractForQuery, - CriteriaGoverningQuery, CriterionStandingQuery, - DesignHistoryQuery, - DoTheseConflictQuery, EnquiryInContextQuery, EnquiryStatusQuery, GateListQuery, GateStatusQuery, - InterpretationHistoryQuery, - KnownAtQuery, NeighboursOfQuery, NotesQuery, NowQuery, - OriginOfQuery, ProseForQuery, - PursuitsOfQuery, ReachableQuery, - ReproducibilityOfQuery, - ReproductionOfQuery, SearchQuery, StoppedWorkQuery, - WhatDependsOnQuery, WhyQuery, - HowQuery, WhySupportedQuery, ResourceQuery, WorkListQuery, @@ -85,7 +57,6 @@ import { FindingGroup } from "./finding"; import { StandingGroup } from "./standing"; import { BlockedGroup } from "./blocked"; import { InventoryGroup } from "./inventory"; -import { LearnedGroup } from "./learned"; import { StoryGroup } from "./story"; import { ExplainGroup, EXPLAINERS, enquiryInContext as enquiryInContextOf } from "./explain"; @@ -104,7 +75,6 @@ export class ReadSurface extends SessionCore { readonly #standing: StandingGroup; readonly #blocked: BlockedGroup; readonly #inventory: InventoryGroup; - readonly #learned: LearnedGroup; readonly #story: StoryGroup; readonly #explain: ExplainGroup; @@ -122,7 +92,6 @@ export class ReadSurface extends SessionCore { this.#standing = new StandingGroup(...shared); this.#blocked = new BlockedGroup(...shared); this.#inventory = new InventoryGroup(...shared); - this.#learned = new LearnedGroup(...shared); this.#story = new StoryGroup(...shared); this.#explain = new ExplainGroup(...shared); } @@ -150,16 +119,6 @@ export class ReadSurface extends SessionCore { return this.#happened.howMuchWasTranscribed(); } - /** Every line of enquiry pursuing this question. */ - async pursuitsOf(query: PursuitsOfQuery): Promise { - return this.#finding.pursuitsOf(query); - } - - /** Where a question came from, if it came from sharpening an earlier one. */ - async originOf(query: OriginOfQuery): Promise { - return this.#finding.originOf(query); - } - /** Claims asserting a proposition — the one place wording is resolved. */ async claimsAsserting(query: ClaimsAssertingQuery): Promise { return this.#finding.claimsAsserting(query); @@ -170,11 +129,6 @@ export class ReadSurface extends SessionCore { return this.#finding.search(query); } - /** What the record held at a stated moment. */ - async whatWasKnown(query: KnownAtQuery): Promise { - return this.#standing.whatWasKnown(query); - } - /** What the programme knows: settled, unsettled, and never looked at. */ async whatIsKnown(): Promise { return this.#standing.whatIsKnown(); @@ -190,16 +144,6 @@ export class ReadSurface extends SessionCore { return this.#blocked.contractFor(query); } - /** Which criterion governs this gate? */ - async criteriaGoverning(query: CriteriaGoverningQuery): Promise { - return this.#blocked.criteriaGoverning(query); - } - - /** A locked design and everything that has happened to it, oldest first. */ - async designHistory(query: DesignHistoryQuery): Promise { - return this.#blocked.designHistory(query); - } - /** May this gate be relied on, and on what evidence? */ async gateStatus(query: GateStatusQuery): Promise { return this.#blocked.gateStatus(query); @@ -214,10 +158,6 @@ export class ReadSurface extends SessionCore { async workList(query: WorkListQuery): Promise { return this.#blocked.workList(query); } - /** What the programme found out, under the question it was asked for. */ - async learned(): Promise { - return this.#learned.learned(); - } /** Every claim on the record, with what bears on it. */ async claimList(): Promise { @@ -244,21 +184,6 @@ export class ReadSurface extends SessionCore { return this.#story.enquiryStatus(query); } - /** What a re-run did and did not establish. */ - async reproductionOf(query: ReproductionOfQuery): Promise { - return this.#story.reproductionOf(query); - } - - /** An interpretation and every narrowing behind it, oldest first. */ - async interpretationHistory(query: InterpretationHistoryQuery): Promise { - return this.#story.interpretationHistory(query); - } - - /** Whether two findings actually conflict. */ - async doTheseConflict(query: DoTheseConflictQuery): Promise { - return this.#story.doTheseConflict(query); - } - /** * One record and its neighbours within `depth` hops, as the resource the HTTP API serves. * @@ -275,16 +200,6 @@ export class ReadSurface extends SessionCore { return this.#story.whySupported(query); } - /** How much of a past construction can be rebuilt. */ - async reproducibilityOf(query: ReproducibilityOfQuery): Promise { - return this.#story.reproducibilityOf(query); - } - - /** What is affected if this artefact turns out to be wrong? */ - async whatDependsOn(query: WhatDependsOnQuery): Promise { - return this.#story.whatDependsOn(query); - } - /** One condition: what it requires, what has been said about it, and what it holds up. */ async criterionStanding(query: CriterionStandingQuery): Promise { return this.#explain.criterionStanding(query); @@ -449,22 +364,6 @@ export class ReadSurface extends SessionCore { }); return EXPLAINERS.claim(this, found[0]!.claim); } - /** - * `how ` — ordered steps behind the current state of any handle. - * Refuses unknown like `why`. Walks SUPERSEDES/SUPERSEDES (Notes and Decisions) and - * MOTIVATES pairings in StoryGroup; marks superseded with successor when present. - */ - async how(query: HowQuery): Promise { - const subject = query.subject; - const asHandle = subject.toUpperCase() as AnyRef; - if (!(await this.reachable({ subject: asHandle }))) - throw new DomainRefusal({ - kind: "not-found", - message: `${subject} not found`, - subject: asHandle, - }); - return this.#story.how(query); - } } /** diff --git a/packages/core-domain/read/learned.ts b/packages/core-domain/read/learned.ts deleted file mode 100644 index d6c01c7a..00000000 --- a/packages/core-domain/read/learned.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * What the programme found out. - * - * `claims` lists conclusions; it does not say what any of them was for. This - * groups them under the question they were reached against, with the finding - * underneath — which is the answer to "what was all this compute for?". - */ - -import { optional, vertexProps } from "@labkit/core-db/cypher"; -import { SessionCore } from "../core"; -import { byHandle, ref } from "../report"; -import type { Learned, LearnedUnderQuestion } from "../report"; - -export class LearnedGroup extends SessionCore { - async learned(): Promise { - // Two clauses, not `[:SUPPORTS|CHALLENGES]`: AGE has no edge alternation. - const rows = await this.graph.query( - `MATCH (q:Question)-[:MOTIVATES]->(loe:LineOfEnquiry) - OPTIONAL MATCH (u:EvidenceUnit)-[:ADDRESSES]->(loe) - OPTIONAL MATCH (u)-[:PRODUCES]->(ev:Evidence) - OPTIONAL MATCH (ev)-[:SUPPORTS]->(sup:Claim) - OPTIONAL MATCH (ev)-[:CHALLENGES]->(ch:Claim) - RETURN q, loe, ev, sup, ch`, - { - q: vertexProps<{ natural_id: string; name: string }>(), - loe: vertexProps<{ natural_id: string }>(), - ev: optional(vertexProps<{ natural_id: string; statement: string }>()), - sup: optional(vertexProps<{ natural_id: string; name: string }>()), - ch: optional(vertexProps<{ natural_id: string; name: string }>()), - }, - {}, - ); - - const confirmatory = await this.confirmatoryOf([ - ...new Set( - rows - .flatMap((row) => [row.sup, row.ch]) - .flatMap((c) => (c ? [ref("claim", c.natural_id)] : [])), - ), - ]); - const byQuestion = new Map }>(); - for (const row of rows) { - const entry = byQuestion.get(row.q.natural_id) ?? { - question: ref("question", row.q.natural_id), - asks: row.q.name, - found: [], - seen: new Set(), - }; - for (const [claim, bearing] of [ - [row.sup, "supports"] as const, - [row.ch, "challenges"] as const, - ]) { - if (!claim || !row.ev) continue; - // One claim per question, by the first finding that reached it: a - // conclusion cited by four runs is one thing learned, not four. - const key = `${claim.natural_id}:${bearing}`; - if (entry.seen.has(key)) continue; - entry.seen.add(key); - entry.found.push({ - claim: ref("claim", claim.natural_id), - asserts: claim.name, - bearing, - confirmed: confirmatory.has(ref("claim", claim.natural_id)), - finding: ref("evidence", row.ev.natural_id), - states: row.ev.statement, - }); - } - byQuestion.set(row.q.natural_id, entry); - } - - const questions = [...byQuestion.values()] - .map(({ seen: _seen, ...q }) => ({ - ...q, - found: q.found.sort((a, b) => byHandle(a.claim, b.claim)), - })) - .sort((a, b) => byHandle(a.question, b.question)); - return { questions, found: questions.reduce((n, q) => n + q.found.length, 0) }; - } -} diff --git a/packages/core-domain/read/standing.ts b/packages/core-domain/read/standing.ts index c85cb933..a4d3c234 100644 --- a/packages/core-domain/read/standing.ts +++ b/packages/core-domain/read/standing.ts @@ -1,99 +1,12 @@ import { optional, vertexProps } from "@labkit/core-db/cypher"; import { SessionCore } from "../core"; -import type { ClaimRef, HistoricalSurvey, KnowledgeSurvey, QuestionStanding } from "../report"; +import type { ClaimRef, KnowledgeSurvey, QuestionStanding } from "../report"; import { byHandle, ref } from "../report"; -import type { KnownAtQuery } from "../queries"; /** The two ways a finding bears on a claim; a closure is read for each. */ const BEARINGS = ["SUPPORTS", "CHALLENGES"] as const; export class StandingGroup extends SessionCore { - /** - * What the record held at a stated moment. Row Z. - */ - async whatWasKnown({ at }: KnownAtQuery): Promise { - const parsed = Date.parse(at); - if (Number.isNaN(parsed)) - throw new Error( - `whatWasKnown expected an ISO instant like 2026-07-15T12:34:56.000Z, got "${at}"`, - ); - const asOf = new Date(parsed).toISOString(); - - const standings = new Map(); - const asked = new Map(); - - // Two passes, one per bearing: AGE has no edge alternation, and the cited finding may - // support or challenge the answering claim. - for (const bearing of BEARINGS) { - const rows = await this.graph.query( - `MATCH (q:Question) - WHERE q.posed_at <= $at - OPTIONAL MATCH (accepting:Decision)-[:ACCEPTS]->(q) - OPTIONAL MATCH (q)-[:MOTIVATES]->(loe:LineOfEnquiry) - OPTIONAL MATCH (resolving:Decision)-[:CLOSES]->(loe) - OPTIONAL MATCH (resolving)-[:ANSWERS]->(answering:Claim) - OPTIONAL MATCH (answering)-[:BASED_ON]->(part:Claim) - OPTIONAL MATCH (resolving)-[:BASED_ON]->(cited:Evidence) - OPTIONAL MATCH (cited)-[:${bearing}]->(borne:Claim) - OPTIONAL MATCH (vouching:Decision)-[:CONFIRMED]->(answering) - RETURN q, accepting, loe, resolving, answering, part, borne, vouching`, - { - q: vertexProps<{ natural_id: string; name: string }>(), - accepting: optional(vertexProps<{ decided_at: string }>()), - loe: optional(vertexProps<{ natural_id: string; started_at?: string }>()), - resolving: optional(vertexProps<{ decided_at: string }>()), - answering: optional(vertexProps<{ natural_id: string }>()), - part: optional(vertexProps<{ natural_id: string }>()), - borne: optional(vertexProps<{ natural_id: string }>()), - vouching: optional(vertexProps<{ decided_at: string }>()), - }, - { at: asOf }, - ); - - for (const row of rows) { - const question = row.q.natural_id; - const entry = asked.get(question) ?? { asks: row.q.name, accepted: false }; - entry.accepted ||= row.accepting !== null && row.accepting.decided_at <= asOf; - asked.set(question, entry); - - const was = standings.get(question) ?? { resolved: false, promoted: false, open: false }; - const existed = - row.loe !== null && (row.loe.started_at === undefined || row.loe.started_at <= asOf); - const closed = existed && row.resolving !== null && row.resolving.decided_at <= asOf; - const bearsOnAnswer = - row.answering !== null && - row.borne !== null && - (row.borne.natural_id === row.answering.natural_id || - row.part?.natural_id === row.borne.natural_id); - const answered = closed && bearsOnAnswer; - standings.set(question, { - resolved: was.resolved || answered, - promoted: - was.promoted || (answered && row.vouching !== null && row.vouching.decided_at <= asOf), - open: was.open || (existed && !closed), - }); - } - } - - const survey: HistoricalSurvey = { - at: asOf, - established: [], - provisional: [], - accepted: [], - open: [], - }; - for (const [question, e] of asked) { - const entry: QuestionStanding = { question: ref("question", question), asks: e.asks }; - const was = standings.get(question) ?? { resolved: false, promoted: false, open: false }; - if (was.open && e.accepted) survey.accepted.push(entry); - else if (was.open) survey.open.push(entry); - else if (was.resolved && was.promoted) survey.established.push(entry); - else if (was.resolved) survey.provisional.push(entry); - else survey.open.push(entry); - } - return survey; - } - /** What the programme knows, folded over each question's pursuits. */ async whatIsKnown(): Promise { const anchor = `MATCH (q:Question) diff --git a/packages/core-domain/read/story.ts b/packages/core-domain/read/story.ts index be26fbd3..5350933f 100644 --- a/packages/core-domain/read/story.ts +++ b/packages/core-domain/read/story.ts @@ -1,51 +1,24 @@ -import { edgeProps, optional, scalar, vertexProps } from "@labkit/core-db/cypher"; +import { optional, vertexProps } from "@labkit/core-db/cypher"; import type { ArtefactProps, ClaimProps, ComputationProps, EvidenceProps, - IdentityString, IndexedString, - Prose, } from "@labkit/core-db/domain"; -import { SEARCHABLE_TEXT, labelForNaturalId } from "@labkit/core-db/domain"; import { SessionCore } from "../core"; -import { byHandle, isRefOfKind, kindOf, ref, verdictOf } from "../report"; +import { ref, verdictOf } from "../report"; import type { - AffectedClaim, - AffectedEnquiry, CheckStatus, ClaimRef, - ConcludedClaim, - ConflictSide, - ConflictVerdict, - DecisionRef, - DependencyReport, EnquiryRef, EnquiryStatus, - How, - IdentifiedArtefact, - InterpretationHistory, ObservationsRef, - ReproducibilityReport, - ReproductionReport, Reverification, - Revision, SupportExplanation, } from "../report"; -import { createdIn } from "../events"; -import type { DomainEvent } from "../events"; import { DomainRefusal } from "../refusal"; -import type { - DoTheseConflictQuery, - EnquiryStatusQuery, - HowQuery, - InterpretationHistoryQuery, - ReproducibilityOfQuery, - ReproductionOfQuery, - WhatDependsOnQuery, - WhySupportedQuery, -} from "../queries"; +import type { EnquiryStatusQuery, WhySupportedQuery } from "../queries"; import { checkStatusOf, checksAnchor, criteriaChecks } from "./checks"; import { blockedBy } from "./blocked"; import { dedupeById, type Identified } from "./shared"; @@ -253,320 +226,6 @@ export class StoryGroup extends SessionCore { ); } - /** - * What a re-run did and did not establish. - */ - async reproductionOf({ verification }: ReproductionOfQuery): Promise { - const link = await this.graph.query( - `MATCH (:Computation {natural_id: $id})<-[:USES]-(:EvidenceUnit)-[:PRODUCES]->(new:Evidence) - MATCH (new)-[:REVERIFIES]->(old:Evidence)<-[:PRODUCES]-(:EvidenceUnit)-[:USES]->(oldcomp:Computation) - RETURN new, old, oldcomp`, - { - new: vertexProps<{ natural_id: string }>(), - old: vertexProps<{ natural_id: string }>(), - oldcomp: vertexProps<{ natural_id: string; method: string }>(), - }, - { id: verification }, - ); - const found = link[0]; - if (!found) throw new Error(`${verification} re-verifies nothing`); - - const method = await this.graph.query( - `MATCH (c:Computation {natural_id: $id}) RETURN c`, - { c: vertexProps<{ method: string }>() }, - { id: verification }, - ); - - // Keyed by natural id, never by `logical_name`. Two runs can each record something called - // "initial conditions" and mean different data; comparing the names would make those the - // same execution input. What a run read, **in order and with repeats**, plus the same as a - // set for the difference calculation below. - const inputs = async ( - computation: string, - ): Promise<{ - read: IdentifiedArtefact[]; - bySubject: Map; - }> => { - const rows = await this.graph.query( - `MATCH (:Computation {natural_id: $id})-[c:CONSUMES]->(a:Artefact) RETURN a, c`, - { - a: vertexProps<{ natural_id: string; logical_name: string }>(), - c: edgeProps<{ positions?: number[] }>(), - }, - { id: computation }, - ); - const occurrences = rows.flatMap((r) => - (r.c.positions ?? [Number.MAX_SAFE_INTEGER]).map((position) => ({ - position, - a: r.a, - })), - ); - occurrences.sort( - (x, y) => x.position - y.position || x.a.natural_id.localeCompare(y.a.natural_id), - ); - const identify = (a: { natural_id: string; logical_name: string }): IdentifiedArtefact => ({ - part: ref("observations", a.natural_id), - name: a.logical_name, - }); - return { - read: occurrences.map((o) => identify(o.a)), - bySubject: new Map(rows.map((r) => [ref("observations", r.a.natural_id), identify(r.a)])), - }; - }; - const mine = await inputs(verification); - const theirs = await inputs(found.oldcomp.natural_id); - const mineBy = mine.bySubject; - const theirsBy = theirs.bySubject; - - // Absence and difference are not the same answer, and absence on BOTH sides - // is still absence: two runs that each recorded nothing have not reproduced - // anything, they have simply both failed to say what they read. Comparing - // the two empty sets reported `reproduced`, contradicting the premise the - // scenario exists for. - const provenanceMissing = theirsBy.size === 0; - const differs: ReproductionReport["differs"] = provenanceMissing - ? [...mineBy.values()].map((what) => ({ - what, - standing: "unrecorded-in-the-original" as const, - })) - : [ - ...[...mineBy] - .filter(([id]) => !theirsBy.has(id)) - .map(([, what]) => ({ what, standing: "changed" as const })), - // The other direction, which was not computed at all: an input the - // original read and the re-run did not is a difference too, and - // reporting `not-reproduced` with an empty `differs` named nothing. - ...[...theirsBy] - .filter(([id]) => !mineBy.has(id)) - .map(([, what]) => ({ - what, - standing: "not-used-by-the-re-run" as const, - })), - ]; - // Sorted by name then identity: the name is what a reader scans, and the - // identity breaks the tie when two inputs share one. - differs.sort( - (a, b) => a.what.name.localeCompare(b.what.name) || a.what.part.localeCompare(b.what.part), - ); - - // Which way each run cut, read from the bearing each finding was recorded - // with -- never from comparing the two findings' wording. Both are needed: - // reading only the re-run's made two runs that each found *against* the - // proposition report as disagreeing with each other. - const challenges = async (evidence: string): Promise => - ( - await this.graph.query( - `MATCH (:Evidence {natural_id: $id})-[:CHALLENGES]->(:Claim) RETURN 1`, - { ok: scalar() }, - { id: evidence }, - ) - ).length > 0; - const newChallenges = await challenges(found.new.natural_id); - const oldChallenges = await challenges(found.old.natural_id); - const agrees = newChallenges === oldChallenges; - - return { - // Identity and wording both. Method text alone leaves two runs of one - // method indistinguishable. - verification, - verificationMethod: method[0]!.c.method, - of: ref("analysis", found.oldcomp.natural_id), - ofMethod: found.oldcomp.method, - conclusion: agrees ? "agrees" : "disagrees", - // Both lists, in order, and no verdict over them. Whether the same - // records read in a different order is the same execution depends on what - // the method does; the record does not know and does not guess. - verificationRead: mine.read, - ofRead: theirs.read, - differs, - // Which way the RE-RUN cuts for the claim -- a question about the - // proposition, not about whether the two runs concur. Two runs that agree - // on a negative finding agree with each other and lower confidence in the - // proposition, and those are different sentences. - bearing: newChallenges ? "lowers" : "raises", - }; - } - - /** - * An interpretation and every narrowing behind it, oldest first. - */ - async interpretationHistory({ - claim, - }: InterpretationHistoryQuery): Promise { - // **Walked by id.** `reinterpret` writes `Decision -MOTIVATES-> narrower` and `Decision - // -SUPERSEDES-> each withdrawn claim`, both carrying natural ids, so every step is reachable - // by identity. - const proposition = await this.assertedBy(claim); - if (proposition === undefined) throw new Error(`${claim} not found`); - - // Depth from the claim asked about, so the deepest revisions are the - // oldest. A claim reached by two paths of different lengths keeps the - // longer one, which is what puts every revision behind it deeper still. - const steps: Array<{ depth: number; revision: Revision }> = []; - const narrowed = new Set(); - const walked = new Set(); - let frontier: ConcludedClaim[] = [{ claim, asserts: proposition }]; - const reached = new Map([[claim, frontier[0]!]]); - - for (let depth = 1; frontier.length > 0; depth++) { - const rows = await this.graph.query( - // `nxt` bound and matched by id. Lower-case RETURN names throughout: a - // camelCase one decodes as null. See `buildAsClause`. - `MATCH (d:Decision)-[:MOTIVATES]->(nxt:Claim) - WHERE nxt.natural_id IN $ids - MATCH (d)-[:SUPERSEDES]->(was:Claim) - RETURN d, was, nxt`, - { - d: vertexProps<{ natural_id: string; reason: string }>(), - was: vertexProps<{ name: string } & Identified>(), - nxt: vertexProps<{ name: string } & Identified>(), - }, - { ids: frontier.map((c) => c.claim) }, - ); - - // One entry per decision. A decision that withdrew several readings comes - // back as one row per withdrawn claim, and every one of them is a step - // backwards from the same act. - const byDecision = new Map< - DecisionRef, - { reason: Prose; nxt: ConcludedClaim; was: Map } - >(); - for (const row of rows) { - const decision = ref("decision", row.d.natural_id); - const entry = byDecision.get(decision) ?? { - reason: row.d.reason, - nxt: { claim: ref("claim", row.nxt.natural_id), asserts: row.nxt.name }, - was: new Map(), - }; - const was = ref("claim", row.was.natural_id); - entry.was.set(was, { claim: was, asserts: row.was.name }); - byDecision.set(decision, entry); - } - - const next = new Map(); - for (const [decision, entry] of byDecision) { - // A claim reached by two paths yields the same decision twice. The - // revision is one act and is reported once, at the greater depth. - if (walked.has(decision)) continue; - walked.add(decision); - narrowed.add(entry.nxt.claim); - const withdrew = [...entry.was.values()]; - steps.push({ - depth, - revision: { - revision: decision, - previously: withdrew, - nowClaims: entry.nxt, - reason: entry.reason, - // Scoped to the withdrawn claim's own line of enquiry. The bare - // proposition would ask "what was decided on the strength of this - // SENTENCE", which reaches another chain's decisions. - restingOnTheOldReading: await this.decidedOnTheStrengthOf( - await this.scopeOf(withdrew[0]!.claim), - ), - }, - }); - for (const was of withdrew) { - reached.set(was.claim, was); - next.set(was.claim, was); - } - } - frontier = [...next.values()]; - } - - // Oldest first: deepest first, and by decision within a depth so two - // branches come back in a stable order rather than the graph's. - steps.sort( - (a, b) => b.depth - a.depth || a.revision.revision.localeCompare(b.revision.revision), - ); - - return { - // Every reading the walk reached that no revision produced. On a line - // that is the first claim; on a merge it is one per branch, including a - // branch an analysis concluded outright and nobody narrowed. - originally: [...reached.values()].filter((c) => c.claim !== claim && !narrowed.has(c.claim)), - // The handle the caller asked about, not one re-found by its wording. - nowClaims: { claim, asserts: proposition }, - revisions: steps.map((s) => s.revision), - }; - } - - /** - * Whether two findings actually conflict. - */ - async doTheseConflict({ a, b }: DoTheseConflictQuery): Promise { - const sides = [await this.sideOf(a), await this.sideOf(b)]; - const [left, right] = sides; - - // Value equality, which a handle gives: two records wrongly told apart here - // turn a contradiction into a dissociation, silently and with the compiler's - // blessing, since both sides have the same type. - const sameScope = left!.enquiry === right!.enquiry; - if (!sameScope) { - // Support for equivalence on one endpoint says nothing about another. - // Identical wording does not make them one claim. - return { - conflict: false, - relation: "dissociation", - differsBy: "scope", - sides: sides.map(({ enquiry: _enquiry, ...side }) => side), - }; - } - - const opposed = - (left!.supportedBy.length > 0 && right!.challengedBy.length > 0) || - (left!.challengedBy.length > 0 && right!.supportedBy.length > 0); - - return { - conflict: opposed, - relation: opposed ? "contradiction" : "corroboration", - differsBy: null, - sides: sides.map(({ enquiry: _enquiry, ...side }) => side), - }; - } - - private async sideOf(conclusion: ClaimRef): Promise { - const resolved = await this.scopeOf(conclusion); - const enquiry = resolved.enquiry!; - - const asked = await this.graph.query( - `MATCH (q:Question)-[:MOTIVATES]->(:LineOfEnquiry {natural_id: $id}) RETURN q`, - { q: vertexProps<{ name: string } & Identified>() }, - { id: enquiry }, - ); - - const scope = resolved; - // Deduped by id. `findingsBearing` already selects natural_id and it was - // being discarded on the mapping line, so two independent findings phrased - // alike counted as one corroboration -- and `doTheseConflict` decides from - // these arrays' lengths. - const findings = async (bearing: "SUPPORTS" | "CHALLENGES") => - dedupeById( - (await this.findingsBearing(scope, bearing)).map((r) => ({ - evidence: ref("evidence", r.e.natural_id), - states: r.e.statement, - })), - (f) => f.evidence, - ).sort((a, b) => byHandle(a.evidence, b.evidence)); - - const claim = conclusion; - // `pursue` writes MOTIVATES from the question it was given, so there is - // always one. An empty handle stood here for the case that cannot arise. - const motivating = asked[0]; - if (motivating === undefined) - throw new Error(`${enquiry} has no question; every line of enquiry is pursued from one`); - - return { - claim, - question: ref("question", motivating.q.natural_id), - proposition: resolved.proposition, - asks: motivating.q.name, - supportedBy: await findings("SUPPORTS"), - challengedBy: await findings("CHALLENGES"), - enquiry, - }; - } - /** "Why does this conclusion count as supported?" and "what did the superseded inference claim?" */ async whySupported({ claim }: WhySupportedQuery): Promise { const scope = await this.scopeOf(claim); @@ -590,7 +249,7 @@ export class StoryGroup extends SessionCore { ); // The review each retraction actually rested on (row O). Absent for an - // artefact invalidated by anything other than replaceAnalysis(), which is + // artefact invalidated by anything other than a recorded `replaceAnalysis`, which is // why the reader still falls back rather than assuming the edge is there. const retractedBy = new Map( ( @@ -785,140 +444,6 @@ export class StoryGroup extends SessionCore { }; } - /** - * How much of a past construction can be rebuilt. - */ - async reproducibilityOf({ - analysis, - rebuilt, - }: ReproducibilityOfQuery): Promise { - const offered = new Map(rebuilt.map((r) => [r.part, r.hash])); - - // An absent subject and an empty one are different states: answering them - // alike lets this report say `reproducible: true` about nothing. The - // existence check is separate from the parts query because both return zero - // rows and only one of them is a caller error. - const subject = await this.graph.query( - `MATCH (c:Computation {natural_id: $id}) RETURN c`, - { c: vertexProps<{ natural_id: string }>() }, - { id: analysis }, - ); - if (subject.length === 0) throw new Error(`${analysis} not found`); - - const parts = await this.graph.query( - `MATCH (:Computation {natural_id: $id})-[:CONSUMES]->(a:Artefact) RETURN a`, - { - a: vertexProps<{ - natural_id: string; - logical_name: string; - content_hash?: string; - }>(), - }, - { id: analysis }, - ); - - const exact: IdentifiedArtefact[] = []; - const differing: IdentifiedArtefact[] = []; - const unverifiable: IdentifiedArtefact[] = []; - const notRebuilt: IdentifiedArtefact[] = []; - for (const { a } of parts) { - const candidate = offered.get(ref("observations", a.natural_id)); - // Two ways for no comparison to happen, and neither is inequality: the record has no hash - // (permanent, about the artefact), or this attempt did not rebuild the part (about the - // attempt). `differing` is a comparison that ran and came out unequal, which is a - // different kind of statement. - const entry = { - part: ref("observations", a.natural_id), - name: a.logical_name, - }; - if (!a.content_hash) unverifiable.push(entry); - else if (candidate === undefined) notRebuilt.push(entry); - else if (candidate === a.content_hash) exact.push(entry); - else differing.push(entry); - } - - const byName = (a: IdentifiedArtefact, b: IdentifiedArtefact) => - a.name.localeCompare(b.name) || a.part.localeCompare(b.part); - return { - analysis, - exact: exact.sort(byName), - differing: differing.sort(byName), - unverifiable: unverifiable.sort(byName), - notRebuilt: notRebuilt.sort(byName), - // Anything not shown to match leaves the construction unshown. `exact.length > 0` is the - // conjunct three empty lists cannot supply: an analysis that consumed nothing satisfies - // "nothing differed, nothing was unverifiable, nothing went unrebuilt" vacuously, and - // would report that a construction with no parts reproduces. - reproducible: - exact.length > 0 && - differing.length === 0 && - unverifiable.length === 0 && - notRebuilt.length === 0, - }; - } - - /** - * What is affected if this artefact turns out to be wrong? - */ - async whatDependsOn({ subject }: WhatDependsOnQuery): Promise { - // **`typeof` cannot tell these apart any more, and that is the trap.** A handle is a - // branded string now, so `typeof subject === "string"` is true for both arms of the union - // and sent every handle off to be looked up by logical name -- which threw `no artefact - // named "ART_21"`. - const start = isRefOfKind("observations", subject) - ? (subject as ObservationsRef) - : await this.artefactNamed(subject); - - // Walk the pipeline downstream before asking what rests on it. An analysis can read another - // analysis's output (row AE), so invalidating a raw input reaches every stage built on top - // of it -- and asking only about the artefact handed in stops at the first stage. - const reached = new Set([start]); - for (let frontier = [start]; frontier.length > 0; ) { - const next: ObservationsRef[] = []; - for (const id of frontier) { - const downstream = await this.graph.query( - `MATCH (:Artefact {natural_id: $id})<-[:CONSUMES]-(:Computation)-[:PRODUCES]->(out:Artefact) - RETURN out`, - { out: vertexProps<{ natural_id: string }>() }, - { id }, - ); - for (const row of downstream) { - if (reached.has(ref("observations", row.out.natural_id))) continue; - reached.add(ref("observations", row.out.natural_id)); - next.push(ref("observations", row.out.natural_id)); - } - } - frontier = next; - } - - // Deduplicated **by id**, not by wording. Two claims asserting the same - // sentence in different lines of enquiry are two claims, and a `Set` - // of names merges them silently. - const claims = new Map(); - const enquiries = new Map(); - for (const artefact of reached) { - const { claims: c, enquiries: e } = await this.restingOnArtefact( - ref("observations", artefact), - ); - for (const found of c) claims.set(found.claim, found); - for (const found of e) enquiries.set(found.enquiry, found); - } - - return { - // Which record the answer is about -- and when a name was passed, which - // record that name resolved to. - subject: ref("observations", start), - claims: [...claims.values()], - enquiries: [...enquiries.values()], - routesWalked: [ - "evidence recorded in this artefact, and the claims it bears on", - "computations that consumed this artefact, and the claims their findings bear on", - "the same, for every artefact downstream of this one through CONSUMES/PRODUCES", - ], - complete: false, - }; - } - /** * The artefacts a claim's still-current analyses consumed, for one bearing. */ @@ -1004,297 +529,4 @@ export class StoryGroup extends SessionCore { ); return rows.some((r) => r.narrowed || r.replaced); } - - /** The claims and enquiries resting on one artefact, by the two direct routes. */ - private async restingOnArtefact( - artefact: ObservationsRef, - ): Promise<{ claims: AffectedClaim[]; enquiries: AffectedEnquiry[] }> { - const rows = await this.graph.query( - `MATCH (a:Artefact {natural_id: $id}) - OPTIONAL MATCH (a)<-[:RECORDED_IN]-(e:Evidence) - OPTIONAL MATCH (e)-[:SUPPORTS]->(claim:Claim) - OPTIONAL MATCH (e)-[:CHALLENGES]->(challenged:Claim) - OPTIONAL MATCH (loe:LineOfEnquiry)-[:REQUIRES]->(e) - RETURN claim, challenged, loe`, - { - claim: optional(vertexProps()), - challenged: optional(vertexProps()), - loe: optional(vertexProps<{ name: string } & Identified>()), - }, - { id: artefact }, - ); - - // The input side. Separate query rather than more OPTIONAL MATCHes on the - // same one, because the two routes share no bound variable and combining - // them multiplies rows for no gain. - const consumers = await this.graph.query( - `MATCH (:Artefact {natural_id: $id})<-[:CONSUMES]-(:Computation)<-[:USES]-(u:EvidenceUnit) - MATCH (u)-[:PRODUCES]->(e:Evidence) - OPTIONAL MATCH (e)-[:SUPPORTS]->(claim:Claim) - OPTIONAL MATCH (e)-[:CHALLENGES]->(challenged:Claim) - OPTIONAL MATCH (u)-[:ADDRESSES]->(loe:LineOfEnquiry) - RETURN claim, challenged, loe`, - { - claim: optional(vertexProps()), - challenged: optional(vertexProps()), - loe: optional(vertexProps<{ name: string } & Identified>()), - }, - { id: artefact }, - ); - - const all = [...rows, ...consumers]; - return { - // A claim whose refutation rested on this record is affected by - // invalidating it, exactly as a supported one is. - claims: all.flatMap((r) => - [r.claim, r.challenged] - .filter((c): c is ClaimProps & Identified => !!c) - .map((c) => ({ claim: ref("claim", c.natural_id), asserts: c.name })), - ), - enquiries: all.flatMap((r) => - r.loe - ? [ - { - enquiry: ref("enquiry", r.loe.natural_id), - pursuing: r.loe.name, - }, - ] - : [], - ), - }; - } - - /** - * Resolves an artefact name to one artefact, or refuses. - */ - private async artefactNamed(name: IndexedString): Promise { - const rows = await this.graph.query( - `MATCH (a:Artefact {logical_name: $name}) RETURN a`, - { a: vertexProps<{ natural_id: string }>() }, - { name }, - ); - if (rows.length === 0) throw new Error(`no artefact named "${name}"`); - if (rows.length > 1) { - throw new Error( - `${rows.length} artefacts are named "${name}". Name which, by the record that produced it.`, - ); - } - return ref("observations", rows[0]!.a.natural_id); - } - /** - * Walks SUPERSEDES both ways (Note and Decision), SUPERSEDES, and paired MOTIVATES. - * Works for any handle kind. Steps that were superseded are marked false starts; - * successor is named when SUPERSEDES/SUPERSEDES record one (via the MOTIVATES target). - * Orders by event seq when available, else by id numeric. --since filters steps. - */ - async how({ subject, since }: HowQuery): Promise { - const id = subject.toUpperCase(); - - const relevant = new Set(); - const toVisit: string[] = [id]; - const visited = new Set(); - while (toVisit.length > 0) { - const cur = toVisit.pop()!; - if (visited.has(cur)) continue; - visited.add(cur); - relevant.add(cur); - - const sup = await this.graph.query( - `MATCH (a {natural_id: $id})-[r:SUPERSEDES]->(b) WHERE a.retracted IS NULL AND b.retracted IS NULL RETURN b AS other - UNION - MATCH (b)-[r:SUPERSEDES]->(a {natural_id: $id}) WHERE a.retracted IS NULL AND b.retracted IS NULL RETURN b AS other`, - { other: vertexProps<{ natural_id: string }>() }, - { id: cur }, - ); - for (const r of sup) { - const o = r.other?.natural_id; - if (o && !visited.has(o)) toVisit.push(o); - } - - const ch = await this.graph.query( - `MATCH (a {natural_id: $id})-[r:SUPERSEDES]->(b) WHERE a.retracted IS NULL AND b.retracted IS NULL RETURN b AS other - UNION - MATCH (b)-[r:SUPERSEDES]->(a {natural_id: $id}) WHERE a.retracted IS NULL AND b.retracted IS NULL RETURN b AS other`, - { other: vertexProps<{ natural_id: string }>() }, - { id: cur }, - ); - for (const r of ch) { - const o = r.other?.natural_id; - if (o && !visited.has(o)) toVisit.push(o); - } - - const viaS = await this.graph.query( - `MATCH (d:Decision)-[:SUPERSEDES]->(x {natural_id: $id}) OPTIONAL MATCH (d)-[:MOTIVATES]->(m) RETURN d, m`, - { - d: optional(vertexProps<{ natural_id: string }>()), - m: optional(vertexProps<{ natural_id: string }>()), - }, - { id: cur }, - ); - for (const r of viaS) { - if (r.d?.natural_id && !visited.has(r.d.natural_id)) toVisit.push(r.d.natural_id); - if (r.m?.natural_id && !visited.has(r.m.natural_id)) toVisit.push(r.m.natural_id); - } - const viaC = await this.graph.query( - `MATCH (d:Decision)-[:SUPERSEDES]->(x {natural_id: $id}) OPTIONAL MATCH (d)-[:MOTIVATES]->(m) RETURN d, m`, - { - d: optional(vertexProps<{ natural_id: string }>()), - m: optional(vertexProps<{ natural_id: string }>()), - }, - { id: cur }, - ); - for (const r of viaC) { - if (r.d?.natural_id && !visited.has(r.d.natural_id)) toVisit.push(r.d.natural_id); - if (r.m?.natural_id && !visited.has(r.m.natural_id)) toVisit.push(r.m.natural_id); - } - - const mot = await this.graph.query( - `MATCH (d:Decision)-[:MOTIVATES]->(m {natural_id: $id}) - OPTIONAL MATCH (d)-[:SUPERSEDES]->(s) - OPTIONAL MATCH (d)-[:SUPERSEDES]->(c) - RETURN d, s, c`, - { - d: optional(vertexProps<{ natural_id: string }>()), - s: optional(vertexProps<{ natural_id: string }>()), - c: optional(vertexProps<{ natural_id: string }>()), - }, - { id: cur }, - ); - for (const r of mot) { - if (r.d?.natural_id && !visited.has(r.d.natural_id)) toVisit.push(r.d.natural_id); - if (r.s?.natural_id && !visited.has(r.s.natural_id)) toVisit.push(r.s.natural_id); - if (r.c?.natural_id && !visited.has(r.c.natural_id)) toVisit.push(r.c.natural_id); - } - - const decL = await this.graph.query( - `MATCH (d:Decision {natural_id: $id}) - OPTIONAL MATCH (d)-[:SUPERSEDES]->(s) - OPTIONAL MATCH (d)-[:SUPERSEDES]->(c) - OPTIONAL MATCH (d)-[:MOTIVATES]->(m) - RETURN s, c, m`, - { - s: optional(vertexProps<{ natural_id: string }>()), - c: optional(vertexProps<{ natural_id: string }>()), - m: optional(vertexProps<{ natural_id: string }>()), - }, - { id: cur }, - ); - for (const r of decL) { - for (const k of ["s", "c", "m"] as const) { - const v = r[k]?.natural_id; - if (v && !visited.has(v)) toVisit.push(v); - } - } - } - - const evs: readonly DomainEvent[] = await this.events.all(); - const seqFor = (h: string): number | undefined => { - for (const e of evs) { - if (e.seq === undefined) continue; - if (e.subject === h || createdIn(e).includes(h)) return e.seq; - } - return undefined; - }; - const numberIn = (handle: string): number => Number(handle.slice(handle.indexOf("_") + 1)) || 0; - - const subjectKind = kindOf(id); - const steps: Array<{ - handle: string; - what: string; - superseded: boolean; - successor?: string; - because?: string; - seq?: number; - }> = []; - - for (const h of relevant) { - const k = kindOf(h); - if (k === "decision" && h !== id && subjectKind !== "decision") continue; - - let superseded = false; - let successor: string | undefined; - let because: string | undefined; - - const nsup = await this.graph.query( - `MATCH (newer:Note)-[:SUPERSEDES]->(t {natural_id: $id}) WHERE t.retracted IS NULL RETURN newer LIMIT 1`, - { newer: vertexProps<{ natural_id: string }>() }, - { id: h }, - ); - if (nsup[0]?.newer) { - superseded = true; - successor = nsup[0].newer.natural_id; - } - - if (!successor) { - type SupersederRow = { - d?: { natural_id: string; reason?: string }; - succ?: { natural_id: string } | null; - }; - let drow: SupersederRow | null = null; - // Every superseding decision, so the one that names a claim successor can be picked - // when the target is a claim: replacing an analysis supersedes its claims but motivates - // the new analysis, while `conclude --replacing` motivates the new claim. - const supRows = await this.graph.query( - `MATCH (d:Decision)-[:SUPERSEDES]->(t {natural_id: $id}) WHERE t.retracted IS NULL OPTIONAL MATCH (d)-[:MOTIVATES]->(succ) RETURN d, succ`, - { - d: vertexProps<{ natural_id: string; reason?: string }>(), - succ: optional(vertexProps<{ natural_id: string }>()), - }, - { id: h }, - ); - const candidates = supRows.filter((r) => r.d); - if (candidates.length > 0) { - // Prefer a successor that is a claim when the superseded handle is a claim. - const isClaim = kindOf(h) === "claim"; - drow = - candidates.find((r) => { - const s = r.succ?.natural_id; - return !isClaim || !s || kindOf(s) === "claim"; - }) || candidates[0]!; - } - if (drow?.d) { - superseded = true; - successor = drow.succ?.natural_id; - because = drow.d.reason || undefined; - } - } - - let what = h; - const label = labelForNaturalId(h); - const props = SEARCHABLE_TEXT[label] ?? []; - if (props.length > 0) { - const [row] = await this.graph.query( - `MATCH (n {natural_id: $id}) WHERE n.retracted IS NULL RETURN n`, - { n: vertexProps>() }, - { id: h }, - ); - if (row) { - for (const p of props) { - const v = row.n[p]; - if (typeof v === "string" && v.length > 0) { - what = v; - break; - } - } - } - } - if (what === h) what = k ?? "record"; - - const seq = seqFor(h); - steps.push({ handle: h, what, superseded, successor, because, seq }); - } - - steps.sort((a, b) => { - const sa = a.seq, - sb = b.seq; - if (sa !== undefined && sb !== undefined) return sa - sb; - if (sa !== undefined) return -1; - if (sb !== undefined) return 1; - return numberIn(a.handle) - numberIn(b.handle); - }); - - let filtered = steps; - if (since !== undefined) filtered = steps.filter((s) => s.seq !== undefined && s.seq > since); - return { subject: id, steps: filtered }; - } } diff --git a/packages/core-domain/report.ts b/packages/core-domain/report.ts index 57a9a5cb..93e5e411 100644 --- a/packages/core-domain/report.ts +++ b/packages/core-domain/report.ts @@ -128,8 +128,6 @@ export type { SearchMatch, SearchGroup, Notes, - How, - HowStep, ListedNote, QuestionStanding, AcceptedQuestion, @@ -140,10 +138,7 @@ export type { CitedFinding, EvaluationRecord, BearingFinding, - AffectedClaim, - AffectedEnquiry, ConfirmatoryResult, - DecidedQuestion, GatedWork, ReplacementClaim, Reverification, @@ -152,16 +147,10 @@ export type { Condition, DecidingEvaluation, CheckStatus, - AmendmentRecord, - Revision, - ConditionHistory, RevisedFinding, GateGoverned, AnalysisRevision, Registration, - Learned, - LearnedFinding, - LearnedUnderQuestion, ListedAnalysis, ListedClaim, ListedCriterion, @@ -174,50 +163,31 @@ export type { UnaffectedRecord, Cause, KnowledgeSurvey, - HistoricalSurvey, SupportExplanation, - DependencyReport, EnquiryQuestion, EnquiryStatus, EnquiryInContext, - DesignHistory, - InterpretationHistory, - ReproductionReport, - QuestionOrigin, - OriginOf, Addressing, TaskContract, - CriteriaGoverning, GateStatus, CriterionStanding, Explanation, - ConflictSide, - ConflictVerdict, - ReproducibilityReport, RecordedAnalysis, Posed, Noted, Pursued, OpenedEnquiry, RecordedObservations, - SharpenedQuestion, Synthesised, - RecordedReview, ClosedEnquiry, StoppedWork, - ClosedGate, PlannedWork, StatedCriterion, DeclaredGate, EvaluatedCriterion, AcceptedAsUnresolved, Restated, - Undone, - VerificationReport, AmendmentReport, - ReplacementReport, - ReinterpretationReport, - Pursuits, RegisteredSession, GateList, WorkList, diff --git a/packages/core-domain/reports.ts b/packages/core-domain/reports.ts index 5f9c14bb..1fc60b14 100644 --- a/packages/core-domain/reports.ts +++ b/packages/core-domain/reports.ts @@ -37,9 +37,8 @@ const identity = () => z.string().meta({ identity: true }); const concludedClaim = z.strictObject({ claim: ref("claim"), asserts: prose(), - // Populated by `recordAnalysis`/`reverify`/`replaceAnalysis`, absent for a - // claim reached by wording (`claimsAsserting`) or one narrowing several - // prior findings (`reinterpret`'s `nowClaims`) — see `ConcludedClaim.finding`. + // Populated for a claim an analysis concluded, absent for a claim reached by + // wording (`claimsAsserting`) — see `ConcludedClaim.finding`. finding: ref("evidence").optional(), }); @@ -94,28 +93,6 @@ export const notes = z.strictObject({ notes: z.array(listedNote), }); -/** - * One step on the path that produced a handle's current state. Superseded steps - * are false starts; `successor` names what stands instead when the graph says. - */ -export const howStep = z.strictObject({ - handle: z.string(), - /** Kind label, or the record's own prose when it has any. */ - what: prose(), - superseded: z.boolean(), - successor: z.string().optional(), - /** Decision reason when the superseding act carried one. */ - because: prose().optional(), - /** Minting event seq when the log joins; absent when it does not. */ - seq: z.number().optional(), -}); - -/** `how` — the ordered acts behind one handle, false starts marked. */ -export const how = z.strictObject({ - subject: z.string(), - steps: z.array(howStep), -}); - /** * `what_happened` — the acts themselves, which is the one thing the graph does not hold. */ @@ -268,22 +245,10 @@ const citedFinding = z.strictObject({ states: z.string(), }); -const affectedClaim = z.strictObject({ - claim: ref("claim"), - asserts: prose(), -}); -const affectedEnquiry = z.strictObject({ - enquiry: ref("enquiry"), - pursuing: prose(), -}); const confirmatoryResult = z.strictObject({ claim: ref("claim"), asserts: prose(), }); -const decidedQuestion = z.strictObject({ - question: ref("question"), - asks: prose(), -}); const replacementClaim = z.strictObject({ claim: ref("claim"), asserts: prose(), @@ -349,24 +314,6 @@ const checkStatus = z.strictObject({ decidedBy: decidingEvaluation.optional(), }); -const amendmentRecord = z.strictObject({ - amendment: ref("decision"), - replaced: condition, - nowRequires: condition, - reason: prose(), - citing: z.array(citedFinding), - rerun: z.array(gatedWork), - nature: z.enum(["mechanical", "scientific", "prespecification"]), -}); - -const revision = z.strictObject({ - revision: ref("decision"), - previously: z.array(concludedClaim), - nowClaims: concludedClaim, - reason: prose(), - restingOnTheOldReading: z.array(decidedQuestion), -}); - /* -- the seven tools' return shapes -------------------------------------- */ export const knowledgeSurvey = z.strictObject({ @@ -378,14 +325,6 @@ export const knowledgeSurvey = z.strictObject({ closedPursuits: z.array(closedPursuit), }); -export const historicalSurvey = z.strictObject({ - at: timestamp(), - established: z.array(questionStanding), - provisional: z.array(questionStanding), - accepted: z.array(questionStanding), - open: z.array(questionStanding), -}); - export const supportExplanation = z.strictObject({ claim: ref("claim"), proposition: prose(), @@ -418,16 +357,6 @@ export const supportExplanation = z.strictObject({ replacedBy: replacementClaim.optional(), }); -export const dependencyReport = z.strictObject({ - subject: ref("observations"), - claims: z.array(affectedClaim), - enquiries: z.array(affectedEnquiry), - routesWalked: z.array(z.string()), - // Literal `false`, not `boolean`. The report is a lower bound and says so in - // its type; a caller must not be able to read `complete: true` from it. - complete: z.literal(false), -}); - export const enquiryQuestion = z.strictObject({ question: ref("question"), asks: prose(), @@ -464,61 +393,8 @@ export const enquiryInContext = z.strictObject({ .nullable(), }); -const conditionHistory = z.strictObject({ - originally: condition, - nowRequires: condition, - criterion: ref("criterion"), - amendments: z.array(amendmentRecord), -}); - -export const designHistory = z.strictObject({ - gate: ref("gate"), - conditions: z.array(conditionHistory), -}); - -export const interpretationHistory = z.strictObject({ - originally: z.array(concludedClaim), - nowClaims: concludedClaim, - revisions: z.array(revision), -}); - -export const reproductionReport = z.strictObject({ - verification: ref("analysis"), - verificationMethod: z.string(), - of: ref("analysis"), - ofMethod: z.string(), - conclusion: z.enum(["agrees", "disagrees"]), - verificationRead: z.array(identifiedArtefact), - ofRead: z.array(identifiedArtefact), - differs: z.array( - z.strictObject({ - what: identifiedArtefact, - standing: z.enum(["unrecorded-in-the-original", "changed", "not-used-by-the-re-run"]), - }), - ), - bearing: z.enum(["raises", "lowers"]), -}); - /* -- the six reads exposed later than the rest ---------------------------- */ -/** - * `origin_of` — `null` for a question somebody simply asked, which is most of them. Wrapped, - * because `structuredContent` must be an object and a bare `null` is not one; `origin: null` - * says "asked outright" rather than "no answer available". - */ -export const questionOrigin = z.strictObject({ - /** Which origin was found. `reason` and `knownAtTheTime` are the sharpened arm's, and empty - * on the other — a note records no reason and cites nothing. */ - kind: z.enum(["sharpened", "noted"]), - from: z.string() as unknown as z.ZodType | Ref<"note">>, - said: z.string(), - reason: z.string().nullable(), - knownAtTheTime: z.array(citedFinding), -}); -export const originOf = z.strictObject({ - origin: questionOrigin.nullable(), -}); - /** * The line of enquiry (and question) a task exists to advance -- see * `Addressing` in `packages/core-domain/report.ts`. Shared rather than inlined per @@ -544,11 +420,6 @@ export const taskContract = z.strictObject({ addressing: addressingSchema.optional(), }); -/** `criteria_governing` — an array, so it is wrapped like `pursuits_of`. */ -export const criteriaGoverning = z.strictObject({ - criteria: z.array(ref("criterion")), -}); - export const gateStatus = z.strictObject({ gate: ref("gate"), consequence: prose(), @@ -713,31 +584,6 @@ export const explanation = z.discriminatedUnion("kind", [ }), ]); -const conflictSide = z.strictObject({ - claim: ref("claim"), - question: ref("question"), - proposition: prose(), - asks: prose(), - supportedBy: z.array(citedFinding), - challengedBy: z.array(citedFinding), -}); - -export const conflictVerdict = z.strictObject({ - conflict: z.boolean(), - relation: z.enum(["contradiction", "dissociation", "corroboration"]), - differsBy: z.literal("scope").nullable(), - sides: z.array(conflictSide), -}); - -export const reproducibilityReport = z.strictObject({ - analysis: ref("analysis"), - exact: z.array(identifiedArtefact), - differing: z.array(identifiedArtefact), - unverifiable: z.array(identifiedArtefact), - notRebuilt: z.array(identifiedArtefact), - reproducible: z.boolean(), -}); - /* -- the write tools' return shapes --------------------------------------- */ /** @@ -791,22 +637,11 @@ export const recordedObservations = z.strictObject({ observations: ref("observations"), events: z.array(domainEvent), }); -/** What `sharpen` returns — #161's audit: the frozen `Decision` was withheld. */ -export const sharpenedQuestion = z.strictObject({ - question: ref("question"), - decision: ref("decision"), - events: z.array(domainEvent), -}); /** What `synthesise` returns — the claim drawn across the findings it rests on. */ export const synthesised = z.strictObject({ claim: ref("claim"), events: z.array(domainEvent), }); -/** What `record_review` returns. */ -export const recordedReview = z.strictObject({ - review: ref("review"), - events: z.array(domainEvent), -}); /** What `close_enquiry` returns — #161's audit: this verb returned nothing. */ export const closedEnquiry = z.strictObject({ decision: ref("decision"), @@ -823,12 +658,6 @@ export const stoppedWork = z.strictObject({ closure: z.literal("stopped"), events: z.array(domainEvent), }); -/** What close_gate returns. */ -export const closedGate = z.strictObject({ - decision: ref("decision"), - gate: ref("gate"), - events: z.array(domainEvent), -}); /** What `plan_work` returns. */ export const plannedWork = z.strictObject({ work: ref("work"), @@ -867,13 +696,6 @@ export const restated = z.strictObject({ events: z.array(domainEvent), }); -/** What `undo` returns — every handle the undone act created, now hidden from the ordinary read surface. */ -export const undone = z.strictObject({ - event: z.number(), - retracted: z.array(z.string() as unknown as z.ZodType>), - events: z.array(domainEvent), -}); - const changedConclusion = z.strictObject({ proposition: prose(), was: ref("claim"), @@ -894,14 +716,6 @@ const unaffectedRecord = z.strictObject({ why: z.string(), }); -export const verificationReport = z.strictObject({ - at: timestamp(), - verification: analysisRef, - of: analysisRef, - claims: z.array(concludedClaim), - events: z.array(domainEvent), -}); - export const amendmentReport = z.strictObject({ at: timestamp(), amendment: ref("decision"), @@ -913,33 +727,6 @@ export const amendmentReport = z.strictObject({ events: z.array(domainEvent), }); -export const replacementReport = z.strictObject({ - at: timestamp(), - replacement: ref("analysis"), - decision: ref("decision"), - supersedes: ref("analysis"), - kept: z.array(ref("claim")), - superseded: z.array(concludedClaim), - events: z.array(domainEvent), -}); - -export const reinterpretationReport = z.strictObject({ - at: timestamp(), - previously: z.array(concludedClaim), - nowClaims: concludedClaim, - evidenceStanding: z.array(citedFinding), - restingOnTheOldReading: z.array(z.strictObject({ question: ref("question"), asks: z.string() })), - requiresRecomputation: z.boolean(), - events: z.array(domainEvent), -}); - -/** `pursuits_of` — `ReadSurface.pursuitsOf` returns an array, which is not an object. */ -export const pursuits = z.strictObject({ - // The bare handle, not `minted("enquiry")` — the wrapper exists only so a - // tool whose *whole* answer is one handle has an object to return. - enquiries: z.array(ref("enquiry")), -}); - /** * What `register_session` recorded. */ @@ -1012,8 +799,6 @@ export type ConcludedClaim = z.infer; export type SearchMatch = z.infer; export type SearchGroup = z.infer; export type Notes = z.infer; -export type HowStep = z.infer; -export type How = z.infer; export type ListedNote = z.infer; export type ChangedConclusion = z.infer; export type UnaffectedRecord = z.infer; @@ -1026,10 +811,7 @@ export type AnsweredQuestion = z.infer; export type ClosedPursuit = z.infer; export type IdentifiedArtefact = z.infer; export type CitedFinding = z.infer; -export type AffectedClaim = z.infer; -export type AffectedEnquiry = z.infer; export type ConfirmatoryResult = z.infer; -export type DecidedQuestion = z.infer; export type ReplacementClaim = z.infer; export type Reverification = z.infer; export type EvaluationRecord = z.infer; @@ -1040,9 +822,6 @@ export type UnmetCheck = z.infer; export type Condition = z.infer; export type DecidingEvaluation = z.infer; export type CheckStatus = z.infer; -export type AmendmentRecord = z.infer; -export type Revision = z.infer; -export type ConditionHistory = z.infer; export type RevisedFinding = z.infer; export type GateGoverned = z.infer; export type AnalysisRevision = z.infer; @@ -1052,50 +831,31 @@ export type ListedWork = z.infer; export type Transcription = z.infer; export type Standing = z.infer; export type KnowledgeSurvey = z.infer; -export type HistoricalSurvey = z.infer; export type SupportExplanation = z.infer; -export type DependencyReport = z.infer; export type EnquiryQuestion = z.infer; export type EnquiryStatus = z.infer; export type EnquiryInContext = z.infer; -export type DesignHistory = z.infer; -export type InterpretationHistory = z.infer; -export type ReproductionReport = z.infer; -export type QuestionOrigin = z.infer; -export type OriginOf = z.infer; export type Addressing = z.infer; export type TaskContract = z.infer; -export type CriteriaGoverning = z.infer; export type GateStatus = z.infer; export type CriterionStanding = z.infer; export type Explanation = z.infer; -export type ConflictSide = z.infer; -export type ConflictVerdict = z.infer; -export type ReproducibilityReport = z.infer; export type RecordedAnalysis = z.infer; export type Posed = z.infer; export type Noted = z.infer; export type Pursued = z.infer; export type OpenedEnquiry = z.infer; export type RecordedObservations = z.infer; -export type SharpenedQuestion = z.infer; export type Synthesised = z.infer; -export type RecordedReview = z.infer; export type ClosedEnquiry = z.infer; export type StoppedWork = z.infer; -export type ClosedGate = z.infer; export type PlannedWork = z.infer; export type StatedCriterion = z.infer; export type DeclaredGate = z.infer; export type EvaluatedCriterion = z.infer; export type AcceptedAsUnresolved = z.infer; export type Restated = z.infer; -export type Undone = z.infer; -export type VerificationReport = z.infer; export type AmendmentReport = z.infer; -export type ReplacementReport = z.infer; -export type ReinterpretationReport = z.infer; -export type Pursuits = z.infer; export type RegisteredSession = z.infer; export type GateList = z.infer; export type WorkList = z.infer; @@ -1146,37 +906,8 @@ export const enquiryList = z.strictObject({ enquiries: z.array(listedEnquiry) }) export const analysisList = z.strictObject({ analyses: z.array(listedAnalysis) }); export const criterionList = z.strictObject({ criteria: z.array(listedCriterion) }); -/** One conclusion, with the finding that reached it. */ -export const learnedFinding = z.strictObject({ - claim: ref("claim"), - asserts: prose(), - bearing: z.enum(["supports", "challenges"]), - /** A decision promoted it: others may build on it. */ - confirmed: z.boolean(), - finding: ref("evidence"), - states: prose(), -}); -export type LearnedFinding = z.infer; - -export const learnedUnderQuestion = z.strictObject({ - question: ref("question"), - asks: prose(), - found: z.array(learnedFinding), -}); -export type LearnedUnderQuestion = z.infer; - -/** `learned` — what the programme found out, under the question it was asked for. */ -export const learned = z.strictObject({ - questions: z.array(learnedUnderQuestion), - found: z.number(), -}); -export type Learned = z.infer; - /** Every exported schema in this module, so PROSE_FIELDS can walk them all. */ const SCHEMAS = { - learned, - learnedUnderQuestion, - learnedFinding, claimList, enquiryList, analysisList, @@ -1188,53 +919,33 @@ const SCHEMAS = { claimsAsserting, search, notes, - howStep, - how, whatHappened, domainEvent, knowledgeSurvey, - historicalSurvey, supportExplanation, - dependencyReport, enquiryQuestion, enquiryStatus, enquiryInContext, - designHistory, - interpretationHistory, - reproductionReport, - questionOrigin, - originOf, taskContract, - criteriaGoverning, gateStatus, criterionStanding, explanation, - conflictVerdict, - reproducibilityReport, recordedAnalysis, posed, noted, pursued, openedEnquiry, recordedObservations, - sharpenedQuestion, synthesised, - recordedReview, closedEnquiry, stoppedWork, - closedGate, plannedWork, statedCriterion, declaredGate, evaluatedCriterion, acceptedAsUnresolved, restated, - undone, - verificationReport, amendmentReport, - replacementReport, - reinterpretationReport, - pursuits, registeredSession, gateList, workList, diff --git a/packages/core-domain/write/asking.ts b/packages/core-domain/write/asking.ts index c6f61bca..165c96c8 100644 --- a/packages/core-domain/write/asking.ts +++ b/packages/core-domain/write/asking.ts @@ -5,7 +5,6 @@ import { labelForNaturalId, type Prose } from "@labkit/core-db/domain"; import type { TenantGraph } from "@labkit/core-db/graph"; import type { EnquiryRef, - EvidenceRef, OpenedEnquiry, AnyRef, Noted, @@ -13,10 +12,9 @@ import type { Posed, Pursued, QuestionRef, - SharpenedQuestion, } from "../report"; import { KIND_BY_LABEL, ref, stagedRef } from "../report"; -import type { NoteCommand, PoseCommand, PursueCommand, SharpenCommand } from "../commands"; +import type { NoteCommand, PoseCommand, PursueCommand } from "../commands"; import { SessionCore, type ResearchSessionOptions } from "../core"; import type { Handle } from "./index"; import type { UnitOfWork } from "../projection"; @@ -204,46 +202,4 @@ export class Asking extends SessionCore { }, ); } - - /** - * Sharpens a question into a more precise one, recording the act rather than editing the - * original. - */ - async sharpen(input: SharpenCommand): Promise { - return this.handle("sharpen", input, async (unitOfWork) => { - const original = await this.graph.query( - `MATCH (q:Question {natural_id: $id}) RETURN q`, - { q: vertexProps<{ name: string }>() }, - { id: input.from }, - ); - if (original.length === 0) throw new Error(`${input.from} not found`); - - const standing = await this.standingFindings(); - - const decision = unitOfWork.node("Decision", { - decided_at: this.clock.now(), - reason: input.because, - invalidation_check: "evidence that the sharper question was the wrong one to ask", - }); - unitOfWork.edge(decision, "SHARPENS", input.from); - for (const finding of standing) unitOfWork.edge(decision, "BASED_ON", finding); - - const sharper = await this.posed(input.into, unitOfWork); - unitOfWork.edge(decision, "MOTIVATES", sharper); - - return { - subject: sharper, - result: { question: sharper, decision: stagedRef("decision", decision) }, - }; - }); - } - - /** Every finding currently on the record — what "we knew at the time" means when an act is recorded. */ - private async standingFindings(): Promise { - const rows = await this.graph.query( - `MATCH (:EvidenceUnit)-[:PRODUCES]->(e:Evidence) RETURN e`, - { e: vertexProps<{ natural_id: string }>() }, - ); - return rows.map((r) => ref("evidence", r.e.natural_id)); - } } diff --git a/packages/core-domain/write/index.ts b/packages/core-domain/write/index.ts index cc95d4fa..d6df7f8a 100644 --- a/packages/core-domain/write/index.ts +++ b/packages/core-domain/write/index.ts @@ -8,7 +8,6 @@ import type { AcceptedAsUnresolved, AmendmentReport, ClosedEnquiry, - ClosedGate, DeclaredGate, EvaluatedCriterion, StoppedWork, @@ -19,43 +18,28 @@ import type { Posed, Pursued, Ref, - ReinterpretationReport, RecordedAnalysis, RecordedObservations, - RecordedReview, Synthesised, - ReplacementReport, Restated, - Undone, - SharpenedQuestion, StatedCriterion, - VerificationReport, } from "../report"; import type { Command, AcceptAsUnresolvedCommand, AmendDesignCommand, CloseEnquiryCommand, - CloseGateCommand, StopWorkCommand, PoseCommand, ConcludeCommand, DeclareGateCommand, EvaluateCriterionCommand, ClaimIsConfirmedCommand, - ClaimIsUndecidedCommand, - KeepCommand, - UndoCommand, NoteCommand, PlanWorkCommand, PursueCommand, RecordAnalysisCommand, RecordObservationsCommand, - RecordReviewCommand, - ReinterpretCommand, - ReplaceAnalysisCommand, - ReverifyCommand, - SharpenCommand, SynthesiseCommand, } from "../commands"; import { SessionCore, type Methods, type ResearchSessionOptions } from "../core"; @@ -77,11 +61,6 @@ export type ResearchWrites = Pick>; */ export type Operation = Methods; -/** - * An operation no verb writes any more, but that recorded events still carry. - */ -export type RetiredOperation = "promote" | "is"; - /** What a verb's body returns: what the act was about, and what it produced. */ export interface Act { subject: Ref; @@ -110,8 +89,8 @@ export class WriteSurface extends SessionCore { super(graph, options); // **`this.events`, not `options.events`.** `SessionCore` defaults an absent // sink to a fresh `inMemoryEventLog()` per surface, so passing `options` - // straight down gave each group a log of its own: `handling` recorded into - // this one while `undo` read the revising group's, which was empty. + // straight down gave each group a log of its own, and a group reading the log + // would read one `handling` never records into. const shared: ResearchSessionOptions = { ...options, events: this.events }; const handle: Handle = (operation, command, work) => this.handling(operation, command, work); this.asking = new Asking(graph, shared, handle); @@ -137,10 +116,6 @@ export class WriteSurface extends SessionCore { return this.asking.openEnquiry(question, from); } - async sharpen(input: SharpenCommand): Promise { - return this.asking.sharpen(input); - } - async recordObservations(input: RecordObservationsCommand): Promise { return this.work.recordObservations(input); } @@ -157,10 +132,6 @@ export class WriteSurface extends SessionCore { return this.work.synthesise(input); } - async recordReview(input: RecordReviewCommand): Promise { - return this.work.recordReview(input); - } - async closeEnquiry(input: CloseEnquiryCommand): Promise { return this.stopping.closeEnquiry(input); } @@ -169,10 +140,6 @@ export class WriteSurface extends SessionCore { return this.stopping.acceptAsUnresolved(input); } - async closeGate(input: CloseGateCommand): Promise { - return this.stopping.closeGate(input); - } - async stopWork(input: StopWorkCommand): Promise { return this.stopping.stopWork(input); } @@ -197,34 +164,10 @@ export class WriteSurface extends SessionCore { return this.counting.amendDesign(input); } - async reverify(input: ReverifyCommand): Promise { - return this.revising.reverify(input); - } - - async isUndecided(input: ClaimIsUndecidedCommand): Promise { - return this.revising.isUndecided(input); - } - async isConfirmed(input: ClaimIsConfirmedCommand): Promise { return this.revising.isConfirmed(input); } - async undo(input: UndoCommand): Promise { - return this.revising.undo(input); - } - - async replaceAnalysis(input: ReplaceAnalysisCommand): Promise { - return this.revising.replaceAnalysis(input); - } - - async keep(input: KeepCommand): Promise { - return this.revising.keep(input); - } - - async reinterpret(input: ReinterpretCommand): Promise { - return this.revising.reinterpret(input); - } - /** * One command: a transaction, a unit of work, one event, and the graph * projected from it. The verb queries and enforces and stages; nothing else diff --git a/packages/core-domain/write/revising.ts b/packages/core-domain/write/revising.ts index d7df41b9..d4988158 100644 --- a/packages/core-domain/write/revising.ts +++ b/packages/core-domain/write/revising.ts @@ -1,62 +1,12 @@ /** Same thing, understood differently now. */ -import { scalar, vertexProps } from "@labkit/core-db/cypher"; -import type { ClaimProps, EdgeProps, Prose } from "@labkit/core-db/domain"; import type { TenantGraph } from "@labkit/core-db/graph"; -import { createdIn, edgesIn } from "../events"; -import type { - AnalysisRef, - CitedFinding, - ClaimRef, - ConcludedClaim, - EnquiryRef, - EvidenceRef, - Kind, - ObservationsRef, - Ref, - ReinterpretationReport, - ReplacementReport, - Restated, - ReviewRef, - Undone, - VerificationReport, -} from "../report"; -import { byHandle, kindOf, ref, stagedRef } from "../report"; -import type { - ClaimIsConfirmedCommand, - ClaimIsUndecidedCommand, - KeepCommand, - ReinterpretCommand, - ReplaceAnalysisCommand, - ReverifyCommand, - UndoCommand, -} from "../commands"; +import type { Restated } from "../report"; +import { stagedRef } from "../report"; +import type { ClaimIsConfirmedCommand } from "../commands"; import type { ResearchSessionOptions } from "../core"; import type { Handle } from "./index"; -import { asConcludedClaim, Shared } from "./shared"; -import type { UnitOfWork } from "../projection"; - -/** A handle whose specific kind is not known in advance — any id this record minted. */ -const anyRef = (id: string): Ref => ref((kindOf(id) ?? id) as Kind, id); - -/** What would make a decision of each class wrong. */ -const INVALIDATION_CHECK = { - undecided: "a further finding that settles the proposition either way", - confirmed: "evidence that the promoted result does not replicate", -} as const; - -/** - * What to write to take a property change back. - * - * A key the graph did not hold before is removed by writing `null` — there is - * no delete-property verb, and `null` is what every read already treats as - * absent. - */ -const restored = ( - before: Record, - after: Record, -): Record => - Object.fromEntries(Object.keys(after).map((k) => [k, k in before ? before[k] : null])); +import { Shared } from "./shared"; export class Revising extends Shared { constructor( @@ -67,117 +17,20 @@ export class Revising extends Shared { super(graph, options); } - /** - * Records that a historical result was re-checked, without claiming its run was reproduced. - */ - async reverify(input: ReverifyCommand): Promise { - return this.handle("reverify", input, async (unitOfWork) => { - const at = this.clock.now(); - // **One hop, inferred rather than restated.** The analysis being - // re-checked knows the enquiry it was recorded under, so a caller who - // named the analysis has already said which one. An explicit `enquiry` - // wins: a re-check may legitimately belong to a different line of - // enquiry than the analysis it re-checks, and only the caller knows that. - const enquiry = input.enquiry ?? (await this.enquiryOf(input.historical)); - if (!enquiry) - throw new Error( - `${input.historical} is under no line of enquiry. Name one with --enquiry.`, - ); - - const original = await this.findingFor(input.historical, input.concludes.proposition); - if (!original) { - throw new Error( - `${input.historical} concluded nothing about "${input.concludes.proposition}"`, - ); - } - - const { analysis, unit, output } = await this.recorded( - { enquiry, method: input.method, from: input.under }, - unitOfWork, - ); - - // The analysis this conclusion hangs off is in the same delta, so its - // unit, output and enquiry are handed over rather than queried for. - const concluded = await this.concluding( - { - analysis, - proposition: input.concludes.proposition, - finding: input.concludes.finding, - ...(input.concludes.bearing === undefined ? {} : { bearing: input.concludes.bearing }), - ...(input.concludes.standing === undefined ? {} : { standing: input.concludes.standing }), - }, - unitOfWork, - { unit, output, enquiry }, - ); - - // `REVERIFIES` is evidence-to-evidence and says the same proposition was - // checked again -- deliberately NOT the supersession `conclude - // --replacing` writes, which says a finding was replaced. Two different - // claims about two different acts; see `conclude`'s header. - unitOfWork.edge(concluded.finding, "REVERIFIES", original); - - return { - subject: analysis, - result: { - at, - verification: analysis, - of: input.historical, - claims: [asConcludedClaim(concluded)], - }, - }; - }); - } - - /** - * Records that a finding settles the proposition neither way. - */ - async isUndecided(input: ClaimIsUndecidedCommand): Promise { - return this.restating("isUndecided", input, { - reason: "recorded as undecided", - invalidation_check: INVALIDATION_CHECK.undecided, - connect: (unitOfWork, decision) => { - unitOfWork.edge(decision, "GRADES", input.claim); - unitOfWork.edge(decision, "BASED_ON", input.because); - }, - }); - } - /** * Records that a finding is something others may build on. */ async isConfirmed(input: ClaimIsConfirmedCommand): Promise { - return this.restating("isConfirmed", input, { - reason: input.because, - invalidation_check: INVALIDATION_CHECK.confirmed, - connect: (unitOfWork, decision) => { - unitOfWork.edge(decision, "CONFIRMED", input.claim); - }, - }); - } - - /** - * Shared graph work for `isUndecided` / `isConfirmed`. Private so a public-verb - * sweep does not treat it as a write verb. - */ - private restating( - operation: "isUndecided" | "isConfirmed", - input: ClaimIsUndecidedCommand | ClaimIsConfirmedCommand, - spec: { - reason: Prose; - invalidation_check: Prose; - connect: (unitOfWork: UnitOfWork, decision: Ref<"decision">) => void; - }, - ): Promise { - return this.handle(operation, input, async (unitOfWork) => { + return this.handle("isConfirmed", input, async (unitOfWork) => { const decision = stagedRef( "decision", unitOfWork.node("Decision", { decided_at: this.clock.now(), - reason: spec.reason, - invalidation_check: spec.invalidation_check, + reason: input.because, + invalidation_check: "evidence that the promoted result does not replicate", }), ); - spec.connect(unitOfWork, decision); + unitOfWork.edge(decision, "CONFIRMED", input.claim); return { subject: input.claim, @@ -185,413 +38,4 @@ export class Revising extends Shared { }; }); } - - /** - * Takes back a mistaken act, naming the event it recorded. - */ - async undo(input: UndoCommand): Promise { - return this.handle("undo", input, async (unitOfWork) => { - // No exact-seq filter exists, and `since` finds the next event, which is a different - // one when a rolled-back write left a gap. Hence the equality check. - const [found] = await this.events.select({ since: input.event - 1, limit: 1 }); - if (found?.seq !== input.event) throw new Error(`${input.event} not found`); - - // The event log captures the command that caused the events to be raised. - // When properties are changed, the events log the deltas. - // Undoing a command that mutated props means re-setting props values to their prior - // state, or deleting any keys that were added. - const restoring = found.changes.filter( - (c) => c.change === "NodePropsChanged" || c.change === "EdgePropsChanged", - ); - for (const change of restoring) { - if (change.change === "NodePropsChanged") - unitOfWork.set(change.id, restored(change.before, change.after)); - else - unitOfWork.setEdge( - change.from, - change.label, - change.to, - restored(change.before, change.after) as EdgeProps, - ); - } - - const retracting = createdIn(found); - if (retracting.length === 0 && restoring.length === 0) - throw new Error(`${input.event} (${found.operation}) changed nothing to take back`); - - // Two unlabelled Cypher MATCHes, one per direction: everything outside this act that - // points at, or is pointed at by, a node the act created. Those nodes are about to be - // marked `retracted`, so anything still reaching them is a reason to refuse. - // Unlabelled because a dependent can be any kind of node. - const into = - retracting.length === 0 - ? [] - : await this.graph.query( - `MATCH (external)-[r]->(target) - WHERE target.natural_id IN $ids - AND NOT external.natural_id IN $ids - AND external.retracted IS NULL - RETURN external AS origin, type(r) AS via, target AS reaches`, - { - origin: vertexProps<{ natural_id: string }>(), - via: scalar(), - reaches: vertexProps<{ natural_id: string }>(), - }, - { ids: retracting }, - ); - const outOf = - retracting.length === 0 - ? [] - : await this.graph.query( - `MATCH (source)-[r]->(external) - WHERE source.natural_id IN $ids - AND NOT external.natural_id IN $ids - AND external.retracted IS NULL - RETURN source AS origin, type(r) AS via, external AS reaches`, - { - origin: vertexProps<{ natural_id: string }>(), - via: scalar(), - reaches: vertexProps<{ natural_id: string }>(), - }, - { ids: retracting }, - ); - // Minus the edges this act wrote itself. `conclude` wiring `unit PRODUCES evidence` - // reaches a pre-existing unit; that is the act's own wiring, not a dependent. - const ownEdges = new Set(edgesIn(found).map((e) => `${e.from}|${e.label}|${e.to}`)); - const dependents = [...into, ...outOf].filter( - (d) => !ownEdges.has(`${d.origin.natural_id}|${d.via}|${d.reaches.natural_id}`), - ); - if (dependents.length > 0) { - const named = dependents - .map( - (d) => `${anyRef(d.origin.natural_id)} -[${d.via}]-> ${anyRef(d.reaches.natural_id)}`, - ) - .join(", "); - throw new Error( - `${input.event} (${found.operation}) cannot be undone: ${named} rests on what it created.`, - ); - } - - for (const id of retracting) unitOfWork.set(id, { retracted: true }); - - return { - subject: anyRef(found.subject), - result: { event: input.event, retracted: retracting.map(anyRef) }, - }; - }); - } - - /** - * Replaces an analysis, supersedes the inference it stood on, and returns what moved. - * - * One instruction, so one verb. The replacement is recorded against the same observations. - */ - async replaceAnalysis(input: ReplaceAnalysisCommand): Promise { - return this.revise({ ...input, keeping: [] }, "replaceAnalysis"); - } - - /** - * Revises an analysis by naming the conclusions that survive. - */ - async keep(input: KeepCommand): Promise { - if (input.keeping.length === 0) - throw new Error(`keep needs at least one claim. \`replace\` supersedes an analysis whole.`); - const spans = await this.analysesConcluding(input.keeping); - if (spans.length !== 1) - throw new Error( - spans.length === 0 - ? `no analysis concluded ${input.keeping.join(", ")}; keep carries forward conclusions ` + - `of the analysis being revised, and 'why' on a claim names the analysis that drew it` - : `${input.keeping.join(", ")} were concluded by ${spans.join(" and ")}, and a revision ` + - `revises one analysis; keep the claims of one of them`, - ); - return this.revise({ ...input, supersedes: spans[0]! }, "keep"); - } - - /** The analyses that concluded these claims — one entry per distinct analysis. */ - private async analysesConcluding(claims: ClaimRef[]): Promise { - const found = new Set(); - // Both bearings: a claim reached by a challenging finding was concluded by - // the analysis that challenged it, exactly as a supporting one was. - for (const bearing of ["SUPPORTS", "CHALLENGES"] as const) { - const rows = await this.graph.query( - `MATCH (c:Claim) WHERE c.natural_id IN $ids - MATCH (e:Evidence)-[:${bearing}]->(c) - MATCH (u:EvidenceUnit)-[:PRODUCES]->(e) - MATCH (u)-[:USES]->(comp:Computation) - RETURN comp`, - { comp: vertexProps<{ natural_id: string }>() }, - { ids: claims }, - ); - for (const row of rows) found.add(row.comp.natural_id); - } - return [...found].sort().map((id) => ref("analysis", id)); - } - - /** What an analysis consumed, so a revision of it inherits the same inputs. */ - private async inputsOf(analysis: AnalysisRef): Promise { - const rows = await this.graph.query( - `MATCH (:Computation {natural_id: $id})-[:CONSUMES]->(a:Artefact) - RETURN a`, - { a: vertexProps<{ natural_id: string }>() }, - { id: analysis }, - ); - return rows.map((r) => ref("observations", r.a.natural_id)); - } - - /** `keep` and `replaceAnalysis`, which differ only in how much they carry forward. */ - private async revise( - input: KeepCommand & { supersedes: AnalysisRef }, - /** - * Which act the caller performed. - */ - operation: "keep" | "replaceAnalysis", - ): Promise { - return this.handle(operation, input, async (unitOfWork) => { - const at = this.clock.now(); - - await this.assertReviewOf(input.because, input.supersedes); - const output = await this.outputArtefactOf(input.supersedes); - const inherited = await this.inputsOf(input.supersedes); - const enquiry = (await this.enquiryOf(input.supersedes)) as EnquiryRef; - const before = await this.conclusionsOf(input.supersedes); - - // **A finding falls once.** One already withdrawn by another act -- - // narrowed by a reinterpretation, superseded by an earlier revision -- - // cannot fall again here: two decisions would stand instead of one - // claim, each naming a different successor, and no reader can say - // which holds. - const kept = new Set(input.keeping); - for (const c of before) { - if (kept.has(c.claim)) continue; - const gone = await this.supersessionOf(c.claim); - if (gone !== undefined) - throw new Error(`${c.claim} has already been withdrawn by ${gone}. Keep it instead.`); - } - - // An edge to the review that found it wanting, not a flag on the - // artefact: a flag would summarise the standing of every finding the - // artefact carries, and standing is per finding. - unitOfWork.edge(output, "INVALIDATED_BY", input.because); - - // Add-only: the successor reads what its predecessor read, plus - // whatever this call names. - const { analysis: replacement } = await this.recorded( - { enquiry, method: input.method, from: [...inherited, ...(input.from ?? [])] }, - unitOfWork, - ); - - // **One decision carries the whole act**: this analysis stands in - // place of that one, on this review, superseding these findings and - // keeping those. A reader of the decision sees all of it. - const decision = stagedRef( - "decision", - unitOfWork.node("Decision", { - decided_at: at, - reason: `superseded by a re-run: ${input.method}`, - invalidation_check: "evidence that the superseded analysis was sound after all", - }), - ); - unitOfWork.edge(decision, "SUPERSEDES", input.supersedes); - unitOfWork.edge(decision, "MOTIVATES", replacement); - unitOfWork.edge(decision, "BASED_ON", input.because); - - // Every conclusion not kept falls now. A kept one is **not - // re-parented**: it keeps the evidence that produced it, so asking - // why it holds still answers with the run that produced the number. - const superseded: ConcludedClaim[] = []; - for (const c of before) { - if (kept.has(c.claim)) { - unitOfWork.edge(decision, "KEEPS", c.claim); - continue; - } - unitOfWork.edge(decision, "SUPERSEDES", c.claim); - superseded.push({ claim: c.claim, asserts: c.proposition }); - } - - return { - subject: replacement, - result: { - at, - replacement, - decision, - supersedes: input.supersedes, - kept: input.keeping, - superseded, - }, - }; - }); - } - - /** - * Narrows an interpretation without touching anything it was inferred from. - */ - async reinterpret(input: ReinterpretCommand): Promise { - return this.handle("reinterpret", input, async (unitOfWork) => { - const at = this.clock.now(); - const origin = await this.claimOrigin(input.of); - if (origin?.kind === "synthesis") { - const named = (await this.standingOf([input.of])).get(input.of); - if (named?.withdrawn) - throw new Error( - "claim " + - input.of + - " no longer stands: " + - named.by.join(" and ") + - " withdrew it. " + - (named.insteadOf.length > 0 - ? "Reinterpret " + - named.insteadOf.map((c) => c.claim).join(" or ") + - " which stands in its place" - : "The record does not say which claim stands in its place; " + - "'labkit why " + - named.by[0] + - "' says what the act was"), - ); - - const withdrawn: ConcludedClaim[] = [{ claim: input.of, asserts: origin.asserts }]; - const review = unitOfWork.node("Review", { verdict: input.because }); - const narrower = stagedRef( - "claim", - unitOfWork.node("Claim", { name: input.as, kind: "exploratory" }), - ); - const decision = unitOfWork.node("Decision", { - decided_at: at, - reason: input.because, - invalidation_check: "evidence that the original reading was right after all", - }); - unitOfWork.edge(decision, "MOTIVATES", narrower); - unitOfWork.edge(review, "EVALUATES", input.of); - unitOfWork.edge(decision, "SUPERSEDES", input.of); - for (const part of origin.parts) unitOfWork.edge(narrower, "BASED_ON", part); - - return { - subject: narrower, - result: { - at, - previously: withdrawn, - nowClaims: { claim: narrower, asserts: input.as }, - evidenceStanding: [], - restingOnTheOldReading: [], - requiresRecomputation: false, - }, - }; - } - - // A reinterpretation narrows a READING, not one node: two analyses in one - // line of enquiry concluding the same sentence share a reading, and both - // stop standing. So the scope is (proposition, enquiry) -- but reached - // from the named claim rather than searched for, so nothing is guessed - // about which reading was narrowed. - const scope = await this.scopeOf(input.of); - const previously = scope.proposition; - const claims = await this.graph.query( - `MATCH (c:Claim {name: $name})<-[:SUPPORTS]-(:Evidence)<-[:PRODUCES]-(u:EvidenceUnit) - ${this.withinScope(scope)} - RETURN c`, - { c: vertexProps<{ natural_id: string }>() }, - { - name: scope.proposition, - ...this.scopeParams(scope), - }, - ); - if (claims.length === 0) throw new Error(`${input.of} not found`); - - // Every record this act withdraws, by handle. The reading is one sentence - // and the records asserting it are several -- reporting the sentence alone - // left a caller unable to name which claims stopped standing, and reporting - // one handle would have picked between them arbitrarily. - const withdrawnIds = [...new Set(claims.map((c) => c.c.natural_id))].map((id) => - ref("claim", id), - ); - const withdrawn: ConcludedClaim[] = withdrawnIds.map((claim) => ({ - claim, - asserts: previously, - })); - - // **The match above is on wording, and wording does not say whether a claim still - // stands.** A reading narrowed weeks ago carries its name and its evidence unchanged, so - // it matches again -- and narrowing it a second time puts two successors on one reading - // with nothing saying which the record asserts. - const named = (await this.standingOf([input.of])).get(input.of); - if (named?.withdrawn) - throw new Error( - `claim ${input.of} no longer stands: ${named.by.join(" and ")} withdrew it. ` + - (named.insteadOf.length > 0 - ? `Reinterpret ${named.insteadOf.map((c) => c.claim).join(" or ")}, which stands in its place` - : `The record does not say which claim stands in its place; ` + - `'labkit why ${named.by[0]}' says what the act was`), - ); - // One query for every withdrawn claim's evidence, not one per claim. - // Deduplicated by the Map below: the query selects `natural_id` AND - // `statement` and keying on the statement merged two findings phrased - // alike -- in the field whose whole job is showing the findings survived - // unchanged. - const evidence = await this.graph.query( - `MATCH (e:Evidence)-[:SUPPORTS]->(c:Claim) WHERE c.natural_id IN $ids RETURN e`, - { e: vertexProps<{ natural_id: string; statement: string }>() }, - { ids: withdrawnIds }, - ); - const restingOnTheOldReading = await this.decidedOnTheStrengthOf(scope); - - const review = unitOfWork.node("Review", { verdict: input.because }); - const narrower = stagedRef( - "claim", - unitOfWork.node("Claim", { name: input.as, kind: "exploratory" }), - ); - // The review records that someone objected; the decision records that the - // objection was acted on. Reviews also confirm, so a review alone cannot - // mean "withdrawn" without reading its prose. - const decision = unitOfWork.node("Decision", { - decided_at: this.clock.now(), - reason: input.because, - invalidation_check: "evidence that the original reading was right after all", - }); - unitOfWork.edge(decision, "MOTIVATES", narrower); - - for (const id of withdrawnIds) { - unitOfWork.edge(review, "EVALUATES", id); - unitOfWork.edge(decision, "SUPERSEDES", id); - } - - // Keyed by id: keying on the statement merged two findings phrased alike. - const carried = new Map(); - for (const row of evidence) { - unitOfWork.edge(row.e.natural_id, "SUPPORTS", narrower); - const finding = ref("evidence", row.e.natural_id); - carried.set(finding, { evidence: finding, states: row.e.statement }); - } - - return { - subject: narrower, - result: { - at, - previously: withdrawn, - // The act records what it produced: without this a caller has to go - // back through `claimsAsserting` to name what this very call created. - nowClaims: { claim: narrower, asserts: input.as }, - evidenceStanding: [...carried.values()].sort((a, b) => byHandle(a.evidence, b.evidence)), - restingOnTheOldReading, - requiresRecomputation: false, - }, - }; - }); - } - - /** - * A replacement must be justified by a review OF the analysis being replaced -- otherwise any - * review's verdict could retire any analysis, and `whySupported()` would report a withdrawal - * reason that never referred to the withdrawn work. - */ - private async assertReviewOf(review: ReviewRef, analysis: AnalysisRef): Promise { - const rows = await this.graph.query( - `MATCH (:Review {natural_id: $review})-[:EVALUATES]->(:EvidenceUnit)-[:USES]->(:Computation {natural_id: $analysis}) - RETURN 1`, - { ok: scalar() }, - { review: review, analysis: analysis }, - ); - if (rows.length === 0) { - throw new Error(`${review} does not review ${analysis}`); - } - } } diff --git a/packages/core-domain/write/shared.ts b/packages/core-domain/write/shared.ts index 42d0a91b..af46e286 100644 --- a/packages/core-domain/write/shared.ts +++ b/packages/core-domain/write/shared.ts @@ -98,27 +98,6 @@ export class Shared extends SessionCore { }; } - /** - * Why a claim no longer stands, or `undefined` if it does. - */ - protected async supersessionOf(claim: ClaimRef): Promise | undefined> { - const rows = await this.graph.query( - `MATCH (c:Claim {natural_id: $id}) - OPTIONAL MATCH (narrowed:Decision)-[:SUPERSEDES]->(c) - OPTIONAL MATCH (replaced:Decision)-[:SUPERSEDES]->(c) - RETURN narrowed, replaced`, - { - narrowed: optional(vertexProps<{ natural_id: string }>()), - replaced: optional(vertexProps<{ natural_id: string }>()), - }, - { id: claim }, - ); - const found = rows - .map((r) => r.narrowed?.natural_id ?? r.replaced?.natural_id) - .find((r) => r !== undefined); - return found === undefined ? undefined : ref("decision", found); - } - /** * The finding this conclusion stands in place of, when the act determines it. */ @@ -129,7 +108,7 @@ export class Shared extends SessionCore { const revision = await this.revisedBy(analysis); if (revision === undefined) return undefined; // **Scoped to what this revision superseded, not to everything the old - // analysis concluded.** `keep` carries conclusions forward, and a kept + // analysis concluded.** A recorded `keep` carries conclusions forward, and a kept // finding still stands; pairing to one would say a live finding was // replaced. const fell = await this.graph.query( @@ -228,18 +207,6 @@ export class Shared extends SessionCore { return ref("observations", found.a.natural_id); } - /** The enquiry an analysis was recorded under, for the withdrawal guard's scope. */ - protected async enquiryOf(analysis: AnalysisRef): Promise { - const rows = await this.graph.query( - `MATCH (:Computation {natural_id: $id})<-[:USES]-(:EvidenceUnit)-[:ADDRESSES]->(l:LineOfEnquiry) - RETURN l`, - { l: vertexProps<{ natural_id: string }>() }, - { id: analysis }, - ); - const found = rows[0]; - return found ? ref("enquiry", found.l.natural_id) : undefined; - } - /** The criteria whose `QUALIFIES` edges hold an analysis's unit to them. */ protected async heldToOf(analysis: AnalysisRef): Promise { const rows = await this.graph.query( @@ -400,18 +367,4 @@ export class Shared extends SessionCore { } } } - - /** `{claim, finding, proposition}` per conclusion — the event's own record of the pairing, independent of the typed report. */ - protected conclusionEvents(claims: ConcludedWithStanding[]): Record[] { - return claims.map((c) => ({ - claim: c.claim, - finding: c.finding, - proposition: c.asserts, - // **Per conclusion, because the array is the record of what was - // concluded.** Without it the log cannot say whether a claim now reading - // `confirmatory` was recorded that way or promoted afterwards, and a - // reader has to infer it from whether a `promote` happens to follow. - standing: c.standing, - })); - } } diff --git a/packages/core-domain/write/stopping.ts b/packages/core-domain/write/stopping.ts index 386458b2..400bd0f2 100644 --- a/packages/core-domain/write/stopping.ts +++ b/packages/core-domain/write/stopping.ts @@ -5,7 +5,6 @@ import type { TenantGraph } from "@labkit/core-db/graph"; import type { AcceptedAsUnresolved, ClosedEnquiry, - ClosedGate, EnquiryRef, EvidenceRef, QuestionRef, @@ -13,12 +12,7 @@ import type { } from "../report"; import { ref, stagedRef } from "../report"; import { DomainRefusal } from "../refusal"; -import type { - AcceptAsUnresolvedCommand, - CloseEnquiryCommand, - CloseGateCommand, - StopWorkCommand, -} from "../commands"; +import type { AcceptAsUnresolvedCommand, CloseEnquiryCommand, StopWorkCommand } from "../commands"; import { SessionCore, type ResearchSessionOptions } from "../core"; import type { Handle } from "./index"; @@ -190,48 +184,6 @@ export class Stopping extends SessionCore { }); } - /** Close one gate without changing what any criterion verdict says. */ - async closeGate(input: CloseGateCommand): Promise { - return this.handle("closeGate", input, async (unitOfWork) => { - const [target] = await this.graph.query( - `MATCH (g:Gate {natural_id: $id}) - OPTIONAL MATCH (d:Decision)-[:CLOSES]->(g) - RETURN g, d`, - { - g: vertexProps<{ natural_id: string; consequence: string }>(), - d: optional(vertexProps<{ natural_id: string; reason: string }>()), - }, - { id: input.gate }, - ); - if (!target) - throw new DomainRefusal({ - kind: "not-found", - message: `${input.gate} not found`, - subject: input.gate, - }); - if (target.d) - throw new DomainRefusal({ - kind: "invariant", - message: `${input.gate} is already closed by ${target.d.natural_id}: ${target.d.reason}`, - subject: input.gate, - }); - - const decision = stagedRef( - "decision", - unitOfWork.node("Decision", { - decided_at: this.clock.now(), - reason: input.because, - invalidation_check: "a reason for this gate to govern work again", - }), - ); - unitOfWork.edge(decision, "CLOSES", input.gate); - return { - subject: input.gate, - result: { decision, gate: input.gate }, - }; - }); - } - /** Planned work somebody decided not to do. */ async stopWork(input: StopWorkCommand): Promise { return this.handle("stopWork", input, async (unitOfWork) => { diff --git a/packages/core-domain/write/work.ts b/packages/core-domain/write/work.ts index b1bf513f..32d4b163 100644 --- a/packages/core-domain/write/work.ts +++ b/packages/core-domain/write/work.ts @@ -1,20 +1,14 @@ -/** Measuring, analysing, concluding, reviewing. */ +/** Measuring, analysing, concluding. */ import { vertexProps } from "@labkit/core-db/cypher"; import type { TenantGraph } from "@labkit/core-db/graph"; -import type { - RecordedAnalysis, - RecordedObservations, - RecordedReview, - Synthesised, -} from "../report"; +import type { RecordedAnalysis, RecordedObservations, Synthesised } from "../report"; import { stagedRef } from "../report"; import type { ConcludeCommand, SynthesiseCommand, RecordAnalysisCommand, RecordObservationsCommand, - RecordReviewCommand, } from "../commands"; import type { ResearchSessionOptions } from "../core"; import type { Handle } from "./index"; @@ -132,21 +126,4 @@ export class Work extends Shared { }; }); } - - /** - * Records a reviewer's finding about an analysis. - */ - async recordReview(input: RecordReviewCommand): Promise { - return this.handle("recordReview", input, async (unitOfWork) => { - const unit = await this.unitOf(input.of); - - const review = stagedRef("review", unitOfWork.node("Review", { verdict: input.verdict })); - unitOfWork.edge(review, "EVALUATES", unit); - - return { - subject: review, - result: { review }, - }; - }); - } } diff --git a/tests/cli/views.test.ts b/tests/cli/views.test.ts index 24c7afc3..768899ba 100644 --- a/tests/cli/views.test.ts +++ b/tests/cli/views.test.ts @@ -12,66 +12,15 @@ import { renderWhy, renderWhyDispatch, renderClaims, - renderConflict, } from "@labkit/app-cli/views/knowledge"; -import { renderEnquiry, renderOrigin } from "@labkit/app-cli/views/enquiry"; -import { renderContract, renderGate } from "@labkit/app-cli/views/gates"; -import { renderReproducibility, renderReproduction } from "@labkit/app-cli/views/analysis"; import { renderHappened } from "@labkit/app-cli/views/events"; import type { - ConflictVerdict, RecordedEvent, Explanation, - EnquiryStatus, - GateStatus, KnowledgeSurvey, - ReproducibilityReport, - ReproductionReport, SupportExplanation, - TaskContract, } from "@labkit/core-domain"; -test("an enquiry accepted as unresolved does not render as merely open", () => { - const status: EnquiryStatus = { - enquiry: ref("enquiry", "LOE_1"), - pursuing: "response-curvature sweep", - contributed: [], - open: true, - closure: null, - bearing: null, - evidence: [], - question: { - question: ref("question", "Q_1"), - asks: "does the pruning schedule move convergence?", - acceptedBecause: "the confirmatory set is spent", - reopensIf: "a genuinely new design, or a data source other than the spent set", - }, - }; - const out = renderEnquiry(status, PLAIN); - expect(out).toContain("accepted as unresolved"); - expect(out).toContain("the confirmatory set is spent"); - expect(out).toContain("a genuinely new design"); -}); - -test("an answered enquiry says whether its closure rests on promoted work", () => { - const q = { - question: ref("question", "Q_2"), - asks: "does depth move convergence?", - }; - const base: EnquiryStatus = { - enquiry: ref("enquiry", "LOE_2"), - pursuing: "depth sweep", - contributed: [], - open: false, - closure: "answered", - bearing: "supports", - evidence: [{ evidence: ref("evidence", "EV_1"), states: "a result" }], - question: q, - }; - expect(renderEnquiry({ ...base, restsOn: "exploratory" }, PLAIN)).toContain("exploratory"); - expect(renderEnquiry({ ...base, restsOn: "confirmatory" }, PLAIN)).toContain("confirmatory"); -}); - test("withdrawn, challenged and never-examined render apart", () => { const base: SupportExplanation = { claim: ref("claim", "CLM_9"), @@ -264,204 +213,6 @@ test("every question in the survey carries its handle", () => { expect(out).toContain("(Q_3) does depth matter?"); }); -test("a gate that failed and was re-checked does not read as though it never failed", () => { - const base: GateStatus = { - gate: ref("gate", "GATE_1"), - consequence: "the release is blocked", - state: "satisfied", - checks: [ - { - criterion: ref("criterion", "CRIT_1"), - proposition: "the effect holds at n=20", - state: "passed", - }, - ], - unmet: [], - gating: [], - counts: { passed: 0, failed: 0, "never-run": 0, "no-standing-verdict": 0 }, - everFailed: true, - }; - expect(renderGate(base, PLAIN)).toContain("failed at least once"); - // The flag is a separate fact from the state, so the state still prints. - expect(renderGate(base, PLAIN)).toContain("satisfied"); - expect(renderGate({ ...base, everFailed: false }, PLAIN)).not.toContain("failed at least once"); -}); - -test("never-run and no-standing-verdict are printed apart, not as one 'not passed'", () => { - const status: GateStatus = { - gate: ref("gate", "GATE_2"), - consequence: "the release is blocked", - state: "incomplete", - checks: [ - { - criterion: ref("criterion", "CRIT_1"), - proposition: "nobody has run this", - state: "never-run", - }, - { - criterion: ref("criterion", "CRIT_2"), - proposition: "this was decided and then withdrawn", - state: "no-standing-verdict", - }, - ], - unmet: [], - gating: [], - counts: { passed: 0, failed: 0, "never-run": 0, "no-standing-verdict": 0 }, - everFailed: false, - }; - const out = renderGate(status, PLAIN); - expect(out).toContain("never-run"); - expect(out).toContain("no-standing-verdict"); - // A withdrawn evaluation is listed and marked, not dropped: a check decided - // and then withdrawn is not a check nobody ran. - expect(out).toContain("withdrawn"); -}); - -test("a dissociation is not reported as a disagreement", () => { - const verdict: ConflictVerdict = { - conflict: false, - relation: "dissociation", - differsBy: "scope", - sides: [ - { - claim: ref("claim", "CLM_1"), - question: ref("question", "Q_1"), - proposition: "the schedule moves convergence", - asks: "does it move convergence at depth 4?", - supportedBy: [{ evidence: ref("evidence", "EV_1"), states: "moves by ~3 steps" }], - challengedBy: [], - }, - { - claim: ref("claim", "CLM_2"), - question: ref("question", "Q_2"), - proposition: "the schedule moves convergence", - asks: "does it move convergence at depth 12?", - supportedBy: [], - challengedBy: [ - { - evidence: ref("evidence", "EV_2"), - states: "no effect at depth 12", - }, - ], - }, - ], - }; - const out = renderConflict(verdict, PLAIN); - expect(out).toContain("do not disagree"); - expect(out).not.toContain("Contradiction"); - expect(out).toContain("scope"); - // Identically worded, so the questions are what tell the two sides apart. - expect(out).toContain("depth 4"); - expect(out).toContain("depth 12"); - - const contradiction = renderConflict( - { - ...verdict, - conflict: true, - relation: "contradiction", - differsBy: null, - }, - PLAIN, - ); - expect(contradiction).toContain("Contradiction"); - expect(contradiction).not.toContain("do not disagree"); -}); - -test("a re-run's report never claims the original was reproduced", () => { - const report: ReproductionReport = { - verification: ref("analysis", "COMP_2"), - verificationMethod: "replication at n=20", - of: ref("analysis", "COMP_1"), - ofMethod: "paired comparison", - conclusion: "agrees", - verificationRead: [{ part: ref("observations", "ART_1"), name: "sweep-a" }], - ofRead: [{ part: ref("observations", "ART_1"), name: "sweep-a" }], - differs: [], - bearing: "raises", - }; - const out = renderReproduction(report, PLAIN); - expect(out).toContain("agrees"); - expect(out).not.toContain("reproduced the"); - expect(out).toContain("does not say the original was reproduced"); -}); - -test("unverifiable inputs render apart from ones that differ", () => { - const report: ReproducibilityReport = { - analysis: ref("analysis", "COMP_1"), - exact: [{ part: ref("observations", "ART_1"), name: "sweep-a" }], - differing: [{ part: ref("observations", "ART_2"), name: "sweep-b" }], - unverifiable: [{ part: ref("observations", "ART_3"), name: "sweep-c" }], - notRebuilt: [{ part: ref("observations", "ART_4"), name: "sweep-d" }], - reproducible: false, - }; - const out = renderReproducibility(report, PLAIN); - // Four buckets, four headings -- and the unverifiable one says why it is not - // a failure, since the record kept no hash to compare against. - expect(out).toContain("kept no hash"); - const positions = ["sweep-a", "sweep-b", "sweep-c", "sweep-d"].map((n) => out.indexOf(n)); - expect(positions.every((i) => i >= 0)).toBe(true); - expect(positions).toEqual([...positions].sort((a, b) => a - b)); -}); - -test("a question nobody sharpened says so, in one line", () => { - const out = renderOrigin(null, ref("question", "Q_1"), PLAIN); - expect(out).toBe("Q_1 was posed directly."); - // No paragraph defending the answer. It was two lines explaining that an - // absent origin is not a gap, printed every time the answer was "directly". - expect(out.split("\n")).toHaveLength(1); - - const sharpened = renderOrigin( - { - kind: "sharpened", - from: ref("question", "Q_0"), - said: "does the schedule matter?", - reason: "the first sweep only moved at depth 4", - knownAtTheTime: [{ evidence: ref("evidence", "EV_1"), states: "moves by ~3 steps" }], - }, - ref("question", "Q_1"), - PLAIN, - ); - const noted = renderOrigin( - { - kind: "noted", - from: ref("note", "NOTE_2"), - said: "something about how the edge is handled matters", - reason: null, - knownAtTheTime: [], - }, - ref("question", "Q_2"), - PLAIN, - ); - // A note's own words, and no "because" line — there is no reason to print, - // and an empty one would read as a reason nobody gave. - expect(noted).toContain("came out of a note"); - expect(noted).toContain("something about how the edge is handled matters"); - expect(noted).not.toContain("because:"); - expect(noted).not.toContain("Known at that moment"); - - expect(sharpened).toContain("does the schedule matter?"); - expect(sharpened).toContain("moves by ~3 steps"); - // The frozen-at-the-time caveat, without which a reader takes the list for - // what is known now. - expect(sharpened).toContain("As it stood at the sharpening"); -}); - -test("a work contract says, once, that it is not enforced", () => { - const contract: TaskContract = { - work: ref("work", "TASK_1"), - objective: "sweep depth 4 through 20", - acceptance: "a curve with n>=20 at each depth", - mayRead: ["sweep-a", "sweep-b"], - enforced: false, - }; - const out = renderContract(contract, PLAIN); - expect(out).toContain("sweep-a"); - // In the heading, not a paragraph under it. It was two lines explaining that - // nothing stops a computation reading elsewhere, on every contract. - expect(out).toContain("May read (not enforced)"); - expect(out).not.toContain("nothing stops"); -}); - test("two claims asserting one sentence are not rendered as a duplicate", () => { const one = renderClaims( [ @@ -590,7 +341,6 @@ test("an uncaptured commit is not printed as a hash", () => { // palette. const COLOUR = palette(true); -const ESC = "\u001b"; /** * Strips every SGR sequence, so a coloured page can be compared to a plain one. @@ -598,37 +348,11 @@ const ESC = "\u001b"; // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI is the point const stripped = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); -const gateFixture: GateStatus = { - gate: ref("gate", "GATE_9"), - consequence: "the release is blocked", - state: "blocked", - checks: [ - { - criterion: ref("criterion", "CRIT_1"), - proposition: "the effect holds at n>=20", - state: "failed", - }, - { - criterion: ref("criterion", "CRIT_2"), - proposition: "nobody has run this", - state: "never-run", - }, - ], - unmet: [ - { criterion: ref("criterion", "CRIT_1"), requires: "the effect holds at n>=20", blocks: [] }, - ], - gating: [], - counts: { passed: 0, failed: 0, "never-run": 0, "no-standing-verdict": 0 }, - everFailed: true, -}; - test("colouring changes nothing a reader would read", () => { // The strongest property here, and the cheapest to lose: turning colour on // must not move, reword or reorder anything. If this fails, the two // renderings have diverged and every plain-mode assertion above has stopped // covering what people actually see. - expect(stripped(renderGate(gateFixture, COLOUR))).toBe(renderGate(gateFixture, PLAIN)); - const survey: KnowledgeSurvey = { established: [ { @@ -648,41 +372,6 @@ test("colouring changes nothing a reader would read", () => { expect(stripped(renderKnown(survey, COLOUR))).toBe(renderKnown(survey, PLAIN)); }); -test("a gate's states are coloured apart, not as pass and not-pass", () => { - const out = renderGate(gateFixture, COLOUR); - expect(out).toContain(ESC); - // `failed` and `never-run` are different findings and must not share a code. - const codeFor = (word: string) => - out.match(new RegExp(`\\u001b\\[([0-9;]+)m${word}`))?.[1] ?? `uncoloured:${word}`; - expect(codeFor("failed")).not.toBe(codeFor("never-run")); -}); - -test("colour lands on the state word, not the whole line", () => { - const line = renderGate(gateFixture, COLOUR) - .split("\n") - .find((l) => l.includes("the effect holds at n>=20")); - // An escape immediately before the proposition would mean the whole row had - // been painted, which carries no information a reader can use. - expect(line).toBeDefined(); - expect(line).not.toContain(`${ESC}[31mthe effect holds`); - expect(stripped(line as string)).toContain("the effect holds at n>=20"); -}); - -test("padding is applied before colour, so columns still line up", () => { - // An escape sequence has length. Padding a coloured string pads the bytes - // nobody can see, and the column then lands short by exactly that much. - const columns = renderGate(gateFixture, COLOUR) - .split("\n") - // The condition rows only: `Not currently met` also carries a CRIT_ handle, - // and its rows are not in this column at all. - // Condition rows only. `Not currently met` also carries a CRIT_ handle and - // is not in this column; the header line contains the word "failed". - .filter((l) => /^ {2}- (failed|never-run|passed|no-standing-verdict)\b/.test(stripped(l))) - .map((l) => stripped(l).search(/the effect holds|nobody has run/)); - expect(columns.length).toBe(2); - expect(new Set(columns).size).toBe(1); -}); - test("PLAIN is the identity function, so no view has a second code path", () => { // The difference between a coloured run and a plain one is escape sequences // and nothing else — never which branch rendered the page. diff --git a/tests/consumer/clock_ordering.test.ts b/tests/consumer/clock_ordering.test.ts index f324ac74..a43fa5c4 100644 --- a/tests/consumer/clock_ordering.test.ts +++ b/tests/consumer/clock_ordering.test.ts @@ -1,14 +1,9 @@ /** - * Clock ordering — what a wound clock reaches, and the two rungs row Z walked. + * Clock ordering — what a wound clock reaches, and what evidence times alone can order. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { - ResearchSession, - inMemoryEventLog, - type AnalysisRef, - type EnquiryRef, -} from "@labkit/core-domain"; +import { ResearchSession, inMemoryEventLog } from "@labkit/core-domain"; import type { ClaimRef } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { windableClock, minutes, days } from "../helpers/clock"; @@ -261,217 +256,3 @@ describe("Probe 6 — rung 1: ordering derived from evidence times alone", () => expect(firstSettledEarly.second).toBe("2026-03-02T09:00:00.000Z"); }); }); - -// --------------------------------------------------------------------------- - -describe("Probe 7 — rung 3: the as-of view, once decisions carry an instant", () => { - /** - * Rung 1 was built first and shown to fail (probe 6); rung 2 was declined by argument rather - * than demonstration — sequence is a property of each act, not a relation between two, and an - * `AFTER` edge would leave a reader reconstructing a total order from pairs. - */ - const FIRST = { - asks: "does pruning move convergence?", - prop: "pruning moves convergence", - }; - const SECOND = { - asks: "does depth move convergence?", - prop: "depth moves convergence", - }; - - const MARCH = "2026-03-01T09:00:00.000Z"; - - /** Settles two questions in a stated order, thirty days apart, and reads the record back. */ - async function programme(order: [typeof FIRST, typeof FIRST]) { - const graph = await scenario.begin(); - try { - const c = windableClock(MARCH); - const s = new ResearchSession(graph, { - clock: c, - events: inMemoryEventLog(), - }); - - const prepared: Array<{ - asks: string; - prop: string; - enquiry: EnquiryRef; - analysis: AnalysisRef; - }> = []; - for (const q of [FIRST, SECOND]) { - const { enquiry } = await s.writes.openEnquiry(q.asks); - const { observations: obs } = await s.writes.recordObservations({ - enquiry, - name: `${q.prop} readings`, - finding: `runs for ${q.prop}`, - }); - const { analysis } = await recordAnalysis(s.writes, { - enquiry, - method: "paired-comparison", - from: [obs], - concludes: [{ proposition: q.prop, finding: `result for ${q.prop}` }], - }); - prepared.push({ ...q, enquiry, analysis }); - } - const find = (q: typeof FIRST) => prepared.find((p) => p.prop === q.prop)!; - - // Thirty days in, the first of them is settled. Sixty days in, the other. - c.wind(days(30)); - const early = find(order[0]); - await s.writes.closeEnquiry({ - enquiry: early.enquiry, - answeredBy: await claimNamed(s.reads, early.prop), - }); - c.wind(days(30)); - const late = find(order[1]); - await s.writes.closeEnquiry({ - enquiry: late.enquiry, - answeredBy: await claimNamed(s.reads, late.prop), - }); - - // A second reader over the same graph, and an empty event log: whatever it - // answers is reconstructed from what was written down. - const reader = new ResearchSession(await scenario.current(), { - clock: c, - events: inMemoryEventLog(), - }); - const atDay45 = await reader.reads.whatWasKnown({ at: "2026-04-15T09:00:00.000Z" }); - return { - settledByDay45: atDay45.provisional.map((q) => q.asks), - openAtDay45: atDay45.open.map((q) => q.asks).sort(), - nowSettled: (await reader.reads.whatIsKnown()).provisional.map((q) => q.asks).sort(), - }; - } finally { - await scenario.end(); - } - } - - test("two orderings of the same beliefs now read apart", async () => { - const firstThenSecond = await programme([FIRST, SECOND]); - const secondThenFirst = await programme([SECOND, FIRST]); - - // Both programmes end holding both beliefs. That was never the finding, and - // it is still true -- the present-tense answer is identical. - expect(firstThenSecond.nowSettled).toEqual(secondThenFirst.nowSettled); - expect(firstThenSecond.nowSettled).toEqual([SECOND.asks, FIRST.asks].sort()); - - // Mid-way through, they differ -- which is what probe 2 could not see. - expect(firstThenSecond.settledByDay45).toEqual([FIRST.asks]); - expect(secondThenFirst.settledByDay45).toEqual([SECOND.asks]); - expect(firstThenSecond.openAtDay45).toEqual([SECOND.asks]); - expect(secondThenFirst.openAtDay45).toEqual([FIRST.asks]); - }); - - test("a promotion cannot establish a question before it happened", async () => { - /** - * The wrong answer `025` predicted I would write, and would have: keying the as-of survey - * on `Claim.kind` reports the present. - */ - const graph = await scenario.begin(); - try { - const c = windableClock(MARCH); - const s = new ResearchSession(graph, { - clock: c, - events: inMemoryEventLog(), - }); - - const { enquiry } = await s.writes.openEnquiry(FIRST.asks); - const { observations: obs } = await s.writes.recordObservations({ - enquiry, - name: "readings", - finding: "twelve runs", - }); - const { claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "paired-comparison", - from: [obs], - concludes: [{ proposition: FIRST.prop, finding: "moves by ~3 steps" }], - }); - c.wind(days(10)); - await s.writes.closeEnquiry({ - enquiry, - answeredBy: claimOf(analysisClaims, FIRST.prop), - }); - c.wind(days(40)); - await s.writes.isConfirmed({ - claim: claimOf(analysisClaims, FIRST.prop), - because: "replicated under seed control", - }); - - const reader = new ResearchSession(await scenario.current(), { - clock: c, - events: inMemoryEventLog(), - }); - - // Day 25: settled, and resting on nothing anyone had promoted. - const midway = await reader.reads.whatWasKnown({ at: "2026-03-26T09:00:00.000Z" }); - expect(midway.provisional.map((q) => q.asks)).toEqual([FIRST.asks]); - expect(midway.established).toEqual([]); - - // Day 60: the promotion has happened, and only now is it established. - const after = await reader.reads.whatWasKnown({ at: "2026-05-01T09:00:00.000Z" }); - expect(after.established.map((q) => q.asks)).toEqual([FIRST.asks]); - expect(after.provisional).toEqual([]); - - // The present-tense read collapses that distinction, correctly -- it is - // answering a different question. - expect((await reader.reads.whatIsKnown()).established.map((q) => q.asks)).toEqual([ - FIRST.asks, - ]); - } finally { - await scenario.end(); - } - }); - - /** - * A question is open only between being asked and being settled. Before it exists, and after - * it exists but before anything settles it, are two different moments — asking "what was - * known" in the first must not read back as `open`; only the second moment is. - */ - test("a question is open only between being asked and being settled", async () => { - const graph = await scenario.begin(); - try { - const c = windableClock(MARCH); - const s = new ResearchSession(graph, { - clock: c, - events: inMemoryEventLog(), - }); - const { enquiry } = await s.writes.openEnquiry(FIRST.asks); - const { observations: obs } = await s.writes.recordObservations({ - enquiry, - name: "readings", - finding: "runs", - }); - const { claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "pc", - from: [obs], - concludes: [{ proposition: FIRST.prop, finding: "a result" }], - }); - c.wind(days(10)); - await s.writes.closeEnquiry({ - enquiry, - answeredBy: claimOf(analysisClaims, FIRST.prop), - }); - - const reader = new ResearchSession(await scenario.current(), { - clock: c, - events: inMemoryEventLog(), - }); - - // February: the question had not been posed. Absent, not open. - const before = await reader.reads.whatWasKnown({ at: "2026-02-01T00:00:00.000Z" }); - expect(before.open).toEqual([]); - expect(before.established).toEqual([]); - expect(before.provisional).toEqual([]); - expect(before.accepted).toEqual([]); - expect(before.at).toBe("2026-02-01T00:00:00.000Z"); - - // Five days in: asked, and nothing has settled it. - const during = await reader.reads.whatWasKnown({ at: "2026-03-06T09:00:00.000Z" }); - expect(during.open.map((q) => q.asks)).toEqual([FIRST.asks]); - expect(during.provisional).toEqual([]); - } finally { - await scenario.end(); - } - }); -}); diff --git a/tests/consumer/historical_survey.test.ts b/tests/consumer/historical_survey.test.ts deleted file mode 100644 index 70599fd3..00000000 --- a/tests/consumer/historical_survey.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * The historical survey, put under a wound clock. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { windableClock, days } from "../helpers/clock"; -import { claimOf } from "../helpers/claims"; -import { recordAnalysis } from "../helpers/analysis"; - -let scenario: Scenario; -let graph: Awaited>; - -beforeAll(async () => { - scenario = await openScenario(); -}); -// `begin()` in a hook, not a test body: bun runs beforeEach/afterEach OUTSIDE -// the 5000ms per-test budget, so setup paid here does not count against the -// ceiling. `end()` was already off-budget for the same reason. -beforeEach(async () => { - graph = await scenario.begin(); -}); -afterEach(async () => { - await scenario.end(); -}); -afterAll(async () => { - await scenario.close(); -}); - -const asked = (survey: { - established: Array<{ asks: string }>; - provisional: Array<{ asks: string }>; - accepted: Array<{ asks: string }>; - open: Array<{ asks: string }>; -}) => ({ - established: survey.established.map((q) => q.asks), - provisional: survey.provisional.map((q) => q.asks), - accepted: survey.accepted.map((q) => q.asks), - open: survey.open.map((q) => q.asks), -}); - -describe("what was known, as of an instant", () => { - /** - * **A question posed in April was not open in March. It did not exist.** - */ - test("a question posed after the instant is not reported as open at it", async () => { - const clock = windableClock("2026-03-01T09:00:00.000Z"); - const s = new ResearchSession(graph, { clock, events: inMemoryEventLog() }); - - await s.writes.openEnquiry("does the schedule move convergence?"); - clock.wind(days(31)); - await s.writes.openEnquiry("does batch size interact with it?"); - - const march = await s.reads.whatWasKnown({ at: "2026-03-15T00:00:00.000Z" }); - expect(asked(march).open).toEqual(["does the schedule move convergence?"]); - expect(asked(march).open).not.toContain("does batch size interact with it?"); - }); - - /** - * **`at` is compared as a string, so a valid instant with an offset orders wrongly.** - */ - test("an instant given with a UTC offset is compared as a moment, not as text", async () => { - const clock = windableClock("2026-03-01T08:00:00.000Z"); - const s = new ResearchSession(graph, { clock, events: inMemoryEventLog() }); - - const { enquiry } = await s.writes.openEnquiry("does the schedule move convergence?"); - const { observations } = await s.writes.recordObservations({ - enquiry, - name: "sweep readings", - finding: "twelve runs", - }); - const { claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - from: [observations], - method: "paired comparison", - concludes: [ - { - proposition: "the schedule moves convergence", - finding: "moves by ~3 steps", - }, - ], - }); - clock.windTo("2026-03-01T10:00:00.000Z"); - await s.writes.closeEnquiry({ - enquiry, - answeredBy: claimOf(analysisClaims, "the schedule moves convergence"), - }); - - // 14:00Z, four hours after the close — but "09" sorts before "10". - const offset = await s.reads.whatWasKnown({ at: "2026-03-01T09:00:00-05:00" }); - expect(asked(offset).provisional).toEqual(["does the schedule move convergence?"]); - expect(asked(offset).open).toEqual([]); - }); -}); - -test("historical standing follows each pursuit from its own start through closure", async () => { - const clock = windableClock("2026-01-01T09:00:00.000Z"); - const s = new ResearchSession(graph, { clock, events: inMemoryEventLog() }); - const { question, enquiry: first } = await s.writes.openEnquiry("does depth move convergence?"); - - clock.windTo("2026-01-02T09:00:00.000Z"); - const { observations } = await s.writes.recordObservations({ - enquiry: first, - name: "depth sweep", - finding: "eight paired runs", - }); - const { claims } = await recordAnalysis(s.writes, { - enquiry: first, - from: [observations], - method: "paired comparison", - concludes: [{ proposition: "depth moves convergence", finding: "moves by three steps" }], - }); - await s.writes.closeEnquiry({ - enquiry: first, - answeredBy: claimOf(claims, "depth moves convergence"), - }); - clock.windTo("2026-01-04T09:00:00.000Z"); - const { enquiry: sibling } = await s.writes.pursue({ - question, - approach: "independent replication", - }); - clock.windTo("2026-01-06T09:00:00.000Z"); - await s.writes.closeEnquiry({ enquiry: sibling }); - - expect(asked(await s.reads.whatWasKnown({ at: "2026-01-03T09:00:00.000Z" }))).toMatchObject({ - provisional: ["does depth move convergence?"], - open: [], - }); - expect(asked(await s.reads.whatWasKnown({ at: "2026-01-05T09:00:00.000Z" }))).toMatchObject({ - provisional: [], - open: ["does depth move convergence?"], - }); - expect(asked(await s.reads.whatWasKnown({ at: "2026-01-07T09:00:00.000Z" }))).toMatchObject({ - provisional: ["does depth move convergence?"], - open: [], - }); -}); diff --git a/tests/consumer/vertical_slice.test.ts b/tests/consumer/vertical_slice.test.ts index 1b5595f1..6423cfa4 100644 --- a/tests/consumer/vertical_slice.test.ts +++ b/tests/consumer/vertical_slice.test.ts @@ -1,9 +1,9 @@ /** - * The consumer vertical slice — four reads, paired worlds, real durable state. + * The consumer vertical slice — three reads, paired worlds, real durable state. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { ReadSurface, ResearchSession, inMemoryEventLog, type Clock } from "@labkit/core-domain"; +import { ResearchSession, inMemoryEventLog, type Clock } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf } from "../helpers/claims"; import { recordAnalysis } from "../helpers/analysis"; @@ -140,12 +140,8 @@ describe("Probe 2 — historical survey: what did the record hold at time T?", ( return (await s.reads.whatIsKnown()).established; }; - /** - * The as-of answer is a separate read, not a timestamp smuggled onto a present-tense row: - * `whatWasKnown(at)` exists as its own capability (`known --at` on the CLI, exposed on the - * MCP server too), distinct from scanning a present-tense survey row for a hint. - */ - test("the as-of answer is a separate read, not a field on a present-tense row", async () => { + /** A present-tense survey row carries no timestamp a caller could read an as-of answer off. */ + test("a present-tense survey row carries no time", async () => { const { a, b } = await inTwoWorlds(inOrder(FIRST, SECOND), inOrder(SECOND, FIRST)); // Both worlds hold both beliefs. Correct in both -- what is missing is the @@ -154,8 +150,7 @@ describe("Probe 2 — historical survey: what did the record hold at time T?", ( expect(b.map((q) => q.asks).sort()).toEqual(a.map((q) => q.asks).sort()); // A survey row carries identity and words, and no time. Adding one here - // would mean a caller could read an as-of answer off a present-tense - // result, which is the leak `whatWasKnown()`'s own docstring refuses. + // would mean a caller could read an as-of answer off a present-tense result. const temporalFields = Object.keys(a[0]!).filter((k) => /as_?of|believ|assert(ed)?_?at|recorded_?at|effective|when|timestamp|version/i.test(k), ); @@ -163,9 +158,6 @@ describe("Probe 2 — historical survey: what did the record hold at time T?", ( // `answers` names every answering pursuit, claim and polarity, not time. Still no time // on the row -- the assertion above is the one that would catch that. expect(Object.keys(a[0]!).sort()).toEqual(["answers", "asks", "question"]); - - // And the other half: the capability exists, as a read of its own. - expect(typeof ReadSurface.prototype.whatWasKnown).toBe("function"); }); test("the ordering survives only as a natural-id artefact, which is not a modelled read", async () => { @@ -189,65 +181,6 @@ describe("Probe 2 — historical survey: what did the record hold at time T?", ( // --------------------------------------------------------------------------- -describe("Probe 3 — reconstruction provenance: what was this reconstructing?", () => { - /** - * A durable reconstruction attempt whose remembered fields include its historical target -- - * required by the contract's Designer 2. - */ - test("reproducibility is a read the caller must already know the answer to", async () => { - const graph = await scenario.begin(); - try { - const s = new ResearchSession(graph, { - clock, - events: inMemoryEventLog(), - }); - const { enquiry } = await s.writes.openEnquiry( - "does the encoding beat the historical control?", - ); - - // The historical control, as it survives: recorded, hashed. - const { observations: historical } = await s.writes.recordObservations({ - enquiry, - name: "random control", - finding: "the 2024 control, as archived", - contentHash: "sha256:1111", - }); - const { analysis } = await recordAnalysis(s.writes, { - enquiry, - method: "paired-comparison", - from: [historical], - concludes: [ - { - proposition: "the encoding beats the control", - finding: "difference 2.1%", - }, - ], - }); - - // A regeneration that does NOT match -- coherent, unlike the first draft. - const report = await s.reads.reproducibilityOf({ - analysis, - rebuilt: [{ part: historical, hash: "sha256:2222" }], - }); - expect(report.differing.map((p) => p.name)).toEqual(["random control"]); - expect(report.reproducible).toBe(false); - - // The finding, in two parts. One: the caller had to *pass in* the historical part. The - // direction of the reconstruction is an argument, supplied by someone who already knew - // it, and nothing is written down as a result -- reproducibilityOf is a read that - // persists nothing. - const provenanceFields = Object.keys(report).filter((k) => - /target|reconstruct|attempt|of_?artefact|predecessor|derived_?from|lineage/i.test(k), - ); - expect(provenanceFields).toEqual([]); - } finally { - await scenario.end(); - } - }); -}); - -// --------------------------------------------------------------------------- - describe("Probe 4 — attribution: who made or authorised the consequential act?", () => { /** * DEMONSTRATED GAP, and the strongest of the four — ledger row S, required by all three diff --git a/tests/domain/domain-session.test.ts b/tests/domain/domain-session.test.ts index be1f84bf..d503552d 100644 --- a/tests/domain/domain-session.test.ts +++ b/tests/domain/domain-session.test.ts @@ -8,8 +8,8 @@ import { ResearchSession } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { vertexProps } from "@labkit/core-db/cypher"; import type { TenantGraph } from "@labkit/core-db/graph"; -import { claimNamed, claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { claimOf } from "../helpers/claims"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { evaluationsOf } from "../helpers/criteria"; let scenario: Scenario; @@ -55,59 +55,6 @@ function failingOn( }) as TenantGraph; } -/** - * A reinterpretation interrupted after the original has been withdrawn but before the narrower - * claim inherits its evidence retracts a finding and puts nothing in its place: the record - * stops asserting the original sentence, and the sentence meant to replace it is supported by - * nothing at all. - */ -test("an interrupted reinterpret does not retract a finding it cannot replace", async () => { - const { enquiry } = await session.writes.openEnquiry("does T differ from rewired?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "per-image accuracy", - }); - const { claims: analysisClaims } = await recordAnalysis(session.writes, { - enquiry, - method: "holm-pairwise", - from: [observations], - concludes: [{ proposition: "T beats rewired", finding: "p = 0.002" }], - }); - - const before = await session.reads.whySupported({ - claim: await claimNamed(session.reads, "T beats rewired"), - }); - - // Fourth edge: MOTIVATES, EVALUATES, the SUPERSEDES that withdraws the original, - // and then the SUPPORTS that carries the evidence across to the narrower - // claim. Failing on the last one is the damaging moment. - const interrupted = new ResearchSession(failingOn(graph, "createEdge", 4), { - events: session.events, - }); - await expect( - interrupted.writes.reinterpret({ - of: claimOf(analysisClaims, "T beats rewired"), - as: "T beats rewired on the primary endpoint only", - because: "the secondary endpoint was never powered", - }), - ).rejects.toThrow(/injected failure/); - - // Nothing moved: the finding still stands and still rests on its evidence. - const after = await session.reads.whySupported({ - claim: await claimNamed(session.reads, "T beats rewired"), - }); - expect(after).toEqual(before); - expect(after.withdrawn).toBe(false); - expect(after.verdict).toBe("supported"); - // And no half-made revision is readable. - const history = await session.reads.interpretationHistory({ - claim: await claimNamed(session.reads, "T beats rewired"), - }); - expect(history.nowClaims.asserts).toBe("T beats rewired"); - expect(history.revisions).toEqual([]); -}); - /** * An amendment interrupted after the replacement condition governs the gate * but before the old one is marked changed leaves the gate governed by two @@ -220,68 +167,6 @@ test("recordObservations writes the unit and the evidence together or not at all expect(again).toMatch(/^ART_/); }); -/** - * Every write verb runs inside `inTransaction()`, because an event has to commit with the - * writes it describes. - */ -test("an interrupted sharpen leaves nothing at all", async () => { - const { enquiry } = await session.writes.openEnquiry("does the coating hold?"); - const { observations: obs } = await session.writes.recordObservations({ - enquiry, - name: "run A", - finding: "no delamination", - }); - await recordAnalysis(session.writes, { - enquiry, - method: "cycling", - from: [obs], - concludes: [ - { - proposition: "the coating survives cycling", - finding: "no delamination at 200 cycles", - }, - { - proposition: "the coating survives heat", - finding: "no delamination at 200C", - }, - ], - }); - const { question: original } = await session.writes.pose({ question: "is the coating durable?" }); - - // Fail on the second BASED_ON edge: the decision keeps one finding of three. - const realCreateEdge = graph.createEdge.bind(graph); - let basedOn = 0; - graph.createEdge = (async (from: string, edge: string, to: string) => { - if (edge === "BASED_ON" && ++basedOn === 2) { - throw new Error("injected: the second BASED_ON failed"); - } - return realCreateEdge(from as never, edge as never, to as never); - }) as typeof graph.createEdge; - - await expect( - session.writes.sharpen({ - from: original, - into: "is it durable at 200C?", - because: "too vague", - }), - ).rejects.toThrow(/injected/); - graph.createEdge = realCreateEdge; - - // No decision at all: the rollback means there is nothing to reach. - const decisions = await graph.query(`MATCH (d:Decision) RETURN d`, { - d: vertexProps<{ reason: string }>(), - }); - expect(decisions).toEqual([]); - - // And so `originOf` answers null because the question genuinely has no - // origin, not because the edge it needs happened to be written last. - expect(await session.reads.originOf({ question: original })).toBeNull(); - - // The sharper question was never created, so the survey is simply correct. - const survey = await session.reads.whatIsKnown(); - expect(survey.untested.map((q) => q.asks)).toEqual(["is the coating durable?"]); -}); - /** * `evaluateCriterion`'s three interruption windows. */ @@ -400,7 +285,7 @@ test("a close interrupted before BASED_ON writes nothing before retry", async () * withdraw it: `isWithdrawn` is `cited > 0 && standing === 0`. */ test("a verdict is withdrawn when the evidence it was reached against is retracted", async () => { - const { enquiry, obs, analysis, analysisClaims, criterion, gate } = await aGatedCheck(); + const { enquiry, obs, analysisClaims, criterion, gate } = await aGatedCheck(); await session.writes.evaluateCriterion({ criterion, @@ -415,17 +300,17 @@ test("a verdict is withdrawn when the evidence it was reached against is retract ); expect(before.state).toBe("blocked"); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "the sweep dropped the last decade", - }); - await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, + await reanalyse(session.writes, { enquiry, method: "convergence, all decades", from: [obs], - concludes: [{ proposition: "the solver converges", finding: "residual 4e-9" }], + concludes: [ + { + proposition: "the solver converges", + finding: "residual 4e-9", + replacing: claimOf(analysisClaims, "the solver converges"), + }, + ], }); const after = await session.reads.gateStatus({ gate }); @@ -585,51 +470,6 @@ test("stateCriterion and planWork have no interruption window to have", async () expect(work).toMatch(/^TASK_/); }); -/** - * `recordReview` writes `EVALUATES` **last**, so an interrupted review is an - * orphan `Review` node — `pursue`'s argument, checked rather than assumed. - */ -test("an interrupted recordReview leaves a review nothing can reach", async () => { - const { enquiry } = await session.writes.openEnquiry("does it hold?"); - const { observations: obs } = await session.writes.recordObservations({ - enquiry, - name: "run", - finding: "data", - }); - const { analysis } = await recordAnalysis(session.writes, { - enquiry, - method: "m", - from: [obs], - concludes: [{ proposition: "it holds", finding: "f" }], - }); - - const realCreateEdge = graph.createEdge.bind(graph); - graph.createEdge = (async (from: string, edge: string, to: string) => { - if (edge === "EVALUATES") throw new Error("injected: EVALUATES failed"); - return realCreateEdge(from as never, edge as never, to as never); - }) as typeof graph.createEdge; - - await expect( - session.writes.recordReview({ - of: analysis, - verdict: "the aggregation dropped a fold", - }), - ).rejects.toThrow(/injected/); - graph.createEdge = realCreateEdge; - - const attached = await graph.query(`MATCH (r:Review)-[:EVALUATES]->() RETURN r`, { - r: vertexProps<{ natural_id: string }>(), - }); - expect(attached).toEqual([]); - - // The finding still stands: no review reaches it, so nothing retracts it. - const why = await session.reads.whySupported({ - claim: await claimNamed(session.reads, "it holds"), - }); - expect(why.verdict).toBe("supported"); - expect(why.withdrawn).toBe(false); -}); - /** * `declareGate` writes its edges **after** the node -- `evaluateCriterion`'s arrangement. */ @@ -729,50 +569,6 @@ test("a task planned against an enquiry reports it, with wording; one planned wi expect((await session.reads.contractFor({ work: unaddressed })).addressing).toBeUndefined(); }); -test("closing a blocked gate releases work without changing its failed check", async () => { - const { criterion } = await session.writes.stateCriterion("the error stays below 1e-6"); - const { work } = await session.writes.planWork({ - objective: "publish the comparison", - acceptance: "the comparison is in the report", - }); - const { gate } = await session.writes.declareGate({ - governedBy: [criterion], - consequence: "the comparison is withheld", - protecting: [work], - }); - await session.writes.evaluateCriterion({ criterion, gate, value: "2e-5", outcome: "fail" }); - - expect((await session.reads.gateStatus({ gate })).state).toBe("blocked"); - expect((await session.reads.workList({})).find((row) => row.work === work)?.state).toBe( - "blocked", - ); - - const closed = await session.writes.closeGate({ - gate, - because: "the report now labels this comparison exploratory", - }); - const status = await session.reads.gateStatus({ gate }); - expect(closed).toMatchObject({ gate }); - expect(status.state).toBe("closed"); - expect(status.closure).toEqual({ - decision: closed.decision, - because: "the report now labels this comparison exploratory", - }); - expect(status.checks.map((check) => check.state)).toEqual(["failed"]); - expect((await session.reads.workList({})).find((row) => row.work === work)?.state).toBe( - "planned", - ); - expect((await session.reads.now({})).blocked.work.map((row) => row.work)).not.toContain(work); - - await expect(session.writes.closeGate({ gate, because: "duplicate" })).rejects.toThrow(/already/); - await expect( - session.writes.closeGate({ - gate: "GATE_does_not_exist" as typeof gate, - because: "missing", - }), - ).rejects.toThrow(/not found/); -}); - test("criterion report refuses an evaluation with no stored outcome", async () => { const { criterion } = await session.writes.stateCriterion("a malformed evaluation is visible"); const evaluation = await graph.reserveId("CriterionEvaluation"); diff --git a/tests/domain/how.test.ts b/tests/domain/how.test.ts deleted file mode 100644 index a4cebcbe..00000000 --- a/tests/domain/how.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * `how ` — the ordered provenance of any handle's state, with superseded steps marked. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; -import { ResearchSession } from "@labkit/core-domain"; -import type { TenantGraph } from "@labkit/core-db/graph"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; -import { claimOf } from "../helpers/claims"; - -let scenario: Scenario; -let graph: TenantGraph; -let session: ResearchSession; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - graph = await scenario.begin(); - session = new ResearchSession(graph); -}); -afterEach(async () => { - await scenario.end(); -}); - -test("how on a first-pose question returns one non-superseded step", async () => { - const { question } = await session.writes.pose({ question: "does X cause Y?" }); - const h = await session.reads.how({ subject: question }); - expect(h.subject).toBe(question); - expect(h.steps.length).toBe(1); - expect(h.steps[0]!.handle).toBe(question); - expect(h.steps[0]!.superseded).toBe(false); - expect(h.steps[0]!.successor).toBeUndefined(); -}); - -test("how on a replaced claim marks the old as superseded with successor", async () => { - const { enquiry } = await session.writes.openEnquiry("does method M work?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "raw data", - finding: "measurements", - }); - const first = await recordAnalysis(session.writes, { - enquiry, - method: "initial", - from: [observations], - concludes: [{ proposition: "M works on the sample", finding: "p < 0.01" }], - }); - const claim = claimOf(first.claims, "M works on the sample"); - - // review + replace - const { review } = await session.writes.recordReview({ - of: first.analysis, - verdict: "fail", - }); - const repl = await replaceAnalysis(session.writes, { - supersedes: first.analysis, - because: review, - enquiry, - method: "corrected sampling", - from: [observations], - concludes: [ - { proposition: "M does not work after correction", finding: "p = 0.4", replacing: claim }, - ], - }); - const newClaim = claimOf(repl.claims, "M does not work after correction"); - - const howOld = await session.reads.how({ subject: claim }); - const oldStep = howOld.steps.find((s) => s.handle === claim); - expect(oldStep).toBeDefined(); - expect(oldStep!.superseded).toBe(true); - expect(oldStep!.successor).toBe(newClaim); - - const howNew = await session.reads.how({ subject: newClaim }); - expect(howNew.steps.some((s) => s.handle === newClaim && !s.superseded)).toBe(true); -}); - -test("how on notes with supersedes marks superseded and names successor", async () => { - const { note: old } = await session.writes.note({ text: "initial take" }); - const { note: newer } = await session.writes.note({ text: "replaces it", supersedes: [old] }); - - const hOld = await session.reads.how({ subject: old }); - const sOld = hOld.steps.find((s) => s.handle === old); - expect(sOld?.superseded).toBe(true); - expect(sOld?.successor).toBe(newer); - - const hNew = await session.reads.how({ subject: newer }); - expect(hNew.steps.some((s) => s.handle === newer && !s.superseded)).toBe(true); -}); - -test("--since is a cursor: only later seqs, including dropping the named handle", async () => { - const { note: old } = await session.writes.note({ text: "initial take" }); - const { note: newer } = await session.writes.note({ text: "later take", supersedes: [old] }); - const hOld = await session.reads.how({ subject: old }); - const oldSeq = hOld.steps.find((s) => s.handle === old)?.seq; - expect(oldSeq).toBeDefined(); - const after = await session.reads.how({ subject: old, since: oldSeq }); - expect(after.steps.every((s) => s.seq !== undefined && s.seq > oldSeq!)).toBe(true); - expect(after.steps.some((s) => s.handle === old)).toBe(false); - expect(after.steps.some((s) => s.handle === newer)).toBe(true); -}); - -test("how refuses unknown handle like why", async () => { - await expect(session.reads.how({ subject: "NOTE_999999" })).rejects.toThrow(/not found/); -}); - -test("how dispatches on NOTE, CLM, Q, TASK handles", async () => { - const { question } = await session.writes.pose({ question: "any kind?" }); - const { note } = await session.writes.note({ text: "a note", on: question }); - const { enquiry } = await session.writes.openEnquiry("enq"); - const { work } = await session.writes.planWork({ - objective: "do the thing", - acceptance: "done", - addressing: enquiry, - }); - - // at least they return without throwing and include the subject - for (const subj of [note, question, work]) { - const h = await session.reads.how({ subject: subj }); - expect(h.steps.some((s) => s.handle === subj)).toBe(true); - } -}); diff --git a/tests/domain/subject-identity.test.ts b/tests/domain/subject-identity.test.ts index fcb746f7..900f35f5 100644 --- a/tests/domain/subject-identity.test.ts +++ b/tests/domain/subject-identity.test.ts @@ -192,101 +192,10 @@ describe("1. an enquiry's status was the question's status — FIXED,", () => { const WIDTH = "width matters"; }); -describe("2. an artefact id does not say what kind of artefact it is", () => { - test("observations and an analysis's output share one identity space", async () => { - const s = await session(); - try { - const { enquiry } = await s.writes.openEnquiry("does it hold?"); - const { observations } = await s.writes.recordObservations({ - enquiry, - name: "raw readings", - finding: "twelve runs", - }); - const { analysis } = await recordAnalysis(s.writes, { - enquiry, - method: "stage one", - from: [observations], - concludes: [{ proposition: HOLDS, finding: "it holds" }], - }); - - const later = new ReadSurface(await scenario.current()); - const parts = await later.reproducibilityOf({ analysis, rebuilt: [] }); - const consumed = [ - ...parts.exact, - ...parts.differing, - ...parts.unverifiable, - ...parts.notRebuilt, - ]; - - // Raw measurement is an artefact. - expect(observations.startsWith("ART_")).toBe(true); - // So is what the analysis produced -- same prefix, same space. - const output = consumed.map((p) => p.part); - expect(output.every((id) => id.startsWith("ART_"))).toBe(true); - - // So the two are **indistinguishable by handle**, which is the finding. - // Handles are branded strings, with no separate `kind` field that could - // disagree with the id -- an id whose prefix is shared with outputs -- - // so the ambiguity is in the open where a scenario can decide it. - expect(output).toContain(observations); - } finally { - await scenario.end(); - } - }); - - test("an analysis ref used as an input means that analysis's output artefact", async () => { - // Both routes write the same edge. The reference denotes a computation; the - // verb takes it to mean the artefact the computation produced. - const s = await session(); - try { - const { enquiry } = await s.writes.openEnquiry("two stage?"); - const { observations: raw } = await s.writes.recordObservations({ - enquiry, - name: "raw", - finding: "f", - }); - const { analysis: stageOne } = await recordAnalysis(s.writes, { - enquiry, - method: "stage one", - from: [raw], - concludes: [{ proposition: "p1", finding: "f1" }], - }); - const { analysis: viaAnalysis } = await recordAnalysis(s.writes, { - enquiry, - method: "stage two, by analysis ref", - from: [stageOne], - concludes: [{ proposition: "p2a", finding: "f2" }], - }); - - const read = new ReadSurface(await scenario.current()); - const consumedByA = await read.reproducibilityOf({ analysis: viaAnalysis, rebuilt: [] }); - const outputOfStageOne = [...consumedByA.unverifiable, ...consumedByA.notRebuilt][0]?.part; - expect(outputOfStageOne?.startsWith("ART_")).toBe(true); - - const { analysis: viaArtefact } = await recordAnalysis(s.writes, { - enquiry, - method: "stage two, by artefact id", - from: [outputOfStageOne!], - concludes: [{ proposition: "p2b", finding: "f2" }], - }); - const consumedByB = await read.reproducibilityOf({ analysis: viaArtefact, rebuilt: [] }); - - // Indistinguishable. The `kind` on the second was a lie and cost nothing, - // which is why this is an ambiguity rather than a defect. - expect(consumedByB.unverifiable).toEqual(consumedByA.unverifiable); - } finally { - await scenario.end(); - } - }); - - const HOLDS = "it holds"; -}); - describe("4. the read models drop identifiers the graph already minted", () => { /** - * Every entity here has a natural id, minted in the same round trip that created it. Three - * reports carry one **beside** the wording, which is the template; the rest emit wording - * alone and the caller cannot follow it anywhere. + * Every entity here has a natural id, minted in the same round trip that created it. A report + * carries it **beside** the wording, so the caller can follow it. */ const looksLikeAnId = (v: string) => /^(Q|LOE|EU|EV|CLM|DEC|CRIT|CEVAL|GATE|REV|ART|COMP|TASK)_\d+$/.test(v); @@ -346,27 +255,15 @@ describe("4. the read models drop identifiers the graph already minted", () => { }; } - test("the template: an id beside the wording, in the three reports that do it", async () => { + test("the template: an id beside the wording", async () => { try { - const { read, analysis } = await programme(); + const { read } = await programme(); // whatIsKnown: `question` is the id, `asks` is the text. const known = await read.whatIsKnown(); const standing = [...known.established, ...known.provisional][0]!; expect(looksLikeAnId(standing.question)).toBe(true); expect(looksLikeAnId(standing.asks)).toBe(false); - - // reproducibilityOf: `part` is the id, `name` is the text. - const parts = await read.reproducibilityOf({ analysis, rebuilt: [] }); - const inputs = [ - ...parts.exact, - ...parts.differing, - ...parts.unverifiable, - ...parts.notRebuilt, - ]; - expect(inputs.length).toBeGreaterThan(0); - expect(inputs.every((p) => looksLikeAnId(p.part))).toBe(true); - expect(inputs.every((p) => looksLikeAnId(p.name))).toBe(false); } finally { await scenario.end(); } @@ -392,23 +289,6 @@ describe("4. the read models drop identifiers the graph already minted", () => { } }); - test("whatDependsOn now identifies what is affected — FIXED, step 2", async () => { - // Was: claims:["depth moves convergence"], enquiries:["seed sweep"] -- prose - // no follow-up verb accepts. Now both, in the shape the other reports use. - try { - const { read } = await programme(); - const affected = await read.whatDependsOn({ subject: "sweep readings" }); - - expect(affected.claims.length + affected.enquiries.length).toBeGreaterThan(0); - expect(affected.claims.every((c) => looksLikeAnId(c.claim))).toBe(true); - expect(affected.claims.every((c) => looksLikeAnId(c.asserts))).toBe(false); - expect(affected.enquiries.every((e) => looksLikeAnId(e.enquiry))).toBe(true); - expect(affected.enquiries.every((e) => looksLikeAnId(e.pursuing))).toBe(false); - } finally { - await scenario.end(); - } - }); - test("whySupported identifies the analysis it cites — FIXED, step 2", async () => { try { const { read } = await programme(); @@ -458,14 +338,6 @@ describe("4. the read models drop identifiers the graph already minted", () => { expect((await read.whySupported({ claim })).claim).toEqual(claim); expect((await read.gateStatus({ gate })).gate).toEqual(gate); expect((await read.enquiryStatus({ enquiry })).enquiry).toEqual(enquiry); - expect((await read.designHistory({ gate })).gate).toEqual(gate); - - // whatDependsOn also accepts a logical NAME, and its echo is the record - // that name resolved to -- the one thing a caller passing a name cannot - // otherwise learn about the answer they got back. - const byName = await read.whatDependsOn({ subject: "sweep readings" }); - expect(looksLikeAnId(byName.subject)).toBe(true); - expect(await read.whatDependsOn({ subject: byName.subject })).toEqual(byName); } finally { await scenario.end(); } diff --git a/tests/domain/survey-after-reinterpretation.test.ts b/tests/domain/survey-after-reinterpretation.test.ts deleted file mode 100644 index 1ab55070..00000000 --- a/tests/domain/survey-after-reinterpretation.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Reinterpreting a closed question's claim does not move the question. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { vertexProps } from "@labkit/core-db/cypher"; -import { ResearchSession, inMemoryEventLog, type Clock } from "@labkit/core-domain"; -import { recordAnalysis } from "../helpers/analysis"; - -const clock: Clock = { now: () => "2026-08-29T09:00:00.000Z" }; -let scenario: Scenario; -let s: ResearchSession; -beforeAll(async () => { - scenario = await openScenario(); -}); -let graph: Awaited>; -beforeEach(async () => { - graph = await scenario.begin(); - s = new ResearchSession(graph, { clock, events: inMemoryEventLog() }); -}); -afterEach(async () => { - await scenario.end(); -}); -afterAll(async () => { - await scenario.close(); -}); - -const BUCKETS = ["established", "provisional", "unresolved", "untested", "accepted"] as const; - -test("a reinterpretation does not move the question between buckets", async () => { - const { enquiry } = await s.writes.openEnquiry("does the drug work?"); - const { criterion: crit } = await s.writes.stateCriterion("holds under leave-one-out"); - const { observations: obs } = await s.writes.recordObservations({ - enquiry, - name: "cohort", - finding: "+11%", - }); - const rec = await recordAnalysis(s.writes, { - enquiry, - method: "fit", - from: [obs], - concludes: [{ proposition: "the drug causes the improvement", finding: "+11%" }], - heldTo: [crit], - }); - const claim = rec.claims[0]!.claim; - // **Failed**, which is what makes the two candidate answering claims give - // different answers. Promoted over an unmet check: the original claim is - // promoted and its check failed -> `provisional`. The narrowed claim has - // no criteria at all -> vacuously met -> `established`. With a passing check - // both readings agree and the probe cannot fail. - await s.writes.evaluateCriterion({ - criterion: crit, - value: "0.071", - outcome: "fail", - citing: [claim], - }); - // Promoted, so the two candidate answering claims give DIFFERENT buckets: - // the original is promoted and its check is met -> established; the narrowed - // one is neither -> provisional. Without this the probe cannot fail. - await s.writes.isConfirmed({ claim, because: "held at the prespecified bar" }); - await s.writes.closeEnquiry({ enquiry, answeredBy: claim }); - - const before = await s.reads.whatIsKnown(); - const bucketBefore = BUCKETS.find((b) => before[b].some((q) => q.asks === "does the drug work?")); - - const report = await s.writes.reinterpret({ - of: claim, - as: "the drug is associated with the improvement", - because: "the design cannot separate selection from effect", - }); - - const after = await s.reads.whatIsKnown(); - const bucketAfter = BUCKETS.find((b) => after[b].some((q) => q.asks === "does the drug work?")); - - expect(bucketBefore).toBe("provisional"); - expect(bucketAfter).toBe("provisional"); - const asked = after.provisional.find((q) => q.asks === "does the drug work?"); - expect(asked?.answers.map((a) => a.claim)).toEqual([report.nowClaims.claim]); - // Stable, not merely correct once. An order-dependent answer would vary - // between reads of the same graph. - const runs: (string | undefined)[] = []; - for (let i = 0; i < 5; i++) { - const k = await s.reads.whatIsKnown(); - runs.push(BUCKETS.find((b) => k[b].some((q) => q.asks === "does the drug work?"))); - } - expect(new Set(runs)).toEqual(new Set(["provisional"])); - - // The precondition, measured rather than inferred: does the evidence the - // closing decision cites support more than one claim? - const rows = await graph.query( - `MATCH (d:Decision)-[:CLOSES]->(:LineOfEnquiry) - MATCH (d)-[:BASED_ON]->(e:Evidence)-[:SUPPORTS]->(c:Claim) - RETURN c`, - { c: vertexProps<{ natural_id: string; name: string }>() }, - {}, - ); - // **The precondition, asserted rather than assumed.** Without two claims - // reachable here the test above passes for a reason unrelated to the defect - // it guards — the check that cannot fail, one level out. - expect(rows.length).toBe(2); -}); diff --git a/tests/domain/survey-after-standing-changes.test.ts b/tests/domain/survey-after-standing-changes.test.ts index 5ffb71ce..63ff3bc6 100644 --- a/tests/domain/survey-after-standing-changes.test.ts +++ b/tests/domain/survey-after-standing-changes.test.ts @@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; import { openScenario, type Scenario } from "../helpers/scenario"; import { ResearchSession, inMemoryEventLog, type Clock } from "@labkit/core-domain"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; const clock: Clock = { now: () => "2026-09-11T12:00:00.000Z" }; let scenario: Scenario; @@ -49,17 +49,11 @@ async function aClosedPromotedAnswer() { return { enquiry, observations, analysis: rec.analysis, claim, closed }; } -test("replace removes the old claim from established", async () => { - const { enquiry, observations, analysis, claim } = await aClosedPromotedAnswer(); +test("a replacing conclusion removes the old claim from established", async () => { + const { enquiry, observations, claim } = await aClosedPromotedAnswer(); expect((await s.reads.whatIsKnown()).established.some((q) => q.asks === ASKS)).toBe(true); - const { review } = await s.writes.recordReview({ - of: analysis, - verdict: "the metric was misapplied", - }); - const replaced = await replaceAnalysis(s.writes, { - supersedes: analysis, - because: review, + const replaced = await reanalyse(s.writes, { enquiry, method: "corrected ablation", from: [observations], @@ -77,29 +71,3 @@ test("replace removes the old claim from established", async () => { const asked = known.provisional.find((q) => q.asks === ASKS); expect(asked?.answers.map((a) => a.claim)).toEqual([replaced.claims[0]!.claim]); }); - -test("undecided after promote is not established", async () => { - const { claim } = await aClosedPromotedAnswer(); - const finding = (await s.reads.whySupported({ claim })).support[0]?.evidence; - if (!finding) throw new Error("the closed answer had no finding to grade"); - await s.writes.isUndecided({ claim, because: finding }); - - const known = await (await afterwards()).reads.whatIsKnown(); - expect(known.established.some((q) => q.asks === ASKS)).toBe(false); - expect(known.provisional.some((q) => q.asks === ASKS)).toBe(true); -}); - -test("undoing the close leaves the question unresolved", async () => { - const { closed } = await aClosedPromotedAnswer(); - expect((await s.reads.whatIsKnown()).established.some((q) => q.asks === ASKS)).toBe(true); - - await s.writes.undo({ - event: closed.events[0]!.seq!, - because: "the close named the wrong claim", - }); - - const known = await (await afterwards()).reads.whatIsKnown(); - expect(known.established.some((q) => q.asks === ASKS)).toBe(false); - expect(known.provisional.some((q) => q.asks === ASKS)).toBe(false); - expect(known.unresolved.some((q) => q.asks === ASKS)).toBe(true); -}); diff --git a/tests/events/event-store.test.ts b/tests/events/event-store.test.ts index e60d238f..0c3f2c56 100644 --- a/tests/events/event-store.test.ts +++ b/tests/events/event-store.test.ts @@ -206,37 +206,12 @@ describe("an event records the edges the act created", () => { test("a verb emits its own name", async () => { const { ctx, write } = await surfaceFor("labkit"); const log = pgEventLog(db, ctx); - const { enquiry } = await write.openEnquiry("does the coating hold?"); - const { observations } = await write.recordObservations({ - enquiry, - name: "panel-a", - finding: "120 panels, 90 days", - }); - const { analysis } = await write.recordAnalysis({ - enquiry, - method: "regression", - from: [observations], - }); - const kept = await write.conclude({ - analysis, - proposition: "the coating holds", - finding: "no failures at 90 days", - }); - await write.conclude({ - analysis, - proposition: "the primer holds", - finding: "no failures at 60 days", - }); - const { review } = await write.recordReview({ of: analysis, verdict: "wrong scale" }); - - await write.keep({ - keeping: [kept.claims[0]!.claim], - because: review, - method: "corrected scale", - }); + // `openEnquiry` does what `pose` and `pursue` do, as one act. + await write.openEnquiry("does the coating hold?"); - expect(await log.select({ operation: "keep" })).toHaveLength(1); - expect(await log.select({ operation: "replaceAnalysis" })).toHaveLength(0); + expect(await log.select({ operation: "openEnquiry" })).toHaveLength(1); + expect(await log.select({ operation: "pose" })).toHaveLength(0); + expect(await log.select({ operation: "pursue" })).toHaveLength(0); }); /** diff --git a/tests/events/one-event-log.test.ts b/tests/events/one-event-log.test.ts index e9464da0..cef06a95 100644 --- a/tests/events/one-event-log.test.ts +++ b/tests/events/one-event-log.test.ts @@ -21,18 +21,11 @@ afterEach(async () => { await scenario.end(); }); -/** - * `SessionCore` defaults `events` to a fresh `inMemoryEventLog()`, and every sub-surface used to - * take that default separately: `handling` recorded into the write surface's log while `undo` - * read the revising group's, which was empty. Both shipped adapters pass a sink, so this never - * reached a user — it only bit a caller who constructed a session without one. - */ -test("a session built without a sink still has exactly one, and `undo` can see it", async () => { +test("a session built without a sink still has exactly one", async () => { const session = new ResearchSession(await scenario.current()); - const { events } = await session.writes.pose({ question: "can undo see its own event?" }); + await session.writes.pose({ question: "can the read side see this act?" }); - const undone = await session.writes.undo({ event: events[0]!.seq!, because: "a duplicate" }); - expect(undone.retracted.length).toBeGreaterThan(0); + expect(await session.reads.whatHappened({})).toHaveLength(1); }); test("the surfaces of one session share the sink, defaulted or not", async () => { diff --git a/tests/events/retraction.test.ts b/tests/events/retraction.test.ts index d4f5a48d..be8e7779 100644 --- a/tests/events/retraction.test.ts +++ b/tests/events/retraction.test.ts @@ -1,6 +1,6 @@ /** - * `undo` retracts through the same tenant-scoped role every real session runs as, not through - * the admin connection the rest of the suite uses. + * A retracted node is hidden from the tenant-scoped role every real session runs as, not only + * from the admin connection the rest of the suite uses. */ import { afterAll, beforeAll, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; @@ -23,35 +23,6 @@ afterAll(() => { rmSync(home, { recursive: true, force: true }); }); -test("undo hides what it retracted from the role every ordinary session runs as", async () => { - const connection: LabKitDBConnection = await connectDb(home); - try { - const ctx = await resolveTenantContext(connection.db, connection.tx, "labkit"); - await scopeToTenant(connection.db, ctx); - const graph = new TenantGraph(ctx, connection.db, connection.tx); - const session = new ResearchSession(graph, { events: pgEventLog(connection.db, ctx) }); - - const wording = "retraction end-to-end probe: does this hide?"; - const { question, events } = await session.writes.pose({ question: wording }); - await session.writes.undo({ - event: events[0]!.seq!, - because: "proving the mechanism, not a real question", - }); - - // Unreachable by the wording that used to find it -- not merely absent - // from one report, but genuinely invisible to a normal read. - const found = await session.reads.search({ text: wording }); - expect(found.flatMap((g) => g.matches)).toEqual([]); - - // And unreachable as a write target, the same way a handle nobody ever - // minted would be: `pursue` checks its target exists before wiring - // anything to it. - await expect(session.writes.pursue({ question, approach: "try again" })).rejects.toThrow(); - } finally { - await connection.close(); - } -}, 60_000); - test("every retracted node label is unreachable by lookup and traversal", async () => { const connection: LabKitDBConnection = await connectDb(home); try { diff --git a/tests/helpers/analysis.ts b/tests/helpers/analysis.ts index d413fad7..04a361c9 100644 --- a/tests/helpers/analysis.ts +++ b/tests/helpers/analysis.ts @@ -1,7 +1,6 @@ /** - * A run and every conclusion drawn from it, or a replacement and the findings it supersedes, in - * one call — for a test that wants a run with its findings already on it rather than typing the - * constituent verb calls out by hand. + * A run and every conclusion drawn from it in one call — for a test that wants a run with its + * findings already on it rather than typing the constituent verb calls out by hand. */ import type { @@ -12,10 +11,10 @@ import type { EnquiryRef, InputRef, WorkRef, - ReviewRef, - ReplacementReport, + ClaimRef, + EvidenceRef, } from "@labkit/core-domain/report"; -import type { ResearchWrites, ReplacementConclusion } from "@labkit/core-domain"; +import type { ResearchWrites } from "@labkit/core-domain"; /** Every fragment writes through the public surface and nothing else. */ type W = ResearchWrites; @@ -29,7 +28,7 @@ export async function recordAnalysis( enquiry: EnquiryRef; method: string; from: InputRef[]; - concludes: readonly Conclusion[]; + concludes: readonly (Conclusion & { replacing?: ClaimRef | EvidenceRef })[]; heldTo?: CriterionRef[]; implementing?: WorkRef; }, @@ -50,33 +49,18 @@ export async function recordAnalysis( } /** - * A replacement and the findings it supersedes — the signature `WriteSurface.replaceAnalysis` - * had before its conclusions became acts of their own. + * A fresh run whose conclusions each name the finding they stand in place of, through + * `conclude --replacing`. A conclusion that names none supersedes nothing. */ -export async function replaceAnalysis( +export async function reanalyse( w: W, input: { - supersedes: AnalysisRef; - because: ReviewRef; enquiry: EnquiryRef; method: string; from: InputRef[]; - concludes: readonly ReplacementConclusion[]; + concludes: readonly (Conclusion & { replacing?: ClaimRef | EvidenceRef })[]; }, -): Promise { - const report = await w.replaceAnalysis({ - supersedes: input.supersedes, - because: input.because, - method: input.method, - from: input.from, - }); - // Each conclusion names the finding it supersedes. One the caller does not - // name is not superseded, which is what lets a re-analysis address some of a - // run's conclusions and leave the rest standing. - const claims: ConcludedClaim[] = []; - for (const c of input.concludes) { - const drawn = await w.conclude({ analysis: report.replacement, ...c }); - claims.push(...drawn.claims); - } - return { ...report, claims }; +): Promise<{ replacement: AnalysisRef; claims: ConcludedClaim[] }> { + const { analysis, claims } = await recordAnalysis(w, input); + return { replacement: analysis, claims }; } diff --git a/tests/scenarios/s10_rerunning_is_not_reproducing.test.ts b/tests/scenarios/s10_rerunning_is_not_reproducing.test.ts index f4f278a1..da71ecf9 100644 --- a/tests/scenarios/s10_rerunning_is_not_reproducing.test.ts +++ b/tests/scenarios/s10_rerunning_is_not_reproducing.test.ts @@ -64,8 +64,8 @@ async function aHistoricalResultWithNoRecordedInputs() { describe("S-10: rerunning is not reproducing", () => { /** - * The wrong answer this scenario was built on, kept as the contrast that gives `reverify()` - * its meaning. + * A re-run recorded as a second analysis: `whySupported` lists both findings alike, with + * nothing saying one re-checked the other. */ test("recorded as two analyses, the re-run reads as independent confirmation", async () => { const { enquiry, historicalClaims } = await aHistoricalResultWithNoRecordedInputs(); @@ -104,410 +104,4 @@ describe("S-10: rerunning is not reproducing", () => { events, ); }); - - /** - * Afterward 1. "Is the historical result reproduced?" — its conclusion, - * possibly; its execution, no. Two answers, and collapsing them into one - * boolean is the mistake the scenario is named after. - */ - test("Afterward 1: the conclusion may be reproduced; the execution is not", async () => { - const { enquiry, historical } = await aHistoricalResultWithNoRecordedInputs(); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - expect(report.conclusion).toBe("agrees"); - // The original recorded nothing it read, so there is nothing to have - // reproduced. LabKit says that and stops. - expect(report.ofRead).toEqual([]); - expect(report.differs.map((d) => d.standing)).toEqual(["unrecorded-in-the-original"]); - }); - - /** - * Afterward 2. "What differs between the two runs?" — the initial - * conditions, named as **unrecorded** rather than as equal. Absence of a - * record is not evidence the two agree. - */ - test("Afterward 2: the difference is named as unrecorded, not as equal", async () => { - const { enquiry, historical } = await aHistoricalResultWithNoRecordedInputs(); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - expect(report.differs.map((d) => ({ what: d.what.name, standing: d.standing }))).toEqual([ - { - what: "initial conditions, newly specified", - standing: "unrecorded-in-the-original", - }, - ]); - }); - - /** - * Afterward 3. "Does the new run raise or lower confidence?" — answerable, and distinct from - * "confirms it". An agreeing re-verification strengthens the claim without reproducing it, - * and the report must not let a reader take the first for the second. - */ - test("Afterward 3: bearing on the historical claim is answerable and is not confirmation", async () => { - const { enquiry, historical, historicalClaims } = await aHistoricalResultWithNoRecordedInputs(); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - expect(report.bearing).toBe("raises"); - // There is no `confirms` field: "raises confidence" and "reproduced the - // execution" are different questions, asked separately, without settling - // what the overloaded word would mean. That is not the same as saying an - // independent re-check can never confirm a claim. - expect(report.ofRead).toEqual([]); - - // And the claim itself now reads as re-verified rather than as twice - // independently established. - const why = await (await afterwards()).reads.whySupported({ - claim: claimOf(historicalClaims, PROPOSITION), - }); - expect(why.support.map((s) => s.method)).toEqual(["annealing-v1"]); - expect(why.reverifiedBy.map((r) => r.method)).toEqual(["annealing-v1, re-run"]); - }); - - /** - * Afterward 4. "Can the two be compared numerically?" — no, and the record says so - * unprompted, alongside the rest of the answer. - */ - test("Afterward 4: the record says the original never recorded what it read", async () => { - const { enquiry, historical } = await aHistoricalResultWithNoRecordedInputs(); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - // LabKit does not decide whether two sets of numbers may be put side by - // side -- that is the reader's call, not the record's. What the record - // gives is the fact a comparison call would rest on: the re-run named - // what it read and the original named nothing. - expect(report.ofRead).toEqual([]); - expect(report.verificationRead.map((i) => i.name)).toEqual([ - "initial conditions, newly specified", - ]); - expect(report.differs.map((d) => d.standing)).toEqual(["unrecorded-in-the-original"]); - }); - - /** - * The control. Two runs that BOTH recorded their inputs, and the same - * inputs, are a literal reproduction — the distinction has to cut both ways - * or it is just a blanket caveat on every second run. - */ - test("two runs over the same recorded inputs are a reproduction, and comparable", async () => { - const { enquiry } = await session.writes.openEnquiry( - "does the annealed protocol converge below tolerance?", - ); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const { analysis: first } = await recordAnalysis(session.writes, { - enquiry, - method: "annealing-v1", - from: [conditions], - concludes: [{ proposition: PROPOSITION, finding: "converged, residual 3.1e-4" }], - }); - const rerun = await session.writes.reverify({ - historical: first, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 3.1e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - // Both runs named what they read, and it was the same record. Nothing - // differs, and the report says so without calling that a reproduction -- - // whether it is one depends on what the method does. - expect(report.differs).toEqual([]); - expect(report.verificationRead.map((i) => i.part)).toEqual(report.ofRead.map((i) => i.part)); - expect(report.ofRead).toHaveLength(1); - }); - - /** - * Execution equality must not be compared by artefact *name* -- that is the identity-versus- - * wording mistake, and it recurs. - */ - test("two inputs sharing a name are not the same input", async () => { - const { enquiry } = await session.writes.openEnquiry( - "does the annealed protocol converge below tolerance?", - ); - const { observations: theirs } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const { observations: mine } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions", - finding: "seed 91, tolerance 1e-3, 64 steps", - }); - const { analysis: historical } = await recordAnalysis(session.writes, { - enquiry, - method: "annealing-v1", - from: [theirs], - concludes: [{ proposition: PROPOSITION, finding: "converged, residual 3.1e-4" }], - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [mine], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - // Both directions, and both are true: the re-run read an "initial conditions" the original - // did not, and the original read one the re-run did not. Identical names, two artefacts, - // two differences. The entries carry identity, so "which one changed" is answerable even - // though the names collide. - expect(report.differs.map((d) => d.what.name)).toEqual([ - "initial conditions", - "initial conditions", - ]); - // Both standings present, on two distinct artefacts. Not asserted as an - // ordered list: entries now sort by name and then by identity, and which - // natural id sorts first is not a fact about the research. - expect(report.differs.map((d) => d.standing).sort()).toEqual([ - "changed", - "not-used-by-the-re-run", - ]); - expect(new Set(report.differs.map((d) => d.what.part)).size).toBe(2); - }); - - /** - * Two runs that each recorded *nothing* must not compare equal — an empty - * input record means provenance was never captured, not that the run - * consumed nothing. - */ - test("two runs that both recorded no inputs have not reproduced anything", async () => { - const { enquiry, historical } = await aHistoricalResultWithNoRecordedInputs(); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - // Neither run named anything. Two empty lists, and no claim that they - // therefore match -- absence on both sides is still absence. - expect(report.verificationRead).toEqual([]); - expect(report.ofRead).toEqual([]); - }); - - /** - * External review, finding 3c. The difference calculation only looked for - * new-run inputs absent from the original, so dropping an input reported - * `not-reproduced` with nothing named as differing. - */ - test("an input the original used and the re-run did not is named", async () => { - const { enquiry } = await session.writes.openEnquiry( - "does the annealed protocol converge below tolerance?", - ); - const { observations: a } = await session.writes.recordObservations({ - enquiry, - name: "conditions A", - finding: "seed 4", - }); - const { observations: b } = await session.writes.recordObservations({ - enquiry, - name: "conditions B", - finding: "warm start", - }); - const { analysis: historical } = await recordAnalysis(session.writes, { - enquiry, - method: "annealing-v1", - from: [a, b], - concludes: [{ proposition: PROPOSITION, finding: "converged, residual 3.1e-4" }], - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [a], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - expect(report.differs.map((d) => ({ what: d.what.name, standing: d.standing }))).toEqual([ - { what: "conditions B", standing: "not-used-by-the-re-run" }, - ]); - }); - - /** - * External review, finding 4. Agreement was read from the re-run's bearing - * alone, never compared with the original's — so two runs that both found - * *against* the proposition were reported as disagreeing with each other. - */ - test("two runs that both find against the proposition agree with each other", async () => { - const { enquiry } = await session.writes.openEnquiry( - "does the annealed protocol converge below tolerance?", - ); - const { analysis: historical } = await recordAnalysis(session.writes, { - enquiry, - method: "annealing-v1", - from: [], - concludes: [ - { - proposition: PROPOSITION, - finding: "did not converge", - bearing: "challenges", - }, - ], - }); - const { observations: conditions } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - const rerun = await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [conditions], - concludes: { - proposition: PROPOSITION, - finding: "did not converge either", - bearing: "challenges", - }, - }); - - const report = await (await afterwards()).reads.reproductionOf({ - verification: rerun.verification, - }); - expect(report.conclusion).toBe("agrees"); - // Agreeing with a negative finding does not raise confidence in the - // proposition -- bearing is about the claim, not about the two runs. - expect(report.bearing).toBe("lowers"); - }); - - /** - * External review, finding 5. - */ - test("the claim does not rest on the re-run's inputs", async () => { - const { enquiry } = await session.writes.openEnquiry( - "does the annealed protocol converge below tolerance?", - ); - const { observations: original } = await session.writes.recordObservations({ - enquiry, - name: "original conditions", - finding: "seed 1", - }); - const { analysis: historical, claims: historicalClaims } = await recordAnalysis( - session.writes, - { - enquiry, - method: "annealing-v1", - from: [original], - concludes: [{ proposition: PROPOSITION, finding: "converged, residual 3.1e-4" }], - }, - ); - const { observations: fresh } = await session.writes.recordObservations({ - enquiry, - name: "initial conditions, newly specified", - finding: "seed 4, tolerance 1e-6, 512 steps", - }); - await session.writes.reverify({ - historical, - enquiry, - method: "annealing-v1, re-run", - under: [fresh], - concludes: { - proposition: PROPOSITION, - finding: "converged, residual 2.9e-4", - }, - }); - - const why = await (await afterwards()).reads.whySupported({ - claim: claimOf(historicalClaims, PROPOSITION), - }); - expect(why.support.map((s) => s.method)).toEqual(["annealing-v1"]); - expect(why.reverifiedBy.map((r) => r.method)).toEqual(["annealing-v1, re-run"]); - expect(why.restingOn.map((a) => a.name)).toEqual(["original conditions"]); - }); }); diff --git a/tests/scenarios/s10b_the_same_inputs_in_a_different_order.test.ts b/tests/scenarios/s10b_the_same_inputs_in_a_different_order.test.ts deleted file mode 100644 index 63cf81af..00000000 --- a/tests/scenarios/s10b_the_same_inputs_in_a_different_order.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * S-10b — "The same inputs, in a different order." - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { claimOf } from "../helpers/claims"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const SHIFTED = "the second series is shifted relative to the first"; - -/** - * Researcher: "The alignment is a subtraction — first series minus second. Run it the other way - * round and the sign flips, so the order of the two inputs is part of what the run was." - */ -async function anAlignmentRunInOneOrder( - s: ResearchSession, - order: "first-then-second" | "second-then-first", -) { - const { enquiry } = await s.writes.openEnquiry( - "is the second series shifted relative to the first?", - ); - const { observations: first } = await s.writes.recordObservations({ - enquiry, - name: "series A", - finding: "baseline trace", - contentHash: "sha256:A", - }); - const { observations: second } = await s.writes.recordObservations({ - enquiry, - name: "series B", - finding: "comparison trace", - contentHash: "sha256:B", - }); - const inputs = order === "first-then-second" ? [first, second] : [second, first]; - const { analysis, claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "pairwise-alignment", - from: inputs, - concludes: [{ proposition: SHIFTED, finding: "offset of +4.1 units" }], - }); - return { enquiry, first, second, analysis, analysisClaims }; -} - -describe("S-10b: the same inputs, in a different order", () => { - /** - * The control: the two orders are genuinely different runs, and the record - * holds the same two artefacts either way. - */ - test("both orders record the same two inputs", async () => { - const forwards = await anAlignmentRunInOneOrder(session, "first-then-second"); - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis: forwards.analysis, - rebuilt: [ - { part: forwards.first, hash: "sha256:A" }, - { part: forwards.second, hash: "sha256:B" }, - ], - }); - expect(report.exact.map((p) => p.name).sort()).toEqual(["series A", "series B"]); - expect(report.reproducible).toBe(true); - - await captureConversation( - { - id: "S-10b", - title: "The same inputs, in a different order", - about: - "An alignment subtracts one series from the other, so which input came first is part of what the run was. The record keeps both series either way, and nothing in it tells the two orders apart.", - }, - events, - ); - }); - - /** - * **The finding, and it is an absence rather than row T's wrong answer.** - */ - test("a rebuild in the opposite order reports itself reproducible", async () => { - const backwards = await anAlignmentRunInOneOrder(session, "second-then-first"); - - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis: backwards.analysis, - rebuilt: [ - { part: backwards.first, hash: "sha256:A" }, - { part: backwards.second, hash: "sha256:B" }, - ], - }); - - // Identical to the forwards run in the control above. The two orders are - // indistinguishable to every read on the surface. - expect(report.exact.map((p) => p.name).sort()).toEqual(["series A", "series B"]); - expect(report.reproducible).toBe(true); - }); - - /** - * And the same absence through the verb built for exactly this question. `reproductionOf()` - * decides whether two runs are a reproduction by comparing what each recorded consuming — a - * set comparison, with no order in it. - */ - test("re-verification treats the reversed run as a reproduction", async () => { - const { enquiry, first, second, analysis, analysisClaims } = await anAlignmentRunInOneOrder( - session, - "first-then-second", - ); - - await session.writes.reverify({ - historical: analysis, - enquiry, - method: "pairwise-alignment", - under: [second, first], - concludes: { proposition: SHIFTED, finding: "offset of +4.1 units" }, - }); - - const verification = await (await afterwards()).reads.whySupported({ - claim: claimOf(analysisClaims, SHIFTED), - }); - expect(verification.reverifiedBy.map((r) => r.method)).toEqual(["pairwise-alignment"]); - expect(verification.support).toHaveLength(1); - - // The re-run read the same artefacts in the opposite order and the record - // calls it a re-verification of the original finding. Nothing on the - // surface distinguishes it from a re-run in the same order -- which is the - // absence, stated once more from a second reader. - expect(first).not.toEqual(second); - }); -}); diff --git a/tests/scenarios/s10c_which_input_changed.test.ts b/tests/scenarios/s10c_which_input_changed.test.ts deleted file mode 100644 index 2e3fe1c4..00000000 --- a/tests/scenarios/s10c_which_input_changed.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * S-10c — "Which input changed?" - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const NAME = "control series"; -const HOLDS = "the effect holds against the control"; - -/** - * Researcher: "The original control was lost after the first run. We re-checked the finding - * against the regenerated one — same name, different series." - */ -async function aReVerificationAgainstTheRegeneratedControl(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("does the effect hold against the control?"); - const { observations: original } = await s.writes.recordObservations({ - enquiry, - name: NAME, - finding: "the original series", - contentHash: "sha256:original", - }); - const { analysis, claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "effect-test", - from: [original], - concludes: [{ proposition: HOLDS, finding: "effect survives the control" }], - }); - - const { observations: regenerated } = await s.writes.recordObservations({ - enquiry, - name: NAME, - finding: "regenerated from an inferred algorithm", - contentHash: "sha256:regenerated", - }); - const { verification } = await s.writes.reverify({ - historical: analysis, - enquiry, - method: "effect-test, re-run", - under: [regenerated], - concludes: { proposition: HOLDS, finding: "effect survives the control" }, - }); - return { - enquiry, - original, - regenerated, - analysis, - analysisClaims, - verification, - }; -} - -describe("S-10c: which input changed?", () => { - /** - * The re-run reads a different artefact and the record says so, without - * concluding anything from it — there is no verdict field like - * `execution: "not-reproduced"`. - */ - test("swapping an input for a same-named one is reported as two differences", async () => { - const { verification } = await aReVerificationAgainstTheRegeneratedControl(session); - const report = await (await afterwards()).reads.reproductionOf({ verification }); - expect(report.differs).toHaveLength(2); - - await captureConversation( - { - id: "S-10c", - title: "Which input changed?", - about: - "The original control series was lost and the finding was re-checked against a regenerated one with the same name. The record reports two differences, and the reader has to be able to say which series each of them is about.", - }, - events, - ); - }); - - /** - * **The fourth bite.** *Which* input changed is unanswerable from the report. - */ - test("the two entries name the same thing and mean different artefacts", async () => { - const { original, regenerated, verification } = - await aReVerificationAgainstTheRegeneratedControl(session); - - const report = await (await afterwards()).reads.reproductionOf({ verification }); - - expect(report.differs.map((d) => d.what.name)).toEqual([NAME, NAME]); - expect(report.differs.map((d) => d.what.part).sort()).toEqual([original, regenerated].sort()); - - // And each is paired with the standing that belongs to it: the regenerated - // series is what the re-run introduced, the original is what it stopped - // using. - const byPart = new Map(report.differs.map((d) => [d.what.part, d.standing])); - expect(byPart.get(regenerated)).toBe("changed"); - expect(byPart.get(original)).toBe("not-used-by-the-re-run"); - }); - - /** - * The enumeration behind row F's verdict, asserted rather than argued. - */ - test("a name is never enough, and a reference always is", async () => { - const { original, regenerated } = await aReVerificationAgainstTheRegeneratedControl(session); - const reader = await afterwards(); - - // Name: refused, with the count that makes the refusal actionable. - await expect(reader.reads.whatDependsOn({ subject: NAME })).rejects.toThrow( - /2 artefacts are named/, - ); - - // Reference: answered, separately, for each. - for (const part of [original, regenerated]) { - const rests = await reader.reads.whatDependsOn({ subject: part }); - expect(rests.claims.map((c) => c.asserts)).toEqual([HOLDS]); - } - }); -}); diff --git a/tests/scenarios/s10d_reversed_inputs_one_execution.test.ts b/tests/scenarios/s10d_reversed_inputs_one_execution.test.ts deleted file mode 100644 index 702983f5..00000000 --- a/tests/scenarios/s10d_reversed_inputs_one_execution.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * S-10d — "The record keeps the order it was given." External review of PR #2, discriminator 3, - * then corrected by Dan. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; - -const clock: Clock = { now: () => "2026-08-24T11:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -const METHOD = "difference of the two series"; -const PROP = "the two series differ in magnitude"; - -/** Two series, and a run that takes the difference between them. */ -async function aDifference() { - const { enquiry } = await session.writes.openEnquiry("do the two series differ?"); - const { observations: treated } = await session.writes.recordObservations({ - enquiry, - name: "treated series", - finding: "twelve points", - contentHash: "sha256:treated", - }); - const { observations: control } = await session.writes.recordObservations({ - enquiry, - name: "control series", - finding: "twelve points", - contentHash: "sha256:control", - }); - const { analysis } = await recordAnalysis(session.writes, { - enquiry, - method: METHOD, - from: [treated, control], - concludes: [{ proposition: PROP, finding: "difference 0.4" }], - }); - return { enquiry, treated, control, analysis }; -} - -describe("S-10d — the order a run read its inputs in", () => { - test("a rerun that read the same records in the other order is shown as such", async () => { - const { enquiry, treated, control, analysis } = await aDifference(); - const rerun = await session.writes.reverify({ - historical: analysis, - enquiry, - method: METHOD, - under: [control, treated], - concludes: { proposition: PROP, finding: "difference 0.4" }, - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const report = await later.reads.reproductionOf({ verification: rerun.verification }); - - // The same two records on both sides, so nothing differs... - expect(report.differs).toEqual([]); - // ...and the order each run read them in is on the record, which is the - // whole of what LabKit has to say about it. A reader who knows whether this - // method is order-sensitive can now tell; before, the information was gone. - expect(report.ofRead.map((i) => i.name)).toEqual(["treated series", "control series"]); - expect(report.verificationRead.map((i) => i.name)).toEqual([ - "control series", - "treated series", - ]); - - await captureConversation( - { - id: "S-10d", - title: "The order a run read its inputs in", - about: - "A run takes the difference between two series, and a re-run reads the same two records the other way round. The same records are on both sides, so nothing differs, and the record still shows the order each run read them in.", - }, - events, - ); - }); - - test("a rerun that read them in the same order is shown as that", async () => { - const { enquiry, treated, control, analysis } = await aDifference(); - const rerun = await session.writes.reverify({ - historical: analysis, - enquiry, - method: METHOD, - under: [treated, control], - concludes: { proposition: PROP, finding: "difference 0.4" }, - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const report = await later.reads.reproductionOf({ verification: rerun.verification }); - - expect(report.differs).toEqual([]); - expect(report.verificationRead.map((i) => i.name)).toEqual([ - "treated series", - "control series", - ]); - expect(report.verificationRead.map((i) => i.part)).toEqual(report.ofRead.map((i) => i.part)); - }); - - /** - * The pairing that makes the two tests above evidence rather than decoration. - */ - test("the two orders are different sequences of the same records", async () => { - const { enquiry, treated, control, analysis } = await aDifference(); - const rerun = await session.writes.reverify({ - historical: analysis, - enquiry, - method: METHOD, - under: [control, treated], - concludes: { proposition: PROP, finding: "difference 0.4" }, - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const report = await later.reads.reproductionOf({ verification: rerun.verification }); - - expect(report.verificationRead.map((i) => i.part)).not.toEqual( - report.ofRead.map((i) => i.part), - ); - expect([...report.verificationRead].map((i) => i.part).sort()).toEqual( - [...report.ofRead].map((i) => i.part).sort(), - ); - void treated; - void control; - }); -}); diff --git a/tests/scenarios/s10e_the_same_input_twice.test.ts b/tests/scenarios/s10e_the_same_input_twice.test.ts deleted file mode 100644 index 0e55da5b..00000000 --- a/tests/scenarios/s10e_the_same_input_twice.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * S-10e — "A run that read the same record twice." External peer review of PR #2, merge blocker - * 1. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; - -const clock: Clock = { now: () => "2026-08-24T13:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -const PROP = "the series does not differ from itself"; - -describe("S-10e — the same record, read twice by one run", () => { - test("a run that read one record twice is not reported as having read it once", async () => { - const { enquiry } = await session.writes.openEnquiry("does the series differ from itself?"); - const { observations: series } = await session.writes.recordObservations({ - enquiry, - name: "series", - finding: "twelve points", - contentHash: "sha256:series", - }); - - // The null test: the same series on both sides of a difference. - const { analysis } = await recordAnalysis(session.writes, { - enquiry, - method: "difference of the two series", - from: [series, series], - concludes: [{ proposition: PROP, finding: "difference 0.0" }], - }); - // And a re-run that read it once, so the two are genuinely different. - const rerun = await session.writes.reverify({ - historical: analysis, - enquiry, - method: "difference of the two series", - under: [series], - concludes: { proposition: PROP, finding: "difference 0.0" }, - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const report = await later.reads.reproductionOf({ verification: rerun.verification }); - - expect(report.ofRead.map((i) => i.part)).toEqual([series, series]); - expect(report.verificationRead.map((i) => i.part)).toEqual([series]); - - await captureConversation( - { - id: "S-10e", - title: "The same record, read twice by one run", - about: - "A null test puts one series on both sides of a difference, and a re-run reads it only once. The record keeps how many times each run read the series, so the two are not reported as having read the same thing.", - }, - events, - ); - }); - - test("and the order of a repeat is kept, not just its count", async () => { - const { enquiry } = await session.writes.openEnquiry("does the sandwich cancel?"); - const { observations: a } = await session.writes.recordObservations({ - enquiry, - name: "series A", - finding: "twelve points", - }); - const { observations: b } = await session.writes.recordObservations({ - enquiry, - name: "series B", - finding: "twelve points", - }); - - const { analysis } = await recordAnalysis(session.writes, { - enquiry, - method: "a minus b plus a", - from: [a, b, a], - concludes: [{ proposition: PROP, finding: "residual 0.1" }], - }); - const rerun = await session.writes.reverify({ - historical: analysis, - enquiry, - method: "a minus b plus a", - under: [a, b, a], - concludes: { proposition: PROP, finding: "residual 0.1" }, - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const report = await later.reads.reproductionOf({ verification: rerun.verification }); - - expect(report.ofRead.map((i) => i.name)).toEqual(["series A", "series B", "series A"]); - expect(report.differs).toEqual([]); - }); -}); diff --git a/tests/scenarios/s11_invalidate_analysis.test.ts b/tests/scenarios/s11_invalidate_analysis.test.ts index a21ba72c..c0afeba8 100644 --- a/tests/scenarios/s11_invalidate_analysis.test.ts +++ b/tests/scenarios/s11_invalidate_analysis.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; let scenario: Scenario; @@ -102,129 +102,64 @@ const SIGN_FLIP_CONCLUSIONS = [ { proposition: "T beats static", finding: "p = 0.006 (bootstrap)" }, ]; +/** + * The reviewer's objection, recorded against the analysis it is about, then the replacement + * run: each of its conclusions names the finding it stands in place of. + */ +async function replacedWithASignFlipTest() { + const shipped = await bootstrapAnalysisAsShipped(); + await reviewer.writes.conclude({ + analysis: shipped.analysis, + proposition: "the bootstrap implements the intended null", + finding: "bootstrap is centred on the observed effect; it does not implement the intended null", + bearing: "challenges", + }); + const report = await reanalyse(session.writes, { + enquiry: shipped.enquiry, + method: "sign-flip-permutation", + from: [shipped.observations], + concludes: SIGN_FLIP_CONCLUSIONS.map((c) => ({ + ...c, + replacing: claimOf(shipped.analysisClaims, c.proposition), + })), + }); + return { ...shipped, report }; +} + describe("S-11: the analysis was wrong; the observations were fine", () => { test("the conversation runs end to end through research verbs alone", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - - // Reviewer: your bootstrap is centred on the observed effect. It isn't a null test. - const { review } = await reviewer.writes.recordReview({ - of: analysis, - verdict: - "bootstrap is centred on the observed effect; it does not implement the intended null", - }); - - // Researcher: replace the analysis, mark the prior inference superseded, - // and propagate whatever claims change. - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); - - // The act answers with what it minted: the replacement and the decision - // recording that it revises the earlier analysis. + const { analysis, analysisClaims, report } = await replacedWithASignFlipTest(); expect(report.replacement).not.toEqual(analysis); - expect(report.supersedes).toEqual(analysis); - // LabKit: five pairwise conclusions remain strong. One becomes marginal — - // asked of the record, because six conclusions arrive as six acts and what - // a revision changed is therefore spread across them rather than held by - // any one of them. - const explained = await (await afterwards()).reads.why({ subject: report.replacement }); - if (explained.kind !== "analysis") - throw new Error(`asked about an analysis, got ${explained.kind}`); - expect(explained.report.supersedes).toEqual(analysis); - expect(explained.report.because?.review).toEqual(review); - expect(explained.report.changed).toHaveLength(1); - expect(explained.report.changed[0]).toMatchObject({ - proposition: "T beats rewired", - before: "p = 0.002 (bootstrap)", - after: "p = 0.049 (sign-flip permutation)", + // The conclusion that moved reads the new finding, and still shows the one it replaced. + const why = await (await afterwards()).reads.whySupported({ + claim: claimOf(report.claims, "T beats rewired"), }); - // Five re-reached unchanged, and none left unmentioned: this replacement - // restated every conclusion of the analysis it revises. - expect(explained.report.restated).toHaveLength(5); - expect(explained.report.kept).toEqual([]); + expect(why.verdict).toBe("supported"); + expect(why.support.map((s) => s.finding)).toEqual(["p = 0.049 (sign-flip permutation)"]); + expect(why.superseded.map((s) => s.finding)).toEqual(["p = 0.002 (bootstrap)"]); - // Which records these are about. `restated` and `changed.claim` name the - // REPLACEMENT's claims; `changed.was` names the superseded one, and the two - // sets are disjoint even though every sentence appears in both. - const minted = new Set(report.claims.map((c) => c.claim)); - for (const u of explained.report.restated) expect(minted.has(u.claim)).toBe(true); - expect(minted.has(explained.report.changed[0]!.claim)).toBe(true); - expect(minted.has(explained.report.changed[0]!.was)).toBe(false); + // Every original conclusion was replaced, so none of them stands. + for (const { claim } of analysisClaims) { + const old = await (await afterwards()).reads.whySupported({ claim }); + expect(old.verdict).toBe("withdrawn"); + } await captureConversation( { id: "S-11", title: "The analysis was wrong; the observations were fine", about: - "A reviewer finds the analysis does not implement the null it claims. The observations stand; the analysis is replaced, and only the conclusion that moved is marked as changed.", + "A reviewer finds the analysis does not implement the null it claims. The observations stand; the analysis is run again correctly, and each new conclusion names the one it replaces.", }, events, - explained, + why, ); }); - test("Afterward 1: what is affected is enumerable, not 'everything downstream'", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "not a null test", - }); + test("the observations are not affected, and still underpin the replacement", async () => { + const { report } = await replacedWithASignFlipTest(); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); - - // Every finding the replacement superseded, read from the record. Matched - // by id, not by sentence: after a replacement both the superseded claim and - // the one standing in its place assert the same words. - const revision = await (await afterwards()).reads.why({ subject: report.replacement }); - if (revision.kind !== "analysis") throw new Error(`expected an analysis, got ${revision.kind}`); - const supersededHere = [ - ...revision.report.changed.map((c) => c.was), - ...revision.report.restated.map((r) => r.claim), - ]; - expect(supersededHere).toHaveLength(6); - - // ...and the same answer from a different question. `whatDependsOn` walks - // the artefact; this walks the lineage. They must agree on the count. - const downstream = await session.reads.whatDependsOn({ subject: "bootstrap-pairwise output" }); - expect(downstream.claims).toHaveLength(supersededHere.length); - }); - - test("Afterward 2: the observations are explicitly not affected, and still underpin the replacement", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "not a null test", - }); - - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); - - // The observations are not superseded by this act: it revises an analysis, - // and what an analysis read is untouched by its being revised. - const stillThere = await (await afterwards()).reads.whatDependsOn({ subject: observations }); - expect(stillThere.claims.length).toBeGreaterThan(0); - - // Durable check: the replacement conclusion still rests on the same - // observations, and those observations were never invalidated. const why = await session.reads.whySupported({ claim: claimOf(report.claims, "T beats rewired"), }); @@ -236,57 +171,23 @@ describe("S-11: the analysis was wrong; the observations were fine", () => { expect(why.restingOn.map((a) => a.name)).toContain("per-image classification results"); }); - test("Afterward 4: the replacement conclusion is supported via a different inference", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "not a null test", - }); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); + test("the replacement conclusion is supported via a different inference", async () => { + const { report } = await replacedWithASignFlipTest(); const why = await session.reads.whySupported({ claim: claimOf(report.claims, "T beats rewired"), }); - expect( - await (await afterwards()).reads.whySupported({ - claim: claimOf(report.claims, "T beats rewired"), - }), - ).toEqual(why); expect(why.verdict).toBe("supported"); expect(why.support.map((s) => s.method)).toEqual(["sign-flip-permutation"]); expect(why.support[0]!.finding).toBe("p = 0.049 (sign-flip permutation)"); }); - test("Afterward 5: what the superseded inference claimed is still readable", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "not a null test", - }); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); + test("what the superseded inference claimed is still readable", async () => { + const { report } = await replacedWithASignFlipTest(); const why = await session.reads.whySupported({ claim: claimOf(report.claims, "T beats rewired"), }); - expect( - await (await afterwards()).reads.whySupported({ - claim: claimOf(report.claims, "T beats rewired"), - }), - ).toEqual(why); expect(why.superseded).toHaveLength(1); expect(why.superseded[0]).toMatchObject({ finding: "p = 0.002 (bootstrap)", @@ -333,128 +234,4 @@ describe("S-11: the analysis was wrong; the observations were fine", () => { }); expect(onFashion.restingOn.map((a) => a.name)).toEqual(["fashion-mnist per-image results"]); }); - - test("why support was withdrawn is answerable from the graph, not just the event log", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: - "bootstrap is centred on the observed effect; it does not implement the intended null", - }); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); - - // A fresh session over the same graph -- nothing carried in memory. - const reader = new ResearchSession(await scenario.current(), { clock }); - const why = await reader.reads.whySupported({ - claim: claimOf(report.claims, "T beats rewired"), - }); - expect(why.superseded[0]!.reason).toBe( - "bootstrap is centred on the observed effect; it does not implement the intended null", - ); - }); - - /** - * The review relationship constrains a research action, not just an explanatory query: a - * replacement has to be justified by a review OF the analysis being replaced. - */ - test("a replacement cannot cite a review of some other analysis", async () => { - const { enquiry } = await session.writes.openEnquiry("which construction classifies best?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "obs", - finding: "raw", - }); - - const { analysis: target, claims: targetClaims } = await recordAnalysis(session.writes, { - enquiry, - method: "bootstrap-pairwise", - from: [observations], - concludes: [{ proposition: "T beats rewired", finding: "p = 0.002 (bootstrap)" }], - }); - const { analysis: unrelated } = await recordAnalysis(session.writes, { - enquiry, - method: "unrelated-analysis", - from: [observations], - concludes: [{ proposition: "something else entirely", finding: "n/a" }], - }); - const { review: reviewOfUnrelated } = await session.writes.recordReview({ - of: unrelated, - verdict: "a verdict about other work", - }); - - await expect( - replaceAnalysis(session.writes, { - supersedes: target, - because: reviewOfUnrelated, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: [{ proposition: "T beats rewired", finding: "p = 0.049" }], - }), - ).rejects.toThrow(/does not review/); - - // ...and nothing was invalidated on the way to failing. - // The replacement was refused, so the original claim is the only one. - const why = await session.reads.whySupported({ - claim: claimOf(targetClaims, "T beats rewired"), - }); - expect( - await (await afterwards()).reads.whySupported({ - claim: claimOf(targetClaims, "T beats rewired"), - }), - ).toEqual(why); - expect(why.verdict).toBe("supported"); - expect(why.superseded).toHaveLength(0); - }); - - test("the temporal seam records the invalidation, with its time and what it moved", async () => { - const { enquiry, observations, analysis } = await bootstrapAnalysisAsShipped(); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "not a null test", - }); - const _report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "sign-flip-permutation", - from: [observations], - concludes: SIGN_FLIP_CONCLUSIONS, - }); - - const replacement = (await events.all()).filter((e) => e.operation === "replaceAnalysis"); - expect(replacement).toHaveLength(1); - expect(replacement[0]!.at).toBe(FIXED_NOW); - // Nothing kept: this replacement supersedes every conclusion of the - // analysis it revises, which is what `replace` means. - expect(replacement[0]!.command).toMatchObject({ supersedes: analysis, keeping: [] }); - - // Every research action left a trace, in order — one per action, not one per write. **The - // `conclude` entries are actions**: this run drew six conclusions and the record says so - // six times, as it would had a person typed `labkit conclude` six times. The count is the - // caller's, not the graph's. - const concluded = (n: number) => Array.from({ length: n }, () => "conclude" as const); - expect((await events.all()).map((e) => e.operation)).toEqual([ - "openEnquiry", - "recordObservations", - "recordAnalysis", - ...concluded(SIGN_FLIP_CONCLUSIONS.length), - "recordReview", - // The revision first, then its findings: superseding happens when the - // successor is recorded, and each new conclusion is an act after it. - "replaceAnalysis", - ...concluded(SIGN_FLIP_CONCLUSIONS.length), - ]); - }); - /** - * **Researcher:** I superseded that analysis. Then I noticed one more thing in its output and - * went to record it against it. - */ }); diff --git a/tests/scenarios/s11b_which_review_retracted_it.test.ts b/tests/scenarios/s11b_which_review_retracted_it.test.ts deleted file mode 100644 index 0d1df07f..00000000 --- a/tests/scenarios/s11b_which_review_retracted_it.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * S-11b — "Which review retracted it?" - */ - -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; -import { as } from "../helpers/conversation"; - -let scenario: Scenario; - -/** Frozen: two worlds a read could separate only by elapsed time are not separated. */ -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -async function inOneWorld(build: (s: ResearchSession) => Promise): Promise { - const graph = await scenario.begin(); - try { - return await build( - new ResearchSession(graph, { - clock, - events: inMemoryEventLog(), - attribution: as("Researcher"), - }), - ); - } finally { - await scenario.end(); - } -} - -async function inTwoWorlds( - worldA: (s: ResearchSession) => Promise, - worldB: (s: ResearchSession) => Promise, -): Promise<{ a: T; b: T }> { - return { a: await inOneWorld(worldA), b: await inOneWorld(worldB) }; -} - -const SHIFTS = "the coating shifts the onset temperature"; -const UNSOUND = "the fit used a linear model where the response is plainly sigmoid"; -const CONFIRMING = "numbers check out; independently recomputed the same values"; - -/** - * Researcher: "We have an analysis, and two colleagues have looked at it. One says the method - * is wrong. The other says the arithmetic is right." - */ -async function anAnalysisWithTwoReviews(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("does the coating shift the onset temperature?"); - const { observations: readings } = await s.writes.recordObservations({ - enquiry, - name: "onset sweep", - finding: "onset across twelve coatings", - }); - const { analysis, claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "linear-onset-fit", - from: [readings], - concludes: [{ proposition: SHIFTS, finding: "onset moves by 4.2 K" }], - }); - const { review: critical } = await s.writes.recordReview({ of: analysis, verdict: UNSOUND }); - const { review: confirming } = await s.writes.recordReview({ - of: analysis, - verdict: CONFIRMING, - }); - return { enquiry, readings, analysis, analysisClaims, critical, confirming }; -} - -describe("S-11b: which review retracted it?", () => { - /** - * The control. Two worlds that differ in something the record demonstrably - * carries, so the equalities below are facts about the read surface rather - * than artefacts of the harness. - */ - test("two worlds differing in what the replacement concluded are told apart", async () => { - const build = (finding: string) => async (s: ResearchSession) => { - const { enquiry, readings, analysis, critical } = await anAnalysisWithTwoReviews(s); - const report = await replaceAnalysis(s.writes, { - supersedes: analysis, - because: critical, - enquiry, - method: "sigmoid-onset-fit", - from: [readings], - concludes: [{ proposition: SHIFTS, finding }], - }); - const why = await (await afterwards()).reads.whySupported({ - claim: claimOf(report.claims, SHIFTS), - }); - return why.support.map((x) => x.finding).sort(); - }; - const { a, b } = await inTwoWorlds(build("onset moves by 2.8 K"), build("onset does not move")); - expect(a).toEqual(["onset moves by 2.8 K"]); - expect(b).toEqual(["onset does not move"]); - }); - - /** - * **Row O.** Two worlds identical except for which review the replacement was made on the - * strength of. - */ - test("the reason a finding was superseded is the review that caused it", async () => { - const build = (pick: "critical" | "confirming") => async (s: ResearchSession) => { - const w = await anAnalysisWithTwoReviews(s); - const report = await replaceAnalysis(s.writes, { - supersedes: w.analysis, - because: pick === "critical" ? w.critical : w.confirming, - enquiry: w.enquiry, - method: "sigmoid-onset-fit", - from: [w.readings], - concludes: [{ proposition: SHIFTS, finding: "onset moves by 2.8 K" }], - }); - const why = await (await afterwards()).reads.whySupported({ - claim: claimOf(report.claims, SHIFTS), - }); - return why.superseded - .map((x) => ({ finding: x.finding, reason: x.reason })) - .sort((p, q) => p.reason.localeCompare(q.reason)); - }; - - const { a, b } = await inTwoWorlds(build("critical"), build("confirming")); - - // Each world reports the verdict that actually caused its retraction. - expect(a).toEqual([{ finding: "onset moves by 4.2 K", reason: UNSOUND }]); - expect(b).toEqual([{ finding: "onset moves by 4.2 K", reason: CONFIRMING }]); - - // Stated separately because it is the assertion that was false before row - // O: the two worlds must not be indistinguishable. Everything else about - // them is identical, so this is the read surface carrying which review the - // researcher acted on, and nothing else. - expect(a).not.toEqual(b); - }); - - /** - * The other half, asked of one world so the claim does not depend on the pairing: one - * supersession is reported **once**. - */ - test("one supersession is reported once, with the reason that caused it", async () => { - const reasons = await inOneWorld(async (s) => { - const w = await anAnalysisWithTwoReviews(s); - const report = await replaceAnalysis(s.writes, { - supersedes: w.analysis, - because: w.critical, - enquiry: w.enquiry, - method: "sigmoid-onset-fit", - from: [w.readings], - concludes: [{ proposition: SHIFTS, finding: "onset moves by 2.8 K" }], - }); - const why = await (await afterwards()).reads.whySupported({ - claim: claimOf(report.claims, SHIFTS), - }); - return why.superseded.map((x) => x.reason); - }); - // Two entries for one supersession, because `findingsBearing()` returns a - // row per matching review and nothing collapses them. A finding superseded - // once is reported twice, each time with a different reason, and the two - // reasons contradict each other. - expect(reasons).toEqual([UNSOUND]); - }); - - /** - * The decision did the retracting. Asking why it was made must not report it as the thing - * that fell. - */ - test("the decision rests on the review, and is not reported as retracted by it", async () => { - const wording = await inOneWorld(async (s) => { - const w = await anAnalysisWithTwoReviews(s); - const report = await replaceAnalysis(s.writes, { - supersedes: w.analysis, - because: w.critical, - enquiry: w.enquiry, - method: "sigmoid-onset-fit", - from: [w.readings], - concludes: [{ proposition: SHIFTS, finding: "onset moves by 2.8 K" }], - }); - const why = await (await afterwards()).reads.why({ subject: report.decision }); - return why.because.map((cause) => cause.wording); - }); - expect(wording).toContainEqual(expect.stringContaining("rests on")); - expect(wording).not.toContainEqual(expect.stringContaining("retracted")); - }); -}); diff --git a/tests/scenarios/s11c_absent_is_not_independent.test.ts b/tests/scenarios/s11c_absent_is_not_independent.test.ts deleted file mode 100644 index b7b8ab22..00000000 --- a/tests/scenarios/s11c_absent_is_not_independent.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * S-11c — "Nothing found is not nothing there." - *, ledger row I applied to dependency propagation. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { - ResearchSession, - inMemoryEventLog, - type Clock, - type DependencyReport, - type EventSink, -} from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const CALIBRATION = "the calibration is stable across the run"; -const TREND = "the response trends upward with dose"; - -/** - * Researcher: "Raw sensor data goes through calibration, and the calibrated series is what the - * trend analysis actually reads." - */ -async function aTwoStagePipeline(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("does the response trend upward with dose?"); - const { observations: raw } = await s.writes.recordObservations({ - enquiry, - name: "raw sensor series", - finding: "eleven dose levels, uncalibrated", - contentHash: "sha256:raw", - }); - const { analysis: calibration } = await recordAnalysis(s.writes, { - enquiry, - method: "calibrate", - from: [raw], - concludes: [ - { - proposition: CALIBRATION, - finding: "drift under 0.2% across the run", - }, - ], - }); - - // Stage two. The calibrated series is re-recorded because nothing on the - // surface hands stage one's output to stage two. - const { observations: calibrated } = await s.writes.recordObservations({ - enquiry, - name: "calibrated series", - finding: "eleven dose levels, calibrated", - contentHash: "sha256:calibrated", - }); - const { analysis: trend } = await recordAnalysis(s.writes, { - enquiry, - method: "dose-response-fit", - from: [calibrated], - concludes: [{ proposition: TREND, finding: "monotonic increase, p < 0.01" }], - }); - return { enquiry, raw, calibration, calibrated, trend }; -} - -describe("S-11c: nothing found is not nothing there", () => { - /** - * **The wrong answer a reader acts on.** - */ - test("a re-entered intermediate still severs the chain, and the report says so", async () => { - const { raw } = await aTwoStagePipeline(session); - - const affected = await (await afterwards()).reads.whatDependsOn({ subject: raw }); - - // The traversal is transitive now (row AE), but this builder deliberately re-enters the - // intermediate as fresh observations rather than reading the first analysis's output -- - // which is what a researcher had to do before `from` accepted an AnalysisRef. There is no - // CONSUMES/PRODUCES link to follow, so the trend claim is still out of reach. - expect(affected.claims.map((c) => c.asserts)).toEqual([CALIBRATION]); - expect(affected.claims.map((c) => c.asserts)).not.toContain(TREND); - expect(affected.complete).toBe(false); - - await captureConversation( - { - id: "S-11c", - title: "Nothing found is not nothing there", - about: - "Raw sensor data is calibrated, and the calibrated series is re-entered by hand as fresh observations before the trend analysis reads it. Asking what depends on the raw series reaches only the first stage, and the answer says it is a lower bound rather than a complete list.", - }, - events, - ); - }); - - /** - * The same defect stated so it cannot be dismissed as a two-stage quirk: **an artefact - * nothing depends on and an artefact whose dependants are out of reach return the same - * shape.** A reader cannot tell ignorance from independence, which is exactly row I's - * distinction — absence of evidence versus evidence of absence — asked of propagation. - */ - test("an empty answer says it is a lower bound rather than a finding of independence", async () => { - const { raw, enquiry } = await aTwoStagePipeline(session); - const { observations: unrelated } = await session.writes.recordObservations({ - enquiry, - name: "lab humidity log", - finding: "42% throughout, nothing read it", - }); - - const reader = await afterwards(); - const under = await reader.reads.whatDependsOn({ subject: raw }); - const none = await reader.reads.whatDependsOn({ subject: unrelated }); - - // Nothing was found for the humidity log, and the report does not let that - // be read as independence. This is the remedy in full: the values are - // unchanged, and what changed is that the answer stops overstating itself. - expect(none.claims).toEqual([]); - expect(none.complete).toBe(false); - expect(under.complete).toBe(false); - - // A reader can also see what was actually considered, which is what makes - // the caveat actionable rather than decorative -- the omission in the test - // above is precisely a route not on this list. - expect(none.routesWalked.length).toBe(3); - expect(none.routesWalked).toEqual(under.routesWalked); - }); - - /** - * `complete` is a literal `false`, not a boolean, so the caveat cannot be read off as a - * runtime flag that might one day be true. - */ - test("the report cannot be made to claim completeness", async () => { - const { raw } = await aTwoStagePipeline(session); - const affected = await (await afterwards()).reads.whatDependsOn({ subject: raw }); - - expect(affected.complete).toBe(false); - - // The constraint is on what can be *written*, not on what can be read -- - // `false` is assignable to `boolean`, so asserting the other direction - // proves nothing and TypeScript says so. A report claiming completeness - // does not typecheck, which is the guarantee worth having. - const claimsCompleteness = () => { - // @ts-expect-error `complete` is the literal `false`. Making this legal - // is the change 023 forbids without durable coverage state behind it. - const bad: DependencyReport = { ...affected, complete: true }; - return bad; - }; - expect(typeof claimsCompleteness).toBe("function"); - }); -}); diff --git a/tests/scenarios/s11d_a_stage_cannot_read_a_stage.test.ts b/tests/scenarios/s11d_a_stage_cannot_read_a_stage.test.ts deleted file mode 100644 index 13767f3b..00000000 --- a/tests/scenarios/s11d_a_stage_cannot_read_a_stage.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * S-11d — "Reproducible, on top of something that isn't." - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const TRENDS = "the response trends upward with dose"; - -/** - * Researcher: "The raw series came off an instrument nobody logged the settings for. We - * calibrated it, and the trend analysis reads the calibrated series." - */ -async function aPipelineOnUnverifiableRawData(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("does the response trend upward with dose?"); - const { observations: raw } = await s.writes.recordObservations({ - enquiry, - name: "raw sensor series", - finding: "eleven dose levels, instrument settings not logged", - }); - const { analysis: calibration } = await recordAnalysis(s.writes, { - enquiry, - method: "calibrate", - from: [raw], - concludes: [ - { - proposition: "the calibration is stable", - finding: "drift under 0.2%", - }, - ], - }); - - // Stage two reads stage one's output directly. Before row AE this was not - // expressible: `from` took observations only, so the intermediate had to be - // re-entered as if it were fresh measurement — severing the chain to the raw - // series and making stage two look independently reproducible. - const { analysis: trend } = await recordAnalysis(s.writes, { - enquiry, - method: "dose-response-fit", - from: [calibration], - concludes: [{ proposition: TRENDS, finding: "monotonic increase, p < 0.01" }], - }); - return { enquiry, raw, calibration, trend }; -} - -describe("S-11d: a stage cannot read a stage", () => { - /** The record is right about stage one: it rests on something uncheckable. */ - test("stage one reports itself unreproducible, correctly", async () => { - const { raw, calibration } = await aPipelineOnUnverifiableRawData(session); - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis: calibration, - rebuilt: [{ part: raw, hash: "sha256:whatever" }], - }); - expect(report.unverifiable.map((p) => p.name)).toEqual(["raw sensor series"]); - expect(report.reproducible).toBe(false); - - await captureConversation( - { - id: "S-11d", - title: "A stage cannot read a stage", - about: - "A raw series came off an instrument whose settings were never logged, it is calibrated, and the trend analysis reads the calibration's output directly. The calibration reports itself unreproducible because what it rests on cannot be checked.", - }, - events, - ); - }); - - /** - * Stage two no longer claims to be reproducible on top of something that isn't. **Inverted, - * not deleted** — the two lines this test shipped with in a comment are the live assertions - * now. - */ - test("stage two does not claim reproducibility it cannot have", async () => { - const { trend } = await aPipelineOnUnverifiableRawData(session); - - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis: trend, - rebuilt: [], - }); - - expect(report.reproducible).toBe(false); - // The calibration's output artefact -- what stage two actually read. - expect(report.unverifiable.map((p) => p.name)).toEqual(["calibrate output"]); - expect(report.exact.map((p) => p.name)).toEqual([]); - }); - - /** - * Invalidating the raw series reaches the trend claim two stages downstream -- the query - * walks more than one hop. - */ - test("what depends on the raw series reaches every stage built on it", async () => { - const { raw } = await aPipelineOnUnverifiableRawData(session); - - const fromRaw = await (await afterwards()).reads.whatDependsOn({ subject: raw }); - expect(fromRaw.claims.map((c) => c.asserts).sort()).toEqual( - ["the calibration is stable", TRENDS].sort(), - ); - // Still open-world -- the traversal is now transitive, not complete. - expect(fromRaw.complete).toBe(false); - }); -}); diff --git a/tests/scenarios/s11e_replacement_reads_what_it_invalidated.test.ts b/tests/scenarios/s11e_replacement_reads_what_it_invalidated.test.ts index 3299bcfa..fef4c4b1 100644 --- a/tests/scenarios/s11e_replacement_reads_what_it_invalidated.test.ts +++ b/tests/scenarios/s11e_replacement_reads_what_it_invalidated.test.ts @@ -7,7 +7,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; let scenario: Scenario; @@ -41,7 +41,7 @@ async function afterwards(): Promise { return new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog() }); } -/** An analysis, reviewed as defective — everything a replacement needs. */ +/** An analysis, with a reviewer's note that it is defective — everything a replacement needs. */ async function aDefectiveAnalysis() { const { enquiry } = await session.writes.openEnquiry("does the treatment shorten recovery?"); const { observations } = await session.writes.recordObservations({ @@ -55,31 +55,25 @@ async function aDefectiveAnalysis() { from: [observations], concludes: [{ proposition: PROP, finding: "three days shorter" }], }); - const { review } = await reviewer.writes.recordReview({ - of: analysis, - verdict: "unadjusted for baseline severity", - }); + await reviewer.writes.note({ text: "unadjusted for baseline severity", on: analysis }); return { enquiry, observations, analysis, - review, claim: claimOf(claims, PROP), }; } describe("S-11e — a replacement that consumes the output it invalidated", () => { test("the report says what the input actually is, rather than asserting it survived", async () => { - const { enquiry, analysis, review } = await aDefectiveAnalysis(); + const { enquiry, observations, analysis, claim } = await aDefectiveAnalysis(); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, + const report = await reanalyse(session.writes, { enquiry, method: "severity-adjusted comparison", // The analysis being replaced, named as the replacement's input. - from: [analysis], - concludes: [{ proposition: PROP, finding: "one day shorter, adjusted" }], + from: [observations, analysis], + concludes: [{ proposition: PROP, finding: "one day shorter, adjusted", replacing: claim }], }); // The replacement really does rest on it, and the record says the record it @@ -88,23 +82,21 @@ describe("S-11e — a replacement that consumes the output it invalidated", () = const resting = ( await (await afterwards()).reads.whySupported({ claim: report.claims[0]!.claim }) ).restingOn; - // **Two inputs, and that is the add-only rule.** The successor inherits - // what its predecessor read, and consumes the predecessor's own output - // besides, because this call named it. Only the second is retracted: - // every finding in it fell when the revision was recorded. + // Two inputs: the observations, and the predecessor's own output. Only the + // second is retracted: every finding in it fell when the replacement named it. expect(resting).toHaveLength(2); expect(resting.filter((r) => r.invalidated)).toHaveLength(1); // An ordinary input is unchanged, so the flag is a discriminator and not a // relabelling of every row. const clean = await aDefectiveAnalysis(); - const ordinary = await replaceAnalysis(session.writes, { - supersedes: clean.analysis, - because: clean.review, + const ordinary = await reanalyse(session.writes, { enquiry: clean.enquiry, method: "severity-adjusted comparison", from: [clean.observations], - concludes: [{ proposition: PROP, finding: "one day shorter, adjusted" }], + concludes: [ + { proposition: PROP, finding: "one day shorter, adjusted", replacing: clean.claim }, + ], }); const ordinaryResting = ( await (await afterwards()).reads.whySupported({ claim: ordinary.claims[0]!.claim }) @@ -113,14 +105,12 @@ describe("S-11e — a replacement that consumes the output it invalidated", () = }); test("the replacement's conclusion does not stand on a retracted record", async () => { - const { enquiry, analysis, review } = await aDefectiveAnalysis(); - const report = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, + const { enquiry, observations, analysis, claim } = await aDefectiveAnalysis(); + const report = await reanalyse(session.writes, { enquiry, method: "severity-adjusted comparison", - from: [analysis], - concludes: [{ proposition: PROP, finding: "one day shorter, adjusted" }], + from: [observations, analysis], + concludes: [{ proposition: PROP, finding: "one day shorter, adjusted", replacing: claim }], }); const later = new ResearchSession(await scenario.current(), { @@ -129,22 +119,12 @@ describe("S-11e — a replacement that consumes the output it invalidated", () = }); const why = await later.reads.whySupported({ claim: report.claims[0]!.claim }); - // a `supported` verdict stays, and that is the design rather than an oversight: - // invalidating a record deliberately does not withdraw what rests on it -- - // the consequence is *enumerable* rather than automatic. What was missing - // is the half that makes the doctrine honest — the reader - // could not see, from this answer, that the sole input had been retracted. + // A `supported` verdict stays: retracting a record does not withdraw what rests + // on it. The answer says which input was retracted instead. expect(why.verdict).toBe("supported"); expect(why.restingOn).toHaveLength(2); expect(why.restingOn.filter((r) => r.invalidated)).toHaveLength(1); - // And the enumerable route actually reaches this claim, which is what - // "not automatic" is relying on. If it did not, a `supported` verdict would be - // a wrong answer with no way to find out. - const retracted = why.restingOn.find((r) => r.invalidated)!; - const affected = await later.reads.whatDependsOn({ subject: retracted.part }); - expect(affected.claims.map((c) => c.claim)).toContain(report.claims[0]!.claim); - await captureConversation( { id: "S-11e", diff --git a/tests/scenarios/s11f_a_computed_input_is_not_measurement.test.ts b/tests/scenarios/s11f_a_computed_input_is_not_measurement.test.ts index 7dea701d..ac34ac20 100644 --- a/tests/scenarios/s11f_a_computed_input_is_not_measurement.test.ts +++ b/tests/scenarios/s11f_a_computed_input_is_not_measurement.test.ts @@ -85,37 +85,4 @@ describe("S-11f — a computed input, asked about by the reads that touch inputs events, ); }); - - test("accounting for a computed input declines, and does not report it unequal", async () => { - const { raw, calibration, trend } = await twoStages(); - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - - // Stage one is fully accounted for: a hash was recorded and it matches. - const stageOne = await later.reads.reproducibilityOf({ - analysis: calibration.analysis, - rebuilt: [{ part: raw, hash: "sha256:raw" }], - }); - expect(stageOne.exact.map((p) => p.name)).toEqual(["raw series"]); - expect(stageOne.reproducible).toBe(true); - - // Stage two reads a computed artefact, which carries no hash — nothing was measured, so - // there is nothing to hash against. That lands in `unverifiable`, which is the record - // declining to answer rather than answering no, and it is the correct answer about that - // record. - const stageTwo = await later.reads.reproducibilityOf({ analysis: trend.analysis, rebuilt: [] }); - expect(stageTwo.unverifiable.map((p) => p.name)).toEqual(["calibrate output"]); - // The half that makes this a real probe rather than a restatement: absence - // is not reported as difference. `differing` would be a wrong answer. - expect(stageTwo.differing).toEqual([]); - expect(stageTwo.reproducible).toBe(false); - - // **The one real consequence, and it is a weaker answer rather than a wrong one.** - // `unverifiable` is right about the hash and blind to the route: this artefact was produced - // by a computation whose own input is accounted for exactly, one hop away, and the record - // holds that. - expect(stageOne.reproducible).toBe(true); - }); }); diff --git a/tests/scenarios/s11g_a_partial_replacement.test.ts b/tests/scenarios/s11g_a_partial_replacement.test.ts index d66f0853..72f703b2 100644 --- a/tests/scenarios/s11g_a_partial_replacement.test.ts +++ b/tests/scenarios/s11g_a_partial_replacement.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimOf } from "../helpers/claims"; -import { recordAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; let scenario: Scenario; @@ -35,7 +35,6 @@ afterEach(async () => { /** Two of Bonsai's four comparisons: one the re-analysis revisits, one it excludes. */ const REVISITED = "T differs from the current-random control"; -const SURVIVES = "T differs from the lattice control"; const EXCLUDED = "T differs from the lattice control"; const AGGREGATION = "the aggregation is done on the correct scale"; @@ -78,25 +77,18 @@ async function aRunPartlyReAnalysed(holdTo = false) { }; } -/** The re-analysis, naming the one finding that survives it and no other. */ +/** The re-analysis, replacing the one finding it revisits and naming no other. */ async function theLogScaleReAnalysis(w: Awaited>) { - const { review } = await reviewer.writes.recordReview({ - of: w.v1, - verdict: "raw-scale aggregation is untrustworthy for the stochastic-control comparisons", + await reviewer.writes.note({ + text: "raw-scale aggregation is untrustworthy for the stochastic-control comparisons", + on: w.v1, }); - // The lattice comparison is what survives, matching the re-analysis's own - // scope: everything else the run concluded is superseded here. - const report = await session.writes.keep({ - keeping: [w.stands], - because: review, + return reanalyse(session.writes, { + enquiry: w.enquiry, method: "log-scale re-aggregation", + from: [w.observations], + concludes: [{ proposition: REVISITED, finding: "p = 0.007 log", replacing: w.revisited }], }); - const { claims } = await session.writes.conclude({ - analysis: report.replacement, - proposition: REVISITED, - finding: "p = 0.007 log", - }); - return { ...report, claims }; } async function afterwards(): Promise { @@ -141,106 +133,14 @@ describe("S-11g — a replacement that addresses only some of a run's conclusion * The other side of the same act, which is what makes the test above a * discriminator rather than a fix that switched everything off. */ - test("the finding it did name falls, and names the review that caused it", async () => { + test("the finding it did name falls, and names what replaced it", async () => { const w = await aRunPartlyReAnalysed(); await theLogScaleReAnalysis(w); const why = await (await afterwards()).reads.whySupported({ claim: w.revisited }); + expect(why.verdict).toBe("withdrawn"); expect(why.superseded.map((s) => s.finding)).toEqual(["p = 0.03 raw"]); - expect(why.superseded[0]!.reason).toContain("raw-scale aggregation is untrustworthy"); - }); - - /** - * The read has to be able to say "I cannot tell", or it is guessing. - */ - test("a superseded finding whose wording matches two is reported unpaired, not guessed", async () => { - const { enquiry } = await session.writes.openEnquiry("does T differ from its controls?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "two independent batches", - }); - // One sentence, two findings: the same claim about two batches. - const { analysis: v1, claims: v1Claims } = await recordAnalysis(session.writes, { - enquiry, - method: "raw-scale aggregation", - from: [observations], - concludes: [ - { proposition: REVISITED, finding: "p = 0.03 raw, batch one" }, - { proposition: REVISITED, finding: "p = 0.04 raw, batch two" }, - ], - }); - const { review } = await reviewer.writes.recordReview({ of: v1, verdict: "wrong scale" }); - - // Nothing kept — both fall — and one successor finding asserting the same - // sentence as each of them. - const report = await session.writes.replaceAnalysis({ - supersedes: v1, - because: review, - method: "log-scale re-aggregation", - }); - await session.writes.conclude({ - analysis: report.replacement, - proposition: REVISITED, - finding: "p = 0.007 log", - }); - - const why = await (await afterwards()).reads.why({ subject: report.replacement }); - if (why.kind !== "analysis") throw new Error(`expected an analysis, got ${why.kind}`); - // The superseded one is reported, and not paired with the successor: the - // wording matched more than one finding of the revised analysis. - expect(why.report.unpaired.map((u) => u.claim).sort()).toEqual( - v1Claims.map((c) => c.claim).sort(), - ); - expect(why.report.changed).toEqual([]); - }); - - /** - * The other half of the test above: named, so not a guess. - */ - test("a successor that names what it replaces is paired on the handle, not the wording", async () => { - const { enquiry } = await session.writes.openEnquiry("does T differ from its controls?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "two independent batches", - }); - // One sentence, two findings: the same claim about two batches. - const { analysis: v1, claims: v1Claims } = await recordAnalysis(session.writes, { - enquiry, - method: "raw-scale aggregation", - from: [observations], - concludes: [ - { proposition: REVISITED, finding: "p = 0.03 raw, batch one" }, - { proposition: REVISITED, finding: "p = 0.04 raw, batch two" }, - ], - }); - const batchTwo = v1Claims[1]!.claim; - const { review } = await reviewer.writes.recordReview({ of: v1, verdict: "wrong scale" }); - const report = await session.writes.replaceAnalysis({ - supersedes: v1, - because: review, - method: "log-scale re-aggregation", - }); - - // The successor names which of the two it stands in place of. - const { claims } = await session.writes.conclude({ - analysis: report.replacement, - proposition: REVISITED, - finding: "p = 0.007 log, batch two", - replacing: batchTwo, - }); - - const why = await (await afterwards()).reads.why({ subject: report.replacement }); - if (why.kind !== "analysis") throw new Error(`expected an analysis, got ${why.kind}`); - - // Paired, and to the one that was named. - expect(why.report.changed.map((c) => c.was)).toEqual([batchTwo]); - expect(why.report.changed[0]!.claim).toBe(claims[0]!.claim); - expect(why.report.changed[0]!.before).toBe("p = 0.04 raw, batch two"); - expect(why.report.changed[0]!.after).toBe("p = 0.007 log, batch two"); - // The one nothing named is still unpaired -- naming one does not pair both. - expect(why.report.unpaired.map((u) => u.claim)).toEqual([v1Claims[0]!.claim]); + expect(why.superseded[0]!.reason).toContain("p = 0.007 log"); }); /** @@ -323,95 +223,4 @@ describe("S-11g — a replacement that addresses only some of a run's conclusion [`${w.stands} passed`, `${successor} never-run`].sort(), ); }); - - /** - * **The pairing the act implies is recorded by the act.** - */ - test("a successor is paired to the finding it replaces, with nothing named", async () => { - const { enquiry } = await session.writes.openEnquiry("does T differ from its controls?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "one batch", - }); - const { analysis: v1, claims: v1Claims } = await recordAnalysis(session.writes, { - enquiry, - method: "raw-scale aggregation", - from: [observations], - concludes: [{ proposition: REVISITED, finding: "p = 0.03 raw" }], - }); - const { review } = await reviewer.writes.recordReview({ of: v1, verdict: "wrong scale" }); - const report = await session.writes.replaceAnalysis({ - supersedes: v1, - because: review, - method: "log-scale re-aggregation", - }); - const { claims } = await session.writes.conclude({ - analysis: report.replacement, - proposition: REVISITED, - finding: "p = 0.007 log", - }); - - const why = await (await afterwards()).reads.why({ subject: report.replacement }); - if (why.kind !== "analysis") throw new Error(`expected an analysis, got ${why.kind}`); - expect(why.report.unpaired).toEqual([]); - expect(why.report.changed.map((c) => c.was)).toEqual([v1Claims[0]!.claim]); - expect(why.report.changed[0]!.claim).toBe(claims[0]!.claim); - expect(why.report.changed[0]!.before).toBe("p = 0.03 raw"); - expect(why.report.changed[0]!.after).toBe("p = 0.007 log"); - }); - - /** - * **A kept finding still stands, so nothing replaces it.** - */ - test("a conclusion is never paired to a finding the revision kept", async () => { - const events = inMemoryEventLog(); - const graph = await scenario.current(); - session = new ResearchSession(graph, { clock, events, attribution: as("Researcher") }); - reviewer = new ResearchSession(graph, { clock, events, attribution: as("Reviewer") }); - const { enquiry } = await session.writes.openEnquiry("does T differ from its controls?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "one batch", - }); - const { analysis: v1, claims: v1Claims } = await recordAnalysis(session.writes, { - enquiry, - method: "raw-scale aggregation", - from: [observations], - concludes: [ - { proposition: REVISITED, finding: "p = 0.03 raw" }, - { proposition: SURVIVES, finding: "the lattice comparison, unaffected by scale" }, - ], - }); - const { review } = await reviewer.writes.recordReview({ of: v1, verdict: "wrong scale" }); - const report = await session.writes.keep({ - keeping: [claimOf(v1Claims, SURVIVES)], - because: review, - method: "log-scale re-aggregation", - }); - - // On the proposition that was KEPT, not the one that fell. - await session.writes.conclude({ - analysis: report.replacement, - proposition: SURVIVES, - finding: "the lattice comparison again, on the log scale", - }); - - // **Asserted on what the act wrote, not on a report.** No read shows this: - // `analysisRevision` iterates the claims the LINEAGE decision superseded, and - // `withdrawalOf` needs every claim asserting a proposition to have fallen -- the - // successor's own conclusion keeps it standing. - const superseding = (await events.all()) - .flatMap((e) => e.changes) - .filter((c) => c.change === "EdgeCreated" && c.label === "SUPERSEDES") - .map((c) => (c as { to: string }).to); - expect(superseding).not.toContain(claimOf(v1Claims, SURVIVES)); - - const later = await afterwards(); - const why = await later.reads.why({ subject: report.replacement }); - if (why.kind !== "analysis") throw new Error(`expected an analysis, got ${why.kind}`); - // What did fall is the other conclusion, and this act did not answer it. - expect(why.report.unpaired.map((u) => u.claim)).toEqual([claimOf(v1Claims, REVISITED)]); - }); }); diff --git a/tests/scenarios/s12_reinterpret_claim.test.ts b/tests/scenarios/s12_reinterpret_claim.test.ts index 0c6a35ed..b5a18f42 100644 --- a/tests/scenarios/s12_reinterpret_claim.test.ts +++ b/tests/scenarios/s12_reinterpret_claim.test.ts @@ -1,15 +1,13 @@ /** - * S-12 — "The numbers are right; the sentence about them is wrong." + * S-12 — a claim asserted twice, then challenged: challenged is not withdrawn. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimOf } from "../helpers/claims"; -import { claimNamed } from "../helpers/claims"; -import { ref } from "@labkit/core-domain/report"; import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; +import { as } from "../helpers/conversation"; let scenario: Scenario; let session: ResearchSession; @@ -34,7 +32,6 @@ afterEach(async () => { }); const PREFERENTIAL = "the encoding preferentially preserves discriminative signal"; -const NARROWER = "discriminative signal attenuates less than non-discriminative signal"; /** * One proposition, asserted twice from two independent runs. @@ -89,220 +86,7 @@ async function assertedTwice() { }; } -describe("S-12 — the numbers are right; the sentence about them is wrong", () => { - test("the conversation runs end to end through research verbs alone", async () => { - const programme = await assertedTwice(); - - // Reviewer: these numbers don't support the sentence you've written. - // Researcher: are the calculations wrong? - // Reviewer: no. The interpretation is backwards -- both signal types - // attenuate, and the discriminative one attenuates more. - const report = await session.writes.reinterpret({ - of: claimOf(programme.firstClaims, PREFERENTIAL), - as: NARROWER, - because: "both types attenuate; the ratio is a difference in degree, not preservation", - }); - - // LabKit: evidence stands; the claim is superseded by a narrower - // interpretation. - expect(report.nowClaims.asserts).toBe(NARROWER); - // Both records that asserted the old reading, by handle. The report said - // the sentence and nothing else, so a caller could not name either claim - // this withdrew -- and a single handle here would have picked between two - // records arbitrarily. - expect(report.previously.map((c) => c.asserts)).toEqual([PREFERENTIAL, PREFERENTIAL]); - expect(report.previously.map((c) => c.claim).sort()).toEqual( - [ - claimOf(programme.firstClaims, PREFERENTIAL), - claimOf(programme.secondClaims, PREFERENTIAL), - ].sort(), - ); - expect(report.requiresRecomputation).toBe(false); - expect(report.evidenceStanding.map((f) => f.states).sort()).toEqual([ - "discriminative amplitude ratio 0.79, non-discriminative 0.41", - "discriminative amplitude ratio 0.81, non-discriminative 0.44", - ]); - void programme; - - await captureConversation( - { - id: "S-12", - title: "The numbers are right; the sentence about them is wrong", - about: - "Two cohorts reach the same conclusion, and the wording of that conclusion overstates what the measurements show. The sentence is narrowed; every finding underneath it still stands.", - }, - events, - ); - }); - - /** - * Afterward 1 — what does the record claim now, and what did it claim before? - */ - test("the withdrawn interpretation stops standing, in full", async () => { - const programme = await assertedTwice(); - const beforehand = await session.reads.whySupported({ - claim: claimOf(programme.firstClaims, PREFERENTIAL), - }); - expect(beforehand.verdict).toBe("supported"); - expect(beforehand.support).toHaveLength(2); - - const narrowing = await session.writes.reinterpret({ - of: claimOf(programme.firstClaims, PREFERENTIAL), - as: NARROWER, - because: "both types attenuate", - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const withdrawn = await later.reads.whySupported({ - claim: claimOf(programme.firstClaims, PREFERENTIAL), - }); - expect(withdrawn.verdict).toBe("withdrawn"); - - // Withdrawn is its own state. Nobody asserts the sentence any more, and - // that is not the same as evidence bearing against it -- no measurement - // contradicted anything here, the reading of it changed. - expect(withdrawn.withdrawn).toBe(true); - expect(withdrawn.challenged).toBe(false); - // The same record the verb said it minted, not merely something worded - // like it. Matching on the sentence would not have noticed either way. - expect(withdrawn.replacedBy?.claim).toEqual(narrowing.nowClaims.claim); - expect(withdrawn.replacedBy?.asserts).toBe(NARROWER); - // Its findings are still there, and still say what they said, read as history on the - // interpretation they no longer support. - expect(withdrawn.superseded).toHaveLength(2); - expect(withdrawn.superseded.map((s) => s.reason)).toEqual([ - "both types attenuate", - "both types attenuate", - ]); - - // Still readable, and readable as history rather than as something that - // never happened. - // Asked with the handle the verb returned -- no round trip back through - // the wording to re-find the record this very call created. - const history = await later.reads.interpretationHistory({ claim: narrowing.nowClaims.claim }); - expect(history.originally.map((c) => c.asserts)).toEqual([PREFERENTIAL, PREFERENTIAL]); - expect(history.nowClaims.asserts).toBe(NARROWER); - expect(history.revisions).toHaveLength(1); - expect(history.revisions[0]!.reason).toContain("both types attenuate"); - }); - - /** - * Afterward 2 — which evidence remains valid, and does this require recomputation? - */ - test("every finding survives, and nothing was invalidated", async () => { - const programme = await assertedTwice(); - await session.writes.reinterpret({ - of: claimOf(programme.firstClaims, PREFERENTIAL), - as: NARROWER, - because: "both types attenuate", - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const now = await later.reads.whySupported({ claim: await claimNamed(later.reads, NARROWER) }); - expect(now.verdict).toBe("supported"); - expect(now.support.map((s) => s.finding).sort()).toEqual([ - "discriminative amplitude ratio 0.79, non-discriminative 0.41", - "discriminative amplitude ratio 0.81, non-discriminative 0.44", - ]); - - // The observations underneath are untouched -- this is what separates a - // reinterpretation from a replacement, where the output is invalidated - // and the findings become historical. - expect(now.restingOn.map((a) => a.name).sort()).toEqual([ - "attenuation readings, cohort A", - "attenuation readings, cohort B", - ]); - expect(now.superseded).toEqual([]); - - // The withdrawn interpretation keeps its findings as history: nothing about them changed, - // the claim they bore on was replaced. - const withdrawn = await later.reads.whySupported({ - claim: claimOf(programme.firstClaims, PREFERENTIAL), - }); - expect(withdrawn.superseded.map((s) => s.finding).sort()).toEqual( - now.support.map((s) => s.finding).sort(), - ); - }); - - /** - * Afterward 3 — does anything downstream of the original claim need revisiting? - */ - test("a question closed on the old interpretation is surfaced as resting on it", async () => { - const programme = await assertedTwice(); - await session.writes.closeEnquiry({ - enquiry: programme.enquiry, - answeredBy: claimOf(programme.firstClaims, PREFERENTIAL), - }); - - const report = await session.writes.reinterpret({ - of: claimOf(programme.firstClaims, PREFERENTIAL), - as: NARROWER, - because: "both types attenuate", - }); - - expect(report.restingOnTheOldReading.map((q) => q.asks)).toEqual([ - "does the encoding preferentially preserve discriminative signal?", - ]); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const history = await later.reads.interpretationHistory({ - claim: await claimNamed(later.reads, NARROWER), - }); - expect(history.revisions[0]!.restingOnTheOldReading.map((q) => q.asks)).toEqual([ - "does the encoding preferentially preserve discriminative signal?", - ]); - }); - - /** - * Afterward 4 — a second narrowing, ordered against the first, asked after - * both happened, from a session with an empty event log and no timestamp - * on anything. - */ - test("successive reinterpretations are ordered without timestamps or an event log", async () => { - const programme = await assertedTwice(); - const EVEN_NARROWER = "discriminative signal attenuates less in cohort A only"; - - await session.writes.reinterpret({ - of: claimOf(programme.firstClaims, PREFERENTIAL), - as: NARROWER, - because: "both types attenuate", - }); - await session.writes.reinterpret({ - of: await claimNamed(session.reads, NARROWER), - as: EVEN_NARROWER, - because: "the cohort B ratio does not separate", - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - expect(await later.events.all()).toHaveLength(0); - - const history = await later.reads.interpretationHistory({ - claim: await claimNamed(later.reads, EVEN_NARROWER), - }); - expect(history.originally.map((c) => c.asserts)).toEqual([PREFERENTIAL, PREFERENTIAL]); - expect(history.nowClaims.asserts).toBe(EVEN_NARROWER); - expect(history.revisions.map((r) => r.nowClaims.asserts)).toEqual([NARROWER, EVEN_NARROWER]); - // Plural per step, and the counts differ: the first reinterpretation - // withdrew the two claims that had reached the same reading, the second - // withdrew the one narrower claim that replaced them. - expect(history.revisions.map((r) => r.previously.map((c) => c.asserts))).toEqual([ - [PREFERENTIAL, PREFERENTIAL], - [NARROWER], - ]); - }); - +describe("S-12 — challenged is not withdrawn", () => { /** * A claim can be challenged without its source evidence becoming invalid. */ @@ -344,28 +128,4 @@ describe("S-12 — the numbers are right; the sentence about them is wrong", () expect(standing.support).toHaveLength(2); expect(standing.superseded).toEqual([]); }); - - /** Reinterpreting something nobody claimed writes nothing. */ - test("reinterpreting a proposition that is not on the record writes nothing", async () => { - const programme = await assertedTwice(); - const before = await session.reads.whySupported({ - claim: claimOf(programme.firstClaims, PREFERENTIAL), - }); - - await expect( - session.writes.reinterpret({ - of: ref("claim", "CLM_9999"), - as: "some narrower version of it", - because: "it should not get this far", - }), - ).rejects.toThrow(/CLM_9999 not found/); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - expect( - await later.reads.whySupported({ claim: claimOf(programme.firstClaims, PREFERENTIAL) }), - ).toEqual(before); - }); }); diff --git a/tests/scenarios/s12b_two_chains_one_wording.test.ts b/tests/scenarios/s12b_two_chains_one_wording.test.ts deleted file mode 100644 index 0076ca62..00000000 --- a/tests/scenarios/s12b_two_chains_one_wording.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * S-12b — "Two revision chains that meet at a sentence." External review of PR #2, - * discriminator 4. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -/** The same graph, spoken to by the person who reviews rather than the one who ran it. */ -let reviewer: ResearchSession; -let events: EventSink; - -const clock: Clock = { now: () => "2026-08-24T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - const graph = await scenario.begin(); - events = inMemoryEventLog(); - session = new ResearchSession(graph, { clock, events, attribution: as("Researcher") }); - reviewer = new ResearchSession(graph, { clock, events, attribution: as("Reviewer") }); -}); -afterEach(async () => { - await scenario.end(); -}); - -/** The sentence both chains pass through. Two claims, two enquiries, one wording. */ -const SHARED = "the effect holds under condition X"; - -const A1 = "the effect holds"; -const A3 = "the effect holds under condition X in subgroup Y"; -const B1 = "the instrument drifts"; -const B3 = "the instrument drifts above 40 degrees"; - -/** One enquiry, two readings that turn out to be the same, then narrowed once more. */ -const LEFT = "the input queue fills"; -const RIGHT = "the writer holds the lock"; -const MET = "the sampler stalls at the batch boundary"; -const NARROWER = "the sampler stalls at the batch boundary above eight workers"; - -/** - * Two chains, three claims each, meeting only at their middle wording. - */ -async function twoChains() { - const chain = async (opens: string, first: string, middle: string, last: string) => { - const { enquiry } = await session.writes.openEnquiry(opens); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: `${opens} readings`, - finding: "measured", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [{ proposition: first, finding: `${first}, on the fit` }], - }); - const narrowed = await session.writes.reinterpret({ - of: claimOf(claims, first), - as: middle, - because: "the fit only covers condition X", - }); - const narrower = await session.writes.reinterpret({ - of: narrowed.nowClaims.claim, - as: last, - because: "and only in that subgroup", - }); - return { - enquiry, - first: claimOf(claims, first), - middle: narrowed.nowClaims, - last: narrower.nowClaims, - }; - }; - - const a = await chain("does the effect hold?", A1, SHARED, A3); - const b = await chain("does the instrument drift?", B1, SHARED, B3); - return { a, b }; -} - -describe("S-12b — two revision chains that pass through one sentence", () => { - test("the two middle claims are different records that read alike", async () => { - const { a, b } = await twoChains(); - expect(a.middle.asserts).toBe(SHARED); - expect(b.middle.asserts).toBe(SHARED); - expect(a.middle.claim).not.toBe(b.middle.claim); - - await captureConversation( - { - id: "S-12b", - title: "Two revision chains that pass through one sentence", - about: - "Two unrelated lines of enquiry are each narrowed until they read as the same sentence, and the record keeps them apart as two separate claims.", - }, - events, - ); - }); - - test("each history reads back its own chain, and none of the other's", async () => { - const { a, b } = await twoChains(); - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - - const historyA = await later.reads.interpretationHistory({ claim: a.last.claim }); - expect(historyA.revisions.map((r) => r.nowClaims.asserts)).toEqual([SHARED, A3]); - expect(historyA.originally.map((c) => c.claim)).toEqual([a.first]); - // The step through the shared wording is A's record, not B's. - expect(historyA.revisions[1]!.previously.map((c) => c.claim)).toEqual([a.middle.claim]); - - const historyB = await later.reads.interpretationHistory({ claim: b.last.claim }); - expect(historyB.revisions.map((r) => r.nowClaims.asserts)).toEqual([SHARED, B3]); - expect(historyB.originally.map((c) => c.claim)).toEqual([b.first]); - expect(historyB.revisions[1]!.previously.map((c) => c.claim)).toEqual([b.middle.claim]); - }); -}); - -/** - * Two readings inside **one** enquiry that were separately narrowed to the same sentence, and - * then narrowed again together. - */ -async function twoBranchesThatMeet() { - const { enquiry } = await session.writes.openEnquiry("why does the sampler stall?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "stall traces", - finding: "measured", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [ - { proposition: LEFT, finding: `${LEFT}, on the fit` }, - { proposition: RIGHT, finding: `${RIGHT}, on the fit` }, - ], - }); - const viaLeft = await session.writes.reinterpret({ - of: claimOf(claims, LEFT), - as: MET, - because: "the queue only fills at the boundary", - }); - const viaRight = await session.writes.reinterpret({ - of: claimOf(claims, RIGHT), - as: MET, - because: "the lock is only held at the boundary", - }); - const after = await session.writes.reinterpret({ - of: viaLeft.nowClaims.claim, - as: NARROWER, - because: "and only above eight workers", - }); - return { - left: claimOf(claims, LEFT), - right: claimOf(claims, RIGHT), - viaLeft: viaLeft.nowClaims, - viaRight: viaRight.nowClaims, - after: after.nowClaims, - }; -} - -describe("S-12b — a history that merges", () => { - /** - * **Researcher:** Two separate readings turned out to be the same thing, and I narrowed that - * once more. Show me how I got here. - */ - test("a merge is answered with both branches, not refused", async () => { - const { left, right, viaLeft, viaRight, after } = await twoBranchesThatMeet(); - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - - const history = await later.reads.interpretationHistory({ claim: after.claim }); - - // The last act withdrew both branches at once, which is what makes this a - // merge rather than two histories. - const joining = history.revisions.find((r) => r.nowClaims.claim === after.claim)!; - expect(joining.previously.map((c) => c.claim).sort()).toEqual( - [viaLeft.claim, viaRight.claim].sort(), - ); - - // Both first readings are where this started. Reporting one would name a - // branch the record does not rank. - expect(history.originally.map((c) => c.claim).sort()).toEqual([left, right].sort()); - expect(history.revisions).toHaveLength(3); - }); - - /** - * The same shape one act shorter, which does **not** throw today and answers - * wrong: one branch is a claim nobody narrowed, so the walk finds no decision - * for it and drops it out of `originally` without saying so. - */ - test("a branch that was never narrowed is still where the reading started", async () => { - const { enquiry } = await session.writes.openEnquiry("why does the sampler stall?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "stall traces", - finding: "measured", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [ - { proposition: LEFT, finding: `${LEFT}, on the fit` }, - { proposition: MET, finding: `${MET}, read straight off the fit` }, - ], - }); - const narrowed = await session.writes.reinterpret({ - of: claimOf(claims, LEFT), - as: MET, - because: "the queue only fills at the boundary", - }); - const after = await session.writes.reinterpret({ - of: narrowed.nowClaims.claim, - as: NARROWER, - because: "and only above eight workers", - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const history = await later.reads.interpretationHistory({ claim: after.nowClaims.claim }); - - // Two readings were withdrawn together: the one this chain narrowed to, and - // one an analysis concluded outright. The second was never narrowed, so no - // decision leads back from it -- and it is still a reading this history - // started from. - expect(history.originally.map((c) => c.claim).sort()).toEqual( - [claimOf(claims, LEFT), claimOf(claims, MET)].sort(), - ); - }); - - /** - * The degenerate case the merge rule has to keep right: a claim nobody - * narrowed started from nothing, because nothing was withdrawn to reach it. - */ - test("a claim nobody narrowed has no revisions and nothing behind it", async () => { - const { enquiry } = await session.writes.openEnquiry("why does the sampler stall?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "stall traces", - finding: "measured", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [{ proposition: LEFT, finding: `${LEFT}, on the fit` }], - }); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const history = await later.reads.interpretationHistory({ claim: claimOf(claims, LEFT) }); - expect(history.revisions).toEqual([]); - expect(history.originally).toEqual([]); - expect(history.nowClaims.claim).toBe(claimOf(claims, LEFT)); - }); -}); - -/** One reading, narrowed; then the same sentence concluded afresh. */ -const ONCE = "the sampler stalls"; -const NARROWED_ONCE = "the sampler stalls at the batch boundary under load"; -const NARROWED_AGAIN = "the sampler stalls at the batch boundary above eight workers"; - -describe("S-12b — a reading is narrowed once", () => { - /** - * **Researcher:** I narrowed that reading last week and forgot. What happens if I narrow it - * again? - */ - test("a reading that has already been narrowed is refused, naming what stands instead", async () => { - const { enquiry } = await session.writes.openEnquiry("why does the sampler stall?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "stall traces", - finding: "measured", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [{ proposition: ONCE, finding: `${ONCE}, on the fit` }], - }); - const original = claimOf(claims, ONCE); - const narrowed = await session.writes.reinterpret({ - of: original, - as: NARROWED_ONCE, - because: "the fit only covers the boundary", - }); - - await expect( - session.writes.reinterpret({ - of: original, - as: NARROWED_AGAIN, - because: "and only above eight workers", - }), - ).rejects.toThrow(new RegExp(`no longer stands[\\s\\S]*${narrowed.nowClaims.claim}`)); - - // Nothing was written: the reading still has exactly one successor. - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const history = await later.reads.interpretationHistory({ claim: narrowed.nowClaims.claim }); - expect(history.revisions).toHaveLength(1); - expect(history.originally.map((c) => c.claim)).toEqual([original]); - }); - - /** - * A finding can stop standing without its reading ever being narrowed: replacing the analysis - * supersedes the claim instead. Both acts leave a reading nobody should narrow, and AGE has - * no edge alternation, so the two predicates are two clauses and reading one is silent. - */ - test("a reading whose finding was superseded is refused too, not only a narrowed one", async () => { - const { enquiry } = await session.writes.openEnquiry("why does the sampler stall?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "stall traces", - finding: "measured", - }); - const { analysis, claims } = await recordAnalysis(session.writes, { - enquiry, - method: "fit", - from: [observations], - concludes: [{ proposition: ONCE, finding: `${ONCE}, on the fit` }], - }); - const { review } = await reviewer.writes.recordReview({ - of: analysis, - verdict: "the fit was taken over the wrong window", - }); - const replacement = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, - enquiry, - method: "refit over the right window", - from: [observations], - concludes: [{ proposition: ONCE, finding: `${ONCE}, on the refit` }], - }); - - await expect( - session.writes.reinterpret({ - of: claimOf(claims, ONCE), - as: NARROWED_ONCE, - because: "narrowing a finding that no longer stands", - }), - // The successor is named: `conclude` recorded the replacement standing - // in place of the finding it re-answered, so the refusal can say where - // to go instead of only that the claim fell. - ).rejects.toThrow(new RegExp(`no longer stands[\\s\\S]*${claimOf(replacement.claims, ONCE)}`)); - }); -}); diff --git a/tests/scenarios/s17_unevaluated_gate.test.ts b/tests/scenarios/s17_unevaluated_gate.test.ts index 021ba217..39cfd8f9 100644 --- a/tests/scenarios/s17_unevaluated_gate.test.ts +++ b/tests/scenarios/s17_unevaluated_gate.test.ts @@ -257,10 +257,10 @@ describe("S-17: does the guard actually guard?", () => { test("Afterward 2, restated: which criterion governs this gate?", async () => { const { gate, criterion } = await aDeclaredButUnevaluatedGate(); - const governing = await session.reads.criteriaGoverning({ gate }); - expect(governing.map((c) => c)).toEqual([criterion]); + const governing = await session.reads.gateStatus({ gate }); + expect(governing.checks.map((c) => c.criterion)).toEqual([criterion]); - const durable = await (await afterwards()).reads.criteriaGoverning({ gate }); - expect(durable.map((c) => c)).toEqual([criterion]); + const durable = await (await afterwards()).reads.gateStatus({ gate }); + expect(durable.checks.map((c) => c.criterion)).toEqual([criterion]); }); }); diff --git a/tests/scenarios/s18b_a_promoted_negative_result.test.ts b/tests/scenarios/s18b_a_promoted_negative_result.test.ts index f247d9e6..9879fb0f 100644 --- a/tests/scenarios/s18b_a_promoted_negative_result.test.ts +++ b/tests/scenarios/s18b_a_promoted_negative_result.test.ts @@ -100,20 +100,6 @@ describe("S-18b — a negative result that somebody vouched for", () => { expect(known.provisional.map((q) => q.asks)).not.toContain(ASKS); }); - test("and the historical survey agrees with the current one", async () => { - await aVouchedForNo(); - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - - // Same SUPPORTS-only shape, one query over. Asked at an instant after the - // promotion and the closure. - const then = await later.reads.whatWasKnown({ at: NOW }); - expect(then.established.map((q) => q.asks)).toContain(ASKS); - expect(then.provisional.map((q) => q.asks)).not.toContain(ASKS); - }); - /** * The control. A negative result nobody promoted must still read as scratch, * or the fix above would have made every closure look vouched-for. diff --git a/tests/scenarios/s1_hunch_not_yet_experiment.test.ts b/tests/scenarios/s1_hunch_not_yet_experiment.test.ts index 7a09f04d..dacd8b45 100644 --- a/tests/scenarios/s1_hunch_not_yet_experiment.test.ts +++ b/tests/scenarios/s1_hunch_not_yet_experiment.test.ts @@ -3,16 +3,9 @@ */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { - ResearchSession, - inMemoryEventLog, - type Clock, - type EventSink, - type QuestionRef, -} from "@labkit/core-domain"; +import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf } from "../helpers/claims"; -import { ref } from "@labkit/core-domain/report"; import { recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; @@ -65,8 +58,7 @@ async function priorState() { // Prespecified, and separately vouched for below. Both, because they are // two facts: `standing` says the design was locked before the run, and the // `is confirmed` after this says somebody stands behind the result. - // `established` is the second — a promotion is an act, with a date, which - // is why `known --at` can answer it and cannot answer `standing`. + // `established` is the second — a promotion is an act, with a date. concludes: [ { proposition: NONLINEAR, @@ -139,10 +131,13 @@ describe("S-1 — a hunch that is not yet an experiment", () => { // Researcher: fine. Let's pursue whether different inputs map to // reproducibly different internal responses. - const { question: sharper } = await session.writes.sharpen({ - from: hunch, - into: "do different inputs map to reproducibly different internal responses?", - because: "the vague form is not testable; this one names what would count as an answer", + const { note: why } = await session.writes.note({ + text: "the vague form is not testable; this one names what would count as an answer", + on: hunch, + }); + const { question: sharper } = await session.writes.pose({ + question: "do different inputs map to reproducibly different internal responses?", + from: why, }); expect(sharper).not.toBe(hunch); @@ -215,143 +210,6 @@ describe("S-1 — a hunch that is not yet an experiment", () => { expect(known.unresolved.map((q) => q.question)).toContain(prior.smear); }); - /** - * Afterward 2 — where did the current sharper question come from? - */ - test("the sharper question is traceable to the hunch, which is neither rewritten nor closed", async () => { - const { question: hunch } = await session.writes.pose({ - question: "is the learned topology doing something computationally interesting?", - }); - const { question: sharper } = await session.writes.sharpen({ - from: hunch, - into: "do different inputs map to reproducibly different internal responses?", - because: "the vague form is not testable", - }); - - const origin = await session.reads.originOf({ question: sharper }); - expect(origin?.from).toBe(hunch); - expect(origin?.reason).toContain("not testable"); - - // From a second reader: the original still asks what it originally asked. - const later = new ResearchSession(await scenario.current(), { clock }); - const durable = await later.reads.originOf({ question: sharper }); - expect(durable?.from).toBe(hunch); - expect(durable?.said).toBe( - "is the learned topology doing something computationally interesting?", - ); - - // Narrowing is not answering. Nothing has been shown about the hunch, so - // it is still on the books untested -- not established, and not a failure. - const known = await later.reads.whatIsKnown(); - expect(known.established.map((q) => q.question)).not.toContain(hunch); - expect(known.untested.map((q) => q.question)).toContain(hunch); - expect(known.untested.map((q) => q.asks)).toContain( - "is the learned topology doing something computationally interesting?", - ); - }); - - /** - * Afterward 3 — what was the state of knowledge at the moment this question was sharpened, - * asked after later evidence has arrived? - */ - test("the knowledge behind a sharpening is the knowledge that existed then", async () => { - const prior = await priorState(); - const { question: hunch } = await session.writes.pose({ - question: "is the learned topology doing something computationally interesting?", - }); - - const { question: first } = await session.writes.sharpen({ - from: hunch, - into: "do different inputs map to reproducibly different internal responses?", - because: "the vague form is not testable", - }); - - // Later evidence arrives on the smear question -- after the first - // sharpening, before the second. - const { observations: lateObs } = await session.writes.recordObservations({ - enquiry: prior.smearEnquiry, - name: "seed-controlled response maps", - finding: "response maps recorded with initial conditions held fixed", - }); - await recordAnalysis(session.writes, { - enquiry: prior.smearEnquiry, - method: "seed-controlled-inspection", - from: [lateObs], - concludes: [ - { - proposition: SMEAR, - finding: "family separation survives when initial conditions are held fixed", - }, - ], - }); - - const { question: second } = await session.writes.sharpen({ - from: hunch, - into: "does the same input map to the same internal response across seeds?", - because: "reproducibility is now the part in doubt", - }); - - const behindFirst = await session.reads.originOf({ question: first }); - const behindSecond = await session.reads.originOf({ question: second }); - - // The finding that arrived after the first sharpening must not appear - // behind it, and must appear behind the second. - const LATE = "family separation survives when initial conditions are held fixed"; - expect(behindSecond?.knownAtTheTime.map((f) => f.states)).toContain(LATE); - expect(behindFirst?.knownAtTheTime).not.toContain(LATE); - - // ...and the two sharpenings are told apart at all. - expect(behindFirst?.knownAtTheTime).not.toEqual(behindSecond?.knownAtTheTime ?? []); - - // Afterward, from a second reader with an event log of its own -- which is - // empty. The historical answer is reconstructed from durable scientific - // state, not replayed from the stream of what this session happened to do. - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - expect(await later.events.all()).toHaveLength(0); - expect((await later.reads.originOf({ question: first }))?.knownAtTheTime).not.toContain(LATE); - expect( - (await later.reads.originOf({ question: second }))?.knownAtTheTime.map((f) => f.states), - ).toContain(LATE); - }); - - /** - * Sharpening validates before it writes anything. - */ - test("sharpening a question that is not on the record writes nothing", async () => { - await priorState(); - const before = await session.reads.whatIsKnown(); - - const absent: QuestionRef = ref("question", "Q_404"); - - // The message is the assertion. Sharpening a missing question would fail - // either way -- but failing on the *second* write, when the narrowing edge - // finds no endpoint, means a decision was already on the record. Only the - // up-front guard produces this wording, so a rejection that stops saying - // it is a rejection that started writing first. - await expect( - session.writes.sharpen({ - from: absent, - into: "a sharper form of nothing", - because: "it should not get this far", - }), - ).rejects.toThrow(/Q_404 not found/); - - const later = new ResearchSession(await scenario.current(), { clock }); - const after = await later.reads.whatIsKnown(); - const census = (k: Awaited>) => - [...k.established, ...k.unresolved, ...k.untested].map((q) => q.question).sort(); - expect(census(after)).toEqual(census(before)); - - // Nothing on the record cites the sharpening that never happened. - for (const question of census(after)) { - const origin = await later.reads.originOf({ question: ref("question", question) }); - expect(origin?.reason).not.toBe("it should not get this far"); - } - }); - /** * Afterward 4 — one question, pursued more than one way. */ @@ -371,8 +229,8 @@ describe("S-1 — a hunch that is not yet an experiment", () => { expect(byMapping).not.toBe(byProbe); const later = new ResearchSession(await scenario.current(), { clock }); - const pursuits = await later.reads.pursuitsOf({ question }); - expect(pursuits.map((p) => p).sort()).toEqual([byMapping, byProbe].sort()); + const pursuits = (await later.reads.enquiryList()).filter((e) => e.question === question); + expect(pursuits.map((p) => p.enquiry).sort()).toEqual([byMapping, byProbe].sort()); // One question on the books, not two. const known = await later.reads.whatIsKnown(); diff --git a/tests/scenarios/s20_a_finding_that_settles_nothing.test.ts b/tests/scenarios/s20_a_finding_that_settles_nothing.test.ts deleted file mode 100644 index 07dcb435..00000000 --- a/tests/scenarios/s20_a_finding_that_settles_nothing.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * S-20 — "The re-analysis narrowed it and did not close it." - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; - -const clock: Clock = { now: () => "2026-09-02T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -const REWIRING = "T differs from the rewiring control"; - -/** - * The transcriber's own sentence, from `scripts/db/probe-bonsai-1a.sh`, kept - * verbatim: it is the record of what the researcher said, and paraphrasing it - * would make this scenario about wording this repo chose. - */ -const INCONCLUSIVE = - "NOT resolved: primary (p=0.037) and sign-flip (p=0.041) still say significant, " + - "median (p=0.084) still says not -- narrowed from v1 but not closed; per pre-commitment, " + - "no further transformation attempted, reported as genuinely inconclusive at n=10/25 seeds."; - -/** - * Researcher: "Two of three tests still disagree, and the pre-commitment says - * stop. I am not going to call it either way." - */ -async function aReVerificationThatSettledNothing() { - const { enquiry, question } = await session.writes.openEnquiry( - "does T differ from the rewiring control?", - ); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "per-image results", - finding: "T and the rewiring control, ten seeds", - }); - const { analysis, claims } = await recordAnalysis(session.writes, { - enquiry, - method: "log-scale re-aggregation", - from: [observations], - concludes: [{ proposition: REWIRING, finding: INCONCLUSIVE }], - }); - // The conclusion itself, not just its handle: `is` names the finding that - // put the claim in this state, and that handle is on the conclusion. - const concluded = claims.find((c) => c.asserts === REWIRING); - if (concluded?.finding === undefined) throw new Error("the analysis concluded nothing here"); - return { - enquiry, - question, - observations, - analysis, - claim: concluded.claim, - finding: concluded.finding, - }; -} - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog() }); -} - -describe("S-20 — a finding that settles the proposition neither way", () => { - test("the claim reads as neither supported nor challenged, and keeps its finding", async () => { - const w = await aReVerificationThatSettledNothing(); - - await session.writes.isUndecided({ claim: w.claim, because: w.finding }); - - const why = await (await afterwards()).reads.whySupported({ claim: w.claim }); - - // The whole of #139: today this reads a `supported` verdict, because - // `conclude` defaults the bearing to supports and nothing can say - // otherwise. - expect(why.verdict).toBe("undecided"); - expect(why.standing).toBe("undecided"); - - // Not supported, and the finding is still there — a blanked report would - // say the analysis produced nothing, which is the opposite of what - // happened. - expect(why.support.map((s) => s.finding)).toEqual([INCONCLUSIVE]); - - // The same answer through `why`, which is what an agent is handed over - // MCP. It had no undecided arm and said "nothing has examined it" of a - // claim carrying a finding. - const explained = await (await afterwards()).reads.why({ subject: w.claim }); - expect(explained.is).not.toMatch(/nothing has examined/); - - await captureConversation( - { - id: "S-20", - title: "a finding that settles the proposition neither way", - about: - "A re-analysis narrows the disagreement between three tests without resolving it, and the researcher records the claim as undecided rather than calling it either way.", - }, - events, - explained, - ); - }); - - test("the question is not counted as answered by a finding that settles nothing", async () => { - const w = await aReVerificationThatSettledNothing(); - await session.writes.isUndecided({ claim: w.claim, because: w.finding }); - - const survey = await (await afterwards()).reads.whatIsKnown(); - - // `unresolved` already means "worked on, not settled", which is exactly - // this. The failure to avoid is `established` or `provisional`: both say - // the question has an answer. - expect(survey.unresolved.map((q: { question: unknown }) => q.question)).toContain(w.question); - expect(survey.established.map((q: { question: unknown }) => q.question)).not.toContain( - w.question, - ); - expect(survey.provisional.map((q: { question: unknown }) => q.question)).not.toContain( - w.question, - ); - // Something was run against it, so it is not untested either — the - // distinction the survey's own doc comment insists on. - expect(survey.untested.map((q: { question: unknown }) => q.question)).not.toContain(w.question); - }); -}); diff --git a/tests/scenarios/s21_a_finding_drawn_across_findings.test.ts b/tests/scenarios/s21_a_finding_drawn_across_findings.test.ts index 3f8c07c5..b8699ff6 100644 --- a/tests/scenarios/s21_a_finding_drawn_across_findings.test.ts +++ b/tests/scenarios/s21_a_finding_drawn_across_findings.test.ts @@ -184,56 +184,6 @@ describe("S-21: a finding drawn across findings", () => { session.writes.synthesise({ proposition: HEADLINE, restingOn: [] }), ).rejects.toThrow(/at least one finding to rest on/); }); - test("reinterpret narrows exactly the named synthesis and preserves its parts", async () => { - const { claims } = await fourComparisons(); - const restingOn = [claims[0]!, claims[1]!, claims[0]!]; - const { claim: synthesis } = await session.writes.synthesise({ - proposition: HEADLINE, - restingOn, - }); - - const report = await session.writes.reinterpret({ - of: synthesis, - as: "T shows no advantage in the measured controls", - because: "the headline overstates what the comparisons establish", - }); - const edges = report.events[0]!.changes.filter( - (change): change is import("@labkit/core-domain").EdgeCreated => - change.change === "EdgeCreated", - ); - const expectedParts = [...new Set(restingOn)].sort(); - - expect(report.previously).toEqual([{ claim: synthesis, asserts: HEADLINE }]); - expect(edges.filter((edge) => edge.label === "SUPERSEDES").map((edge) => edge.to)).toEqual([ - synthesis, - ]); - expect(edges.filter((edge) => edge.label === "MOTIVATES").map((edge) => edge.to)).toEqual([ - report.nowClaims.claim, - ]); - expect( - edges - .filter((edge) => edge.label === "BASED_ON") - .map((edge) => edge.to) - .sort(), - ).toEqual(expectedParts); - expect( - edges.filter((edge) => edge.label === "SUPPORTS" || edge.label === "CHALLENGES"), - ).toEqual([]); - - const narrowed = await (await afterwards()).reads.whySupported({ - claim: report.nowClaims.claim, - }); - expect(narrowed.drawnAcross.map((part) => part.claim).sort()).toEqual(expectedParts); - expect(narrowed.support).toEqual([]); - - await expect( - session.writes.reinterpret({ - of: synthesis, - as: "T has no measured advantage", - because: "trying to reinterpret the superseded synthesis", - }), - ).rejects.toThrow(new RegExp(`no longer stands.*${report.nowClaims.claim}`, "s")); - }); test("accepting a synthesis keeps its identity and cites every component finding", async () => { const { enquiry } = await session.writes.openEnquiry("does the measured effect hold?"); @@ -334,9 +284,12 @@ describe("S-21: a finding drawn across findings", () => { citing: synthesis, }); expect(amendment.nature).toBe("mechanical"); - const history = await (await afterwards()).reads.designHistory({ gate }); - expect( - history.conditions[0]!.amendments[0]!.citing.map((finding) => finding.states).sort(), - ).toEqual(expectedFindings); + const why = await (await afterwards()).reads.why({ subject: amendment.amendment }); + const cited = why.because + .map((cause) => cause.wording) + .filter((wording) => wording.startsWith("rests on ")) + .map((wording) => wording.slice("rests on ".length)) + .sort(); + expect(cited).toEqual(expectedFindings); }); }); diff --git a/tests/scenarios/s24_a_mistaken_act_taken_back.test.ts b/tests/scenarios/s24_a_mistaken_act_taken_back.test.ts deleted file mode 100644 index 620f584e..00000000 --- a/tests/scenarios/s24_a_mistaken_act_taken_back.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * S-24 — "I typed that wrong. Take it back." - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -/** Named apart from the per-act `events` a write verb returns. */ -let eventLog: EventSink; - -const clock: Clock = { now: () => "2026-09-05T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - eventLog = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events: eventLog, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -describe("S-24 — a mistaken act taken back", () => { - test("names every handle it retracted", async () => { - const wording = "does the pruning schedule move convergence, typed twice by accident"; - const { question, events } = await session.writes.pose({ question: wording }); - const seq = events[0]!.seq!; - - const undone = await session.writes.undo({ - event: seq, - because: "duplicate entry, wrong wording", - }); - expect(undone.event).toBe(seq); - expect(undone.retracted).toContain(question); - - await captureConversation( - { - id: "S-24", - title: "a mistaken act taken back", - about: - "A question entered twice by accident is taken back, and the act that undoes it names every handle it retracted.", - }, - eventLog, - ); - }); - - test("puts back the value an act set in place", async () => { - const { enquiry } = await session.writes.openEnquiry("does depth move convergence?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "depth sweep results", - finding: "depth 4 vs depth 8", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "paired comparison", - from: [observations], - concludes: [{ proposition: "depth 8 converges faster", finding: "moves by ~3 steps" }], - }); - const claim = claims[0]!.claim; - const before = (await session.reads.whySupported({ claim })).standing; - expect(before).toBe("exploratory"); - - const { events } = await session.writes.isConfirmed({ - claim, - because: "the prespecified check passed", - }); - expect((await session.reads.whySupported({ claim })).standing).toBe("confirmatory"); - - // Undoing the promotion retracts the decision that conferred it. - await session.writes.undo({ event: events[0]!.seq!, because: "promoted the wrong claim" }); - expect((await session.reads.whySupported({ claim })).standing).toBe(before); - }); - - test("refuses to undo an act something else already rests on", async () => { - const { question, events } = await session.writes.pose({ - question: "does pruning depth matter at all?", - }); - const poseSeq = events[0]!.seq!; - - // The enquiry rests on the question via MOTIVATES -- an external node - // reaching into what the pose event created. - await session.writes.pursue({ question, approach: "a depth sweep" }); - - await expect( - session.writes.undo({ event: poseSeq, because: "never mind, wrong question" }), - ).rejects.toThrow(/rests on what it created/); - }); - - test("allows reverse-order undo once the dependent act is retracted", async () => { - const posed = await session.writes.pose({ question: "does pruning depth matter at all?" }); - const pursued = await session.writes.pursue({ - question: posed.question, - approach: "a depth sweep", - }); - - await session.writes.undo({ - event: pursued.events[0]!.seq!, - because: "the pursuit was entered against the wrong question", - }); - const undone = await session.writes.undo({ - event: posed.events[0]!.seq!, - because: "the question was entered by mistake", - }); - - expect(undone.retracted).toContain(posed.question); - }); - - test("work stays waiting when its last gate is retracted", async () => { - const { criterion } = await session.writes.stateCriterion( - "the result clears the release threshold", - ); - const { work } = await session.writes.planWork({ - objective: "publish the result", - acceptance: "the result is published", - }); - const declared = await session.writes.declareGate({ - governedBy: [criterion], - consequence: "the result cannot be published", - protecting: [work], - }); - - await session.writes.undo({ - event: declared.events[0]!.seq!, - because: "the gate was declared against the wrong work", - }); - - expect((await session.reads.workList({})).find((row) => row.work === work)?.state).toBe( - "waiting", - ); - expect((await session.reads.now({})).untouched.map((row) => row.work)).not.toContain(work); - }); - - /** - * The dependants check must distinguish an act's own edges to pre-existing nodes. `conclude` - * writes `unit PRODUCES evidence` and `evidence RECORDED_IN output` to nodes the analysis - * already had; neither edge makes that same act depend on itself. - */ - test("an act's own edges to pre-existing nodes are not dependents of it", async () => { - const { enquiry } = await session.writes.openEnquiry("does depth move convergence?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "depth sweep results", - finding: "depth 4 vs depth 8", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "paired comparison", - from: [observations], - concludes: [{ proposition: "depth 8 converges faster", finding: "moves by ~3 steps" }], - }); - const claim = claims[0]!.claim; - - const all = await session.events.all(); - const concludeSeq = all.find((e) => e.operation === "conclude")!.seq!; - - const undone = await session.writes.undo({ - event: concludeSeq, - because: "this claim was wrong", - }); - expect(undone.retracted).toContain(claim); - }); - - test("refuses once a separate act rests on what conclude produced", async () => { - const { criterion } = await session.writes.stateCriterion("depth 8 converges faster, held up"); - const { enquiry } = await session.writes.openEnquiry("does depth move convergence?"); - const { observations } = await session.writes.recordObservations({ - enquiry, - name: "depth sweep results", - finding: "depth 4 vs depth 8", - }); - const { claims } = await recordAnalysis(session.writes, { - enquiry, - method: "paired comparison", - from: [observations], - heldTo: [criterion], - concludes: [{ proposition: "depth 8 converges faster", finding: "moves by ~3 steps" }], - }); - const claim = claims[0]!.claim; - const all = await session.events.all(); - const concludeSeq = all.find((e) => e.operation === "conclude")!.seq!; - - // A genuinely separate act, resting on the claim's own evidence. - await session.writes.evaluateCriterion({ - criterion, - value: "yes", - outcome: "pass", - citing: [claim], - }); - - await expect( - session.writes.undo({ event: concludeSeq, because: "this claim was wrong after all" }), - ).rejects.toThrow(/rests on what it created/); - }); - - test("refuses a seq nothing on the record has", async () => { - await expect( - session.writes.undo({ event: 999_999, because: "there is nothing at this seq" }), - ).rejects.toThrow(/not found/); - }); -}); diff --git a/tests/scenarios/s24b_walking_back_a_tree_of_mistakes.test.ts b/tests/scenarios/s24b_walking_back_a_tree_of_mistakes.test.ts deleted file mode 100644 index 69ece7f2..00000000 --- a/tests/scenarios/s24b_walking_back_a_tree_of_mistakes.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * S-24b — "I built the whole thing on a mistake. Take all of it back." - * - * One act at a time, newest first. Each `undo` refuses while anything still rests on what - * its act created, so the order is forced by the record rather than remembered by the - * researcher — and the last one leaves nothing behind. - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -/** Named apart from the per-act `events` a write verb returns. */ -let eventLog: EventSink; - -const clock: Clock = { now: () => "2026-09-17T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - eventLog = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events: eventLog, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -/** The whole mistaken programme, with the seq of each act that built it. */ -const buildIt = async () => { - const posed = await session.writes.pose({ question: "does the coating slow corrosion?" }); - const pursued = await session.writes.pursue({ - question: posed.question, - approach: "salt-spray chamber, 200 hours", - }); - const observed = await session.writes.recordObservations({ - enquiry: pursued.enquiry, - name: "salt-spray run", - finding: "mass loss per coupon", - }); - const analysed = await session.writes.recordAnalysis({ - enquiry: pursued.enquiry, - method: "mass-loss comparison", - from: [observed.observations], - }); - const concluded = await session.writes.conclude({ - analysis: analysed.analysis, - proposition: "the coating slows corrosion", - finding: "34% less mass loss", - }); - const claim = concluded.claims[0]!.claim; - const promoted = await session.writes.isConfirmed({ claim, because: "the check passed" }); - return { posed, pursued, observed, analysed, concluded, promoted, claim }; -}; - -describe("S-24b — walking back a tree of mistakes", () => { - test("the record refuses any order but newest-first", async () => { - const built = await buildIt(); - - // The question is the root of everything, so it goes last, not first. - await expect( - session.writes.undo({ event: built.posed.events[0]!.seq!, because: "wrong question" }), - ).rejects.toThrow(/rests on what it created/); - - // The enquiry is likewise still carrying the run. - await expect( - session.writes.undo({ event: built.pursued.events[0]!.seq!, because: "wrong approach" }), - ).rejects.toThrow(/rests on what it created/); - }); - - test("newest-first takes the whole tree back, and the promotion with it", async () => { - const built = await buildIt(); - expect((await session.reads.whySupported({ claim: built.claim })).standing).toBe( - "confirmatory", - ); - - // Newest first. The promotion set a property rather than minting, so taking it - // back is a restore; everything after it retracts what its act created. - const taken: string[][] = []; - for (const events of [ - built.promoted.events, - built.concluded.events, - built.analysed.events, - built.observed.events, - built.pursued.events, - built.posed.events, - ]) { - for (const event of [...events].reverse()) { - const undone = await session.writes.undo({ - event: event.seq!, - because: "the whole arc was a mistake", - }); - taken.push(undone.retracted); - } - } - - // Every act reports what it took back. Whether the retracted nodes then - // vanish from a read is enforced by RLS on `labkit_app`, and these tests do - // not `SET ROLE`, so it cannot be asserted here. - expect(taken.flat()).toContain(built.claim); - expect(taken.flat()).toContain(built.posed.question); - expect(taken.flat()).toContain(built.pursued.enquiry); - expect(taken.flat()).toContain(built.observed.observations); - - await captureConversation( - { - id: "S-24b", - title: "walking back a tree of mistakes", - about: - "A whole line of work built on a mistaken question is taken back one act at a time, newest first, and the promotion goes with it.", - }, - eventLog, - ); - }); - - test("the promotion can be taken back on its own, leaving the claim standing", async () => { - const built = await buildIt(); - await session.writes.undo({ - event: built.promoted.events[0]!.seq!, - because: "promoted before the control ran", - }); - - const why = await session.reads.whySupported({ claim: built.claim }); - expect(why.standing).toBe("exploratory"); - // The claim itself is untouched: only the property the promotion set moved. - expect(why.support.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/scenarios/s26_work_nobody_is_doing.test.ts b/tests/scenarios/s26_work_nobody_is_doing.test.ts index 8189bae8..78640696 100644 --- a/tests/scenarios/s26_work_nobody_is_doing.test.ts +++ b/tests/scenarios/s26_work_nobody_is_doing.test.ts @@ -215,11 +215,5 @@ describe("S-26: work nobody is doing", () => { check.blocks.flatMap((block) => block.gating.map((work) => work.work)), ); expect(afterBlocked).toEqual([active]); - - await session.writes.closeGate({ gate, because: "the sampler is no longer released" }); - const closed = await (await afterwards()).reads.gateStatus({ gate }); - expect(closed.state).toBe("closed"); - expect(closed.gating.map((w) => w.work)).toEqual([active]); - expect(closed.unmet.flatMap((check) => check.blocks)).toEqual([]); }); }); diff --git a/tests/scenarios/s27_why_explains_every_kind.test.ts b/tests/scenarios/s27_why_explains_every_kind.test.ts index 6c72b4e3..46bb4a28 100644 --- a/tests/scenarios/s27_why_explains_every_kind.test.ts +++ b/tests/scenarios/s27_why_explains_every_kind.test.ts @@ -72,10 +72,6 @@ async function anArcOfWork() { on: question, text: "the locked parameters live here", }); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "the method is sound", - }); const { decision } = await session.writes.closeEnquiry({ enquiry, answeredBy: claim }); return { question, @@ -87,7 +83,6 @@ async function anArcOfWork() { work, gate, note, - review, decision, }; } @@ -118,7 +113,7 @@ describe("S-27: why explains every kind", () => { id: "S-27", title: "Why explains every kind", about: - "One ordinary arc of work — a question, observations, an analysis, a check, a gate, a note, a review and a decision — and asking why of each of them gets an answer rather than a refusal.", + "One ordinary arc of work — a question, observations, an analysis, a check, a gate, a note and a decision — and asking why of each of them gets an answer rather than a refusal.", }, events, ); diff --git a/tests/scenarios/s28_a_hunch_became_a_question.test.ts b/tests/scenarios/s28_a_hunch_became_a_question.test.ts index 6f4ae784..f48e226c 100644 --- a/tests/scenarios/s28_a_hunch_became_a_question.test.ts +++ b/tests/scenarios/s28_a_hunch_became_a_question.test.ts @@ -41,16 +41,14 @@ const HUNCH = "something about how the edge is handled matters — I keep seeing const SHARP = "does the edge padding change the reconstruction error?"; describe("S-28: a hunch became a question", () => { - test("Afterward 1: the question says which note it came out of", async () => { + test("Afterward 1: `why` on the question names the note it came out of", async () => { const { note } = await session.writes.note({ text: HUNCH }); const { question } = await session.writes.pose({ question: SHARP, from: note }); - const origin = await (await afterwards()).reads.originOf({ question }); - expect(origin?.kind).toBe("noted"); - expect(origin?.from).toBe(note); // The note's own words, not a restatement: a hunch is worth reading back // exactly as it was written down. - expect(origin?.said).toBe(HUNCH); + const why = await (await afterwards()).reads.why({ subject: question }); + expect(why.because).toEqual([{ handle: note, wording: `was prompted by ${HUNCH}` }]); await captureConversation( { @@ -69,39 +67,14 @@ describe("S-28: a hunch became a question", () => { // The compound act records what the primitive would have. Otherwise the // common case is the one that loses the provenance. - const origin = await (await afterwards()).reads.originOf({ question }); - expect(origin?.kind).toBe("noted"); - expect(origin?.from).toBe(note); - }); - - test("Afterward 3: a sharpened question still reads as sharpened", async () => { - const { question: broad } = await session.writes.pose({ question: "does the edge matter?" }); - const { question: sharp } = await session.writes.sharpen({ - from: broad, - into: SHARP, - because: "which edge, and measured how", - }); - - const origin = await (await afterwards()).reads.originOf({ question: sharp }); - expect(origin?.kind).toBe("sharpened"); - expect(origin?.from).toBe(broad); - expect(origin?.said).toBe("does the edge matter?"); - expect(origin?.reason).toBe("which edge, and measured how"); + const why = await (await afterwards()).reads.why({ subject: question }); + expect(why.because).toContainEqual({ handle: note, wording: `was prompted by ${HUNCH}` }); }); - test("Afterward 4: a question asked outright still has no origin", async () => { + test("Afterward 3: a question asked outright names no origin", async () => { const { question } = await session.writes.pose({ question: SHARP }); - expect(await (await afterwards()).reads.originOf({ question })).toBeNull(); - }); - - test("Afterward 5: `why` on the question names the note that prompted it", async () => { - const { note } = await session.writes.note({ text: HUNCH }); - const { question } = await session.writes.pose({ question: SHARP, from: note }); - - // The generic walk, not a second special-cased read: the edge is one the - // existing reader already renders, and this is the check that it does. const why = await (await afterwards()).reads.why({ subject: question }); - expect(why.because).toEqual([{ handle: note, wording: `was prompted by ${HUNCH}` }]); + expect(why.because).toEqual([]); }); test("posing from a note nobody wrote is refused, and the message says what to do", async () => { diff --git a/tests/scenarios/s29_the_note_came_after_the_question.test.ts b/tests/scenarios/s29_the_note_came_after_the_question.test.ts index 0ec32c01..c3115e90 100644 --- a/tests/scenarios/s29_the_note_came_after_the_question.test.ts +++ b/tests/scenarios/s29_the_note_came_after_the_question.test.ts @@ -47,10 +47,8 @@ describe("S-29: the note came after the question", () => { const { question } = await session.writes.pose({ question: ASKS }); const { note } = await session.writes.note({ text: PROBE, prompted: question }); - const origin = await (await afterwards()).reads.originOf({ question }); - expect(origin?.kind).toBe("noted"); - expect(origin?.from).toBe(note); - expect(origin?.said).toBe(PROBE); + const why = await (await afterwards()).reads.why({ subject: question }); + expect(why.because).toEqual([{ handle: note, wording: `was prompted by ${PROBE}` }]); await captureConversation( { @@ -91,24 +89,6 @@ describe("S-29: the note came after the question", () => { expect(wordings.some((w) => w.includes("has a note on it"))).toBe(true); }); - /** - * A question has one origin. Two would leave a reader with two answers to *why was this - * asked* and nothing saying which holds — the rule `stopWork` and `closeEnquiry` already - * apply to an act that has already happened. - */ - test("a question that was sharpened refuses a second origin, and says what it has", async () => { - const { question: broad } = await session.writes.pose({ question: "does the coating hold?" }); - const { question: sharp } = await session.writes.sharpen({ - from: broad, - into: ASKS, - because: "at temperature is the part nobody measured", - }); - - await expect(session.writes.note({ text: PROBE, prompted: sharp })).rejects.toThrow( - /already came from/, - ); - }); - test("a second note prompting one question is refused", async () => { const { question } = await session.writes.pose({ question: ASKS }); await session.writes.note({ text: PROBE, prompted: question }); diff --git a/tests/scenarios/s30_fixed_before_the_first_run.test.ts b/tests/scenarios/s30_fixed_before_the_first_run.test.ts index 68db407d..615d09f5 100644 --- a/tests/scenarios/s30_fixed_before_the_first_run.test.ts +++ b/tests/scenarios/s30_fixed_before_the_first_run.test.ts @@ -73,11 +73,12 @@ describe("S-30: fixed before the first run", () => { expect(amended.confirmatoryAffected).toEqual([]); // And it is a real amendment, not a note beside the condition. - const history = await (await afterwards()).reads.designHistory({ gate }); - const wordings = history.conditions.flatMap((c) => - c.amendments.map((a) => a.replaced.requires), + const why = await (await afterwards()).reads.why({ subject: amended.amendment }); + expect(why.because).toContainEqual( + expect.objectContaining({ handle: criterion, wording: `replaced ${VAGUE}` }), ); - expect(wordings).toContain(VAGUE); + const status = await (await afterwards()).reads.gateStatus({ gate }); + expect(status.checks.map((c) => c.proposition)).toEqual([PRECISE]); await captureConversation( { diff --git a/tests/scenarios/s3b_criteria_qualify_only.test.ts b/tests/scenarios/s3b_criteria_qualify_only.test.ts index 3c309903..644a9472 100644 --- a/tests/scenarios/s3b_criteria_qualify_only.test.ts +++ b/tests/scenarios/s3b_criteria_qualify_only.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf, whyOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { evaluationsOf } from "../helpers/criteria"; import { as, captureConversation } from "../helpers/conversation"; @@ -262,7 +262,7 @@ describe("S-3b: the same design with nothing downstream", () => { * A replaced analysis's checks are as historical as its findings. */ test("a superseded analysis's failed checks do not disqualify its replacement", async () => { - const { median, analysis, enquiry, observations } = await aFindingHeldToAgreedChecks(); + const { median, analysisClaims, enquiry, observations } = await aFindingHeldToAgreedChecks(); await session.writes.evaluateCriterion({ criterion: median, value: "median p = 0.21", @@ -273,17 +273,17 @@ describe("S-3b: the same design with nothing downstream", () => { .verdict, ).toBe("standard-unmet"); - const { review } = await session.writes.recordReview({ - of: analysis, - verdict: "the aggregation was the wrong one", - }); - const replacement = await replaceAnalysis(session.writes, { - supersedes: analysis, - because: review, + const replacement = await reanalyse(session.writes, { enquiry, method: "holm-pairwise, mean aggregation", from: [observations], - concludes: [{ proposition: PROPOSITION, finding: "p = 0.003, Holm-corrected" }], + concludes: [ + { + proposition: PROPOSITION, + finding: "p = 0.003, Holm-corrected", + replacing: claimOf(analysisClaims, PROPOSITION), + }, + ], }); const why = await (await afterwards()).reads.whySupported({ @@ -294,6 +294,12 @@ describe("S-3b: the same design with nothing downstream", () => { expect(why.standard).toEqual([]); expect(why.unmet.map((u) => u.requires)).toEqual([]); expect(why.verdict).toBe("supported"); + + // And the finding it replaced has fallen, rather than standing beside it unmet. + const replaced = await (await afterwards()).reads.whySupported({ + claim: claimOf(analysisClaims, PROPOSITION), + }); + expect(replaced.verdict).toBe("withdrawn"); }); /** diff --git a/tests/scenarios/s3c_defective_check_repaired.test.ts b/tests/scenarios/s3c_defective_check_repaired.test.ts index 29bd0978..fb053b5d 100644 --- a/tests/scenarios/s3c_defective_check_repaired.test.ts +++ b/tests/scenarios/s3c_defective_check_repaired.test.ts @@ -14,7 +14,7 @@ import { } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf } from "../helpers/claims"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { decidedOn, evaluationsOf } from "../helpers/criteria"; import { as, captureConversation } from "../helpers/conversation"; @@ -182,7 +182,7 @@ describe("S-3c: the check was wrong, not the result", () => { const { robustness, enquiry, observations, analysisClaims } = await aResultHeldToARobustnessCheck(); - const { analysis: defective, claims: defectiveClaims } = await theCheckIsRun( + const { claims: defectiveClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -201,16 +201,10 @@ describe("S-3c: the check was wrong, not the result", () => { (await session.reads.whySupported({ claim: claimOf(analysisClaims, PROPOSITION) })).verdict, ).toBe("standard-unmet"); - // The fault is found in the check, and the check is replaced -- the same - // act used for an analysis that was wrong, aimed here at a piece of work - // that happens to be a check. - const { review } = await session.writes.recordReview({ - of: defective, - verdict: "the aggregation dropped the last fold", - }); - const _corrected = await replaceAnalysis(session.writes, { - supersedes: defective, - because: review, + // The fault is found in the check, and the check is re-run with its + // finding named as the one it replaces -- the same act used for an + // analysis that was wrong, aimed here at a piece of work that is a check. + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], @@ -267,7 +261,7 @@ describe("S-3c: the check was wrong, not the result", () => { protecting: [tertiary], }); - const { analysis: defective, claims: defectiveClaims } = await theCheckIsRun( + const { claims: defectiveClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -285,13 +279,7 @@ describe("S-3c: the check was wrong, not the result", () => { }); expect((await session.reads.gateStatus({ gate })).state).toBe("blocked"); - const { review } = await session.writes.recordReview({ - of: defective, - verdict: "the aggregation dropped the last fold", - }); - const _corrected = await replaceAnalysis(session.writes, { - supersedes: defective, - because: review, + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], @@ -330,7 +318,7 @@ describe("S-3c: the check was wrong, not the result", () => { test("what separates the two cases is whether the failed verdict's basis was withdrawn", async () => { const { robustness, enquiry, observations, analysisClaims } = await aResultHeldToARobustnessCheck(); - const { analysis: failed, claims: failedClaims } = await theCheckIsRun( + const { claims: failedClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -368,13 +356,7 @@ describe("S-3c: the check was wrong, not the result", () => { // Now, and only now, is the first run found to have been faulty. Nothing // else about the record changes -- no new evaluation, no new check. - const { review } = await session.writes.recordReview({ - of: failed, - verdict: "the aggregation dropped the last fold", - }); - await replaceAnalysis(session.writes, { - supersedes: failed, - because: review, + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], @@ -412,7 +394,7 @@ describe("S-3c: the check was wrong, not the result", () => { outcome: "fail", }); - const { analysis: unrelated } = await theCheckIsRun( + const { claims: unrelatedClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -421,17 +403,17 @@ describe("S-3c: the check was wrong, not the result", () => { finding: "median p = 0.04", }, ); - const { review } = await session.writes.recordReview({ - of: unrelated, - verdict: "the aggregation dropped the last fold", - }); - await replaceAnalysis(session.writes, { - supersedes: unrelated, - because: review, + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], - concludes: [{ proposition: AGREES, finding: "median p = 0.05" }], + concludes: [ + { + proposition: AGREES, + finding: "median p = 0.05", + replacing: claimOf(unrelatedClaims, AGREES), + }, + ], }); const why = await (await afterwards()).reads.whySupported({ @@ -448,7 +430,7 @@ describe("S-3c: the check was wrong, not the result", () => { test("a check whose every verdict has been withdrawn has no standing verdict, and did not never-run", async () => { const { robustness, enquiry, observations, analysisClaims } = await aResultHeldToARobustnessCheck(); - const { analysis: defective, claims: defectiveClaims } = await theCheckIsRun( + const { claims: defectiveClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -465,13 +447,7 @@ describe("S-3c: the check was wrong, not the result", () => { }); // The check is found faulty and retired. Nobody has re-run it yet. - const { review } = await session.writes.recordReview({ - of: defective, - verdict: "the aggregation dropped the last fold", - }); - await replaceAnalysis(session.writes, { - supersedes: defective, - because: review, + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], @@ -501,61 +477,6 @@ describe("S-3c: the check was wrong, not the result", () => { expect(why.unmet.map((u) => u.requires)).toEqual([ROBUSTNESS]); }); - /** - * A replacement that cannot be completed must leave nothing behind. - */ - test("a replacement that cannot be completed leaves the earlier failure standing", async () => { - const { robustness, enquiry, observations, analysisClaims } = - await aResultHeldToARobustnessCheck(); - const { analysis: defective, claims: defectiveClaims } = await theCheckIsRun( - enquiry, - observations, - "median-aggregation", - { - proposition: DISAGREES, - finding: "median p = 0.21", - }, - ); - await session.writes.evaluateCriterion({ - criterion: robustness, - value: "median p = 0.21", - outcome: "fail", - citing: [claimOf(defectiveClaims, DISAGREES)], - }); - - // Retire the proposition the replacement is going to try to re-assert, so - // the second half of the compound action is guaranteed to be refused. - await session.writes.reinterpret({ - of: claimOf(defectiveClaims, DISAGREES), - as: "the median aggregation was never computed correctly", - because: "the fold handling was wrong throughout", - }); - - const before = await (await afterwards()).reads.whySupported({ - claim: claimOf(analysisClaims, PROPOSITION), - }); - const { review } = await session.writes.recordReview({ - of: defective, - verdict: "the aggregation dropped the last fold", - }); - await expect( - replaceAnalysis(session.writes, { - supersedes: defective, - because: review, - enquiry, - method: "median-aggregation, all folds", - from: [observations], - concludes: [{ proposition: DISAGREES, finding: "median p = 0.04" }], - }), - ).rejects.toThrow(); - - // Nothing moved. The command failed whole. - const after = await (await afterwards()).reads.whySupported({ - claim: claimOf(analysisClaims, PROPOSITION), - }); - expect(after).toEqual(before); - }); - /** * The itemised check above is right — `no-standing-verdict` is exactly what "a check whose * every verdict has been withdrawn" test asserts. @@ -573,7 +494,7 @@ describe("S-3c: the check was wrong, not the result", () => { protecting: [tertiary], }); - const { analysis: passing, claims: passingClaims } = await theCheckIsRun( + const { claims: passingClaims } = await theCheckIsRun( enquiry, observations, "median-aggregation", @@ -591,17 +512,17 @@ describe("S-3c: the check was wrong, not the result", () => { // The passing check turns out to have been defective and is replaced. // Nobody has re-run it yet: the only evaluation of `robustness` now cites // withdrawn evidence, so the criterion has no standing verdict at all. - const { review } = await session.writes.recordReview({ - of: passing, - verdict: "the aggregation dropped the last fold", - }); - await replaceAnalysis(session.writes, { - supersedes: passing, - because: review, + await reanalyse(session.writes, { enquiry, method: "median-aggregation, all folds", from: [observations], - concludes: [{ proposition: AGREES, finding: "median p = 0.05" }], + concludes: [ + { + proposition: AGREES, + finding: "median p = 0.05", + replacing: claimOf(passingClaims, AGREES), + }, + ], }); const reader = await afterwards(); diff --git a/tests/scenarios/s4_negative_closure.test.ts b/tests/scenarios/s4_negative_closure.test.ts index 0683daec..d930ebba 100644 --- a/tests/scenarios/s4_negative_closure.test.ts +++ b/tests/scenarios/s4_negative_closure.test.ts @@ -7,7 +7,7 @@ import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@ import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf } from "../helpers/claims"; import { ref } from "@labkit/core-domain/report"; -import { recordAnalysis, replaceAnalysis } from "../helpers/analysis"; +import { reanalyse, recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; let scenario: Scenario; @@ -370,23 +370,20 @@ describe("S-4: a negative result that closes the question", () => { * Overlap regression, per review: making CHALLENGES live exposed * SUPPORTS-only assumptions in query paths written before it existed. */ - test("a withdrawn challenge is historical, and propagates as an affected claim", async () => { + test("a withdrawn challenge is historical", async () => { const { specificity, observations } = await aProgrammeWithOneOpenQuestion(); - const { analysis: refutation, claims: refutationClaims } = await recordAnalysis( - session.writes, - { - enquiry: specificity, - method: "cluster-comparison", - from: [observations], - concludes: [ - { - proposition: SPECIFICITY, - finding: "no separation detectable", - bearing: "challenges", - }, - ], - }, - ); + const { claims: refutationClaims } = await recordAnalysis(session.writes, { + enquiry: specificity, + method: "cluster-comparison", + from: [observations], + concludes: [ + { + proposition: SPECIFICITY, + finding: "no separation detectable", + bearing: "challenges", + }, + ], + }); const before = await session.reads.whySupported({ claim: claimOf(refutationClaims, SPECIFICITY), @@ -394,13 +391,7 @@ describe("S-4: a negative result that closes the question", () => { expect(before.challenged).toBe(true); expect(before.against).toHaveLength(1); - const { review } = await session.writes.recordReview({ - of: refutation, - verdict: "the clustering metric was misapplied", - }); - const report = await replaceAnalysis(session.writes, { - supersedes: refutation, - because: review, + await reanalyse(session.writes, { enquiry: specificity, method: "corrected-cluster-comparison", from: [observations], @@ -417,15 +408,6 @@ describe("S-4: a negative result that closes the question", () => { ], }); - // A challenging finding is superseded exactly as a supporting one is — reading only the - // supporting side saw nothing here at all. By handle, not by sentence: after the - // replacement two records assert these words, and this names the refutation's own claim, - // the one that was withdrawn. - const revision = await (await afterwards()).reads.why({ subject: report.replacement }); - if (revision.kind !== "analysis") throw new Error(`expected an analysis, got ${revision.kind}`); - expect(revision.report.changed).toHaveLength(1); - expect(revision.report.changed[0]!.was).toEqual(claimOf(refutationClaims, SPECIFICITY)); - // After the replacement the sentence is claimed twice; this asks about // the original, which is the one that was withdrawn. const after = await session.reads.whySupported({ @@ -437,12 +419,7 @@ describe("S-4: a negative result that closes the question", () => { expect(after.superseded[0]).toMatchObject({ finding: "no separation detectable", bearing: "challenges", - reason: "the clustering metric was misapplied", }); - - // ...and invalidating the record enumerates the challenged claim. - const downstream = await session.reads.whatDependsOn({ subject: "cluster-comparison output" }); - expect(downstream.claims.map((c) => c.asserts)).toContain(SPECIFICITY); }); test("an enquiry nobody has closed is open, and that is not a kind of closure", async () => { diff --git a/tests/scenarios/s5_contradiction_or_dissociation.test.ts b/tests/scenarios/s5_contradiction_or_dissociation.test.ts index 2f73634e..6babd3f5 100644 --- a/tests/scenarios/s5_contradiction_or_dissociation.test.ts +++ b/tests/scenarios/s5_contradiction_or_dissociation.test.ts @@ -103,127 +103,27 @@ async function twoStages() { }; } -describe("S-5 — contradiction or dissociation?", () => { - test("the conversation runs end to end through research verbs alone", async () => { - const programme = await twoStages(); - - // Researcher: didn't the earlier stage prove the graph choice doesn't - // matter? Why does this one rank them? - const verdict = await session.reads.doTheseConflict({ - a: claimOf(programme.earlierClaims, IMMATERIAL), - b: claimOf(programme.laterClaims, IMMATERIAL), - }); - - // LabKit: the earlier stage tested internal mapping strength; this one - // tested external classification utility. Those are distinct - // claims. - expect(verdict.sides.map((s) => s.asks)).toEqual([INTERNAL, EXTERNAL]); - - // Researcher: so this is a dissociation, not a contradiction. - // LabKit: correct. Support for equivalence on one endpoint does not - // imply equivalence on another. - expect(verdict.conflict).toBe(false); - expect(verdict.relation).toBe("dissociation"); - expect(verdict.differsBy).toBe("scope"); - - await captureConversation( - { - id: "S-5", - title: "contradiction or dissociation?", - about: - "Two stages of one programme assert the same sentence with opposite evidence. They turn out to be answering different questions, so this is a dissociation rather than a contradiction.", - }, - events, - ); - }); - - /** - * Afterward 1 — which question does each claim answer, and what bears on it? - */ - test("each claim carries its own question and its own evidence", async () => { - const programme = await twoStages(); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const verdict = await later.reads.doTheseConflict({ - a: claimOf(programme.earlierClaims, IMMATERIAL), - b: claimOf(programme.laterClaims, IMMATERIAL), - }); - - const [first, second] = verdict.sides; - expect(first!.proposition).toBe(IMMATERIAL); - expect(second!.proposition).toBe(IMMATERIAL); - expect(first!.asks).toBe(INTERNAL); - expect(second!.asks).toBe(EXTERNAL); - - expect(first!.supportedBy.map((f) => f.states)).toEqual([ - "all five constructions within 0.02 of each other on mapping strength", - ]); - expect(first!.challengedBy).toEqual([]); - expect(second!.supportedBy).toEqual([]); - expect(second!.challengedBy.map((f) => f.states)).toEqual([ - "constructions separate by 11 points of held-out accuracy", - ]); - }); - - /** - * Afterward 2 — what would a genuine contradiction look like here? - */ - test("two opposing findings within one question are a contradiction", async () => { - const programme = await twoStages(); - - const { observations: rerun } = await session.writes.recordObservations({ - enquiry: programme.internalWork, - name: "mapping-strength readings, wider construction set", - finding: "mapping strength measured for twelve graph constructions", - }); - const { claims: dissentingClaims } = await recordAnalysis(session.writes, { - enquiry: programme.internalWork, - method: "mapping-strength-comparison", - from: [rerun], - concludes: [ - { - proposition: IMMATERIAL, - finding: "two of the twelve constructions fall 0.3 below the rest on mapping strength", - bearing: "challenges", - }, - ], - }); - - const verdict = await session.reads.doTheseConflict({ - a: claimOf(programme.earlierClaims, IMMATERIAL), - b: claimOf(dissentingClaims, IMMATERIAL), - }); - - expect(verdict.conflict).toBe(true); - expect(verdict.relation).toBe("contradiction"); - expect(verdict.differsBy).toBeNull(); - expect(verdict.sides.map((s) => s.asks)).toEqual([INTERNAL, INTERNAL]); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const durable = await later.reads.doTheseConflict({ - a: claimOf(programme.earlierClaims, IMMATERIAL), - b: claimOf(dissentingClaims, IMMATERIAL), - }); - expect(durable.relation).toBe("contradiction"); +/** + * The earlier stage's reading narrowed: its own analysis concludes the narrower proposition in + * place of the claim it drew, naming that claim by handle. + */ +async function narrowEarlierReading(programme: Awaited>) { + return session.writes.conclude({ + analysis: programme.earlier, + proposition: "graph construction does not affect mapping strength within 0.02", + finding: "all five constructions within 0.02 of each other on mapping strength", + replacing: claimOf(programme.earlierClaims, IMMATERIAL), }); +} +describe("S-5 — contradiction or dissociation?", () => { /** * Afterward 3 — does revising or withdrawing one interpretation affect the other? */ test("withdrawing one reading leaves the identically worded one alone", async () => { const programme = await twoStages(); - await session.writes.reinterpret({ - of: claimOf(programme.earlierClaims, IMMATERIAL), - as: "graph construction does not affect mapping strength within 0.02", - because: "immaterial overstates it; the measurement was of mapping strength alone", - }); + await narrowEarlierReading(programme); const later = new ResearchSession(await scenario.current(), { clock, @@ -280,16 +180,10 @@ describe("S-5 — contradiction or dissociation?", () => { answeredBy: claimOf(settledClaims, IMMATERIAL), }); - const report = await session.writes.reinterpret({ - of: claimOf(programme.earlierClaims, IMMATERIAL), - as: "graph construction does not affect mapping strength within 0.02", - because: "immaterial overstates it", - }); + await narrowEarlierReading(programme); // The reconstruction-error question was settled on its own reading, not // on this one. - expect(report.restingOnTheOldReading).toEqual([]); - const later = new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog(), @@ -306,11 +200,7 @@ describe("S-5 — contradiction or dissociation?", () => { */ test("withdrawing a sentence here does not block concluding it elsewhere", async () => { const programme = await twoStages(); - await session.writes.reinterpret({ - of: claimOf(programme.earlierClaims, IMMATERIAL), - as: "graph construction does not affect mapping strength within 0.02", - because: "immaterial overstates it", - }); + await narrowEarlierReading(programme); const { question: elsewhere } = await session.writes.pose({ question: "does the graph construction matter for reconstruction error?", @@ -351,17 +241,18 @@ describe("S-5 — contradiction or dissociation?", () => { /** A citation must be one the cited analysis actually made. */ test("naming a claim that does not exist is refused", async () => { - const _programme = await twoStages(); + const programme = await twoStages(); await expect(session.reads.whySupported({ claim: ref("claim", "CLM_9999") })).rejects.toThrow( /CLM_9999 not found/, ); await expect( - session.writes.reinterpret({ - of: ref("claim", "CLM_9999"), - as: "narrower still", - because: "it should not get this far", + session.writes.conclude({ + analysis: programme.earlier, + proposition: "narrower still", + finding: "it should not get this far", + replacing: ref("claim", "CLM_9999"), }), ).rejects.toThrow(/CLM_9999 not found/); }); @@ -373,7 +264,7 @@ describe("S-5 — contradiction or dissociation?", () => { const programme = await twoStages(); // **The refusal lives in one place, and that is the point.** Both - // `whySupported` and `reinterpret` take a handle, so neither has to guess + // `whySupported` and `conclude --replacing` take a handle, so neither has to guess // which claim was meant -- `claimsAsserting` is the single seam where // text becomes a handle. It reports every match rather than choosing. const found = await session.reads.claimsAsserting({ proposition: IMMATERIAL }); @@ -397,6 +288,16 @@ describe("S-5 — contradiction or dissociation?", () => { }); expect(earlier.proposition).toBe(later.proposition); expect(earlier.support).not.toEqual(later.support); + + await captureConversation( + { + id: "S-5", + title: "contradiction or dissociation?", + about: + "Two stages of one programme assert the same sentence with opposite evidence. Asked by its words, the sentence names two claims, and each answers about its own question.", + }, + events, + ); }); /** One sentence in one scope still reads by text — every earlier scenario depends on it. */ diff --git a/tests/scenarios/s7_amend_locked_design.test.ts b/tests/scenarios/s7_amend_locked_design.test.ts index 5fc2a38d..8de859c8 100644 --- a/tests/scenarios/s7_amend_locked_design.test.ts +++ b/tests/scenarios/s7_amend_locked_design.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimNamed, claimOf } from "../helpers/claims"; -import { ref, type DesignHistory } from "@labkit/core-domain/report"; +import { ref } from "@labkit/core-domain/report"; import { recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; @@ -138,16 +138,6 @@ async function diagnose( }; } -/** - * The one condition on a single-condition gate. Every scenario below but the - * last locks exactly one setting; asserting that here keeps the reads that - * follow about the amendment rather than about which condition they picked. - */ -function theCondition(history: DesignHistory) { - expect(history.conditions).toHaveLength(1); - return history.conditions[0]!; -} - describe("S-7 — locked design, then feasibility finds a mechanical defect", () => { test("the conversation runs end to end through research verbs alone", async () => { const programme = await lockedProgramme(); @@ -191,24 +181,31 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () test("the original setting survives the amendment verbatim", async () => { const programme = await lockedProgramme(); const { cites } = await diagnose(programme.enquiry, programme.feasibilityWork); - await session.writes.amendDesign({ + const report = await session.writes.amendDesign({ criterion: programme.iterationLimit, nowRequires: RAISED_LIMIT, because: "the locked limit is unreachable", citing: cites, }); - const history = await session.reads.designHistory({ gate: programme.feasibilityBoundary }); - expect(theCondition(history).originally.requires).toBe(LOCKED_LIMIT); - expect(theCondition(history).nowRequires.requires).toBe(RAISED_LIMIT); - const later = new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog(), }); - const durable = await later.reads.designHistory({ gate: programme.feasibilityBoundary }); - expect(theCondition(durable).originally.requires).toBe(LOCKED_LIMIT); - expect(theCondition(durable).nowRequires.requires).toBe(RAISED_LIMIT); + const amendment = await later.reads.why({ subject: report.amendment }); + expect(amendment.because).toContainEqual({ + handle: programme.iterationLimit, + wording: `replaced ${LOCKED_LIMIT}`, + }); + expect(amendment.because).toContainEqual({ + handle: report.nowRequires.criterion, + wording: `led to ${RAISED_LIMIT}`, + }); + + const original = await later.reads.why({ subject: programme.iterationLimit }); + if (original.kind !== "criterion") + throw new Error(`expected a criterion, got ${original.kind}`); + expect(original.report.requires).toBe(LOCKED_LIMIT); }); /** @@ -217,7 +214,7 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () test("the amendment cites its diagnosis, and the diagnosis has provenance of its own", async () => { const programme = await lockedProgramme(); const { cites } = await diagnose(programme.enquiry, programme.feasibilityWork); - await session.writes.amendDesign({ + const report = await session.writes.amendDesign({ criterion: programme.iterationLimit, nowRequires: RAISED_LIMIT, because: "the locked limit is unreachable for reasons unrelated to the effect under test", @@ -228,14 +225,11 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () clock, events: inMemoryEventLog(), }); - const history = await later.reads.designHistory({ gate: programme.feasibilityBoundary }); - expect(theCondition(history).amendments).toHaveLength(1); - expect(theCondition(history).amendments[0]!.reason).toContain( - "unrelated to the effect under test", + const amendment = await later.reads.why({ subject: report.amendment }); + expect(amendment.is).toContain("unrelated to the effect under test"); + expect(amendment.because.map((c) => c.wording)).toContain( + "rests on condition number rises with feature count; enlarging the sample does not reduce it", ); - expect(theCondition(history).amendments[0]!.citing.map((f) => f.states)).toEqual([ - "condition number rises with feature count; enlarging the sample does not reduce it", - ]); // ...and the cited diagnosis is a finding with a chain behind it, not an // assertion attached to the amendment. @@ -302,15 +296,6 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () }); expect(scientific.nature).toBe("scientific"); expect(scientific.confirmatoryAffected.map((c) => c.asserts)).toEqual([BEATS_CONTROL]); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const feasibility = await later.reads.designHistory({ gate: programme.feasibilityBoundary }); - const confirmatory = await later.reads.designHistory({ gate: programme.confirmatoryBoundary }); - expect(theCondition(feasibility).amendments[0]!.nature).toBe("mechanical"); - expect(theCondition(confirmatory).amendments[0]!.nature).toBe("scientific"); }); /** @@ -320,60 +305,38 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () const programme = await lockedProgramme(); const { cites } = await diagnose(programme.enquiry, programme.feasibilityWork); - await session.writes.amendDesign({ + const first = await session.writes.amendDesign({ criterion: programme.iterationLimit, nowRequires: RAISED_LIMIT, because: "the locked limit is unreachable", citing: cites, }); - - const current = await session.reads.designHistory({ gate: programme.feasibilityBoundary }); - const raised = theCondition(current).nowRequires.requires; - expect(raised).toBe(RAISED_LIMIT); - - await session.writes.amendDesign({ - criterion: theCondition(current).criterion, + const second = await session.writes.amendDesign({ + criterion: first.nowRequires.criterion, nowRequires: "the solver converges within 50,000 iterations", because: "10,000 still caps on the widest sweeps", citing: cites, }); - // An unrelated decision elsewhere in the programme, to show what this can - // and cannot order. - const { question: aside } = await session.writes.pose({ - question: "should the sweep width be capped at all?", - }); - await session.writes.sharpen({ - from: aside, - into: "does sweep width interact with convergence?", - because: "worth separating", - }); - const later = new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog(), }); expect(await later.events.all()).toHaveLength(0); - const history = await later.reads.designHistory({ gate: programme.feasibilityBoundary }); - expect(theCondition(history).originally.requires).toBe(LOCKED_LIMIT); - expect(theCondition(history).nowRequires.requires).toBe( - "the solver converges within 50,000 iterations", - ); - expect(theCondition(history).amendments.map((a) => a.nowRequires.requires)).toEqual([ - RAISED_LIMIT, + const gate = await later.reads.gateStatus({ gate: programme.feasibilityBoundary }); + expect(gate.checks.map((c) => c.proposition)).toEqual([ "the solver converges within 50,000 iterations", ]); - expect(theCondition(history).amendments.map((a) => a.replaced.requires)).toEqual([ - LOCKED_LIMIT, - RAISED_LIMIT, - ]); // The second amendment stands instead of the first, and says so on the - // record rather than only in the order this report happens to render. - const [first, second] = theCondition(history).amendments; - const stands = await later.reads.why({ subject: second!.amendment }); - expect(stands.because.map((c) => c.handle)).toContain(first!.amendment); + // record rather than only in the order the acts were taken. + const stands = await later.reads.why({ subject: second.amendment }); + expect(stands.because.map((c) => c.handle)).toContain(first.amendment); + expect(stands.because).toContainEqual({ + handle: first.nowRequires.criterion, + wording: `replaced ${RAISED_LIMIT}`, + }); }); /** @@ -394,15 +357,6 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () "feasibility sweep of the evolved condition", ]); expect(report.rerun).not.toContain("the prespecified comparison against the rewired control"); - - const later = new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); - const history = await later.reads.designHistory({ gate: programme.feasibilityBoundary }); - expect(theCondition(history).amendments[0]!.rerun.map((w) => w.objective)).toEqual([ - "feasibility sweep of the evolved condition", - ]); }); /** @@ -453,13 +407,14 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () // a re-run or from an amendment. expect(after.everFailed).toBe(true); - // The retired condition is still readable where it belongs. - const history = await new ResearchSession(await scenario.current(), { - clock, - }).reads.designHistory({ - gate: programme.feasibilityBoundary, + // The amended-away condition is still readable where it belongs. + const original = await new ResearchSession(await scenario.current(), { clock }).reads.why({ + subject: programme.iterationLimit, }); - expect(theCondition(history).originally.criterion).toBe(programme.iterationLimit); + if (original.kind !== "criterion") + throw new Error(`expected a criterion, got ${original.kind}`); + expect(original.report.requires).toBe(LOCKED_LIMIT); + expect(original.report.state).toBe("failed"); }); test("a setting that has already been amended cannot be amended again", async () => { @@ -472,7 +427,7 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () because: "the locked limit is unreachable", citing: cites, }); - const afterFirst = await session.reads.designHistory({ gate: programme.feasibilityBoundary }); + const afterFirst = await session.reads.gateStatus({ gate: programme.feasibilityBoundary }); await expect( session.writes.amendDesign({ @@ -483,12 +438,12 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () }), ).rejects.toThrow(/has already been amended/); - // The history still reads, and reads exactly as it did before. + // The gate still reads, and reads exactly as it did before. const later = new ResearchSession(await scenario.current(), { clock, events: inMemoryEventLog(), }); - expect(await later.reads.designHistory({ gate: programme.feasibilityBoundary })).toEqual( + expect(await later.reads.gateStatus({ gate: programme.feasibilityBoundary })).toEqual( afterFirst, ); }); @@ -497,7 +452,7 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () test("amending a criterion that is not on the record writes nothing", async () => { const programme = await lockedProgramme(); const { cites } = await diagnose(programme.enquiry, programme.feasibilityWork); - const before = await session.reads.designHistory({ gate: programme.feasibilityBoundary }); + const before = await session.reads.gateStatus({ gate: programme.feasibilityBoundary }); await expect( session.writes.amendDesign({ @@ -512,9 +467,7 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () clock, events: inMemoryEventLog(), }); - expect(await later.reads.designHistory({ gate: programme.feasibilityBoundary })).toEqual( - before, - ); + expect(await later.reads.gateStatus({ gate: programme.feasibilityBoundary })).toEqual(before); }); /** @@ -538,13 +491,13 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () protecting: [work], }); - await session.writes.amendDesign({ + const capAmended = await session.writes.amendDesign({ criterion: cap, nowRequires: RAISED_LIMIT, because: "the locked limit is unreachable", citing: cites, }); - await session.writes.amendDesign({ + const tolAmended = await session.writes.amendDesign({ criterion: tolerance, nowRequires: RELAXED_TOLERANCE, because: "the locked tolerance is below the solver's own noise floor", @@ -555,26 +508,24 @@ describe("S-7 — locked design, then feasibility finds a mechanical defect", () clock, events: inMemoryEventLog(), }); - const history = await later.reads.designHistory({ gate }); - - const byOriginal = new Map(history.conditions.map((c) => [c.originally.requires, c])); - expect([...byOriginal.keys()].sort()).toEqual([LOCKED_LIMIT, LOCKED_TOLERANCE].sort()); - - const capHistory = byOriginal.get(LOCKED_LIMIT)!; - expect(capHistory.nowRequires.requires).toBe(RAISED_LIMIT); - expect(capHistory.amendments.map((a) => a.nowRequires.requires)).toEqual([RAISED_LIMIT]); + const status = await later.reads.gateStatus({ gate }); + expect(status.checks.map((c) => c.proposition).sort()).toEqual( + [RAISED_LIMIT, RELAXED_TOLERANCE].sort(), + ); - const tol = byOriginal.get(LOCKED_TOLERANCE)!; - expect(tol.nowRequires.requires).toBe(RELAXED_TOLERANCE); - expect(tol.amendments.map((a) => a.nowRequires.requires)).toEqual([RELAXED_TOLERANCE]); + // Each amendment replaced its own setting and nothing else. + const capWhy = await later.reads.why({ subject: capAmended.amendment }); + expect(capWhy.because).toContainEqual({ handle: cap, wording: `replaced ${LOCKED_LIMIT}` }); + const tolWhy = await later.reads.why({ subject: tolAmended.amendment }); + expect(tolWhy.because).toContainEqual({ + handle: tolerance, + wording: `replaced ${LOCKED_TOLERANCE}`, + }); // The two amendments are unrelated acts. If the tolerance amendment // superseded the cap amendment, this record would say one setting was // replaced by a change to a different setting. - expect(capHistory.amendments[0]!.amendment).not.toBe(tol.amendments[0]!.amendment); - const withdrawal = await later.reads.why({ subject: tol.amendments[0]!.amendment }); - expect(withdrawal.because.map((c) => c.handle)).not.toContain( - capHistory.amendments[0]!.amendment, - ); + expect(capAmended.amendment).not.toBe(tolAmended.amendment); + expect(tolWhy.because.map((c) => c.handle)).not.toContain(capAmended.amendment); }); }); diff --git a/tests/scenarios/s8b_no_who_only_what_ran.test.ts b/tests/scenarios/s8b_no_who_only_what_ran.test.ts index 56361acc..76e95d22 100644 --- a/tests/scenarios/s8b_no_who_only_what_ran.test.ts +++ b/tests/scenarios/s8b_no_who_only_what_ran.test.ts @@ -35,89 +35,13 @@ async function afterwards(): Promise { /** * Scopes a session over the world the hooks opened. The world's lifecycle * lives in `beforeEach`/`afterEach`, outside bun's 5000ms per-test ceiling, - * since both tests here open exactly one world. + * since the test here opens exactly one world. */ async function inOneWorld(build: (s: ResearchSession) => Promise): Promise { return build(new ResearchSession(graph, { clock, events: inMemoryEventLog() })); } -const MOVES = "the pruning schedule shifts the convergence point"; -const CONFIG = "agent configuration"; - describe("S-8b: there is no who, only what ran", () => { - /** - * Researcher: "Two analyses reached the same conclusion. One was run by an agent on last - * month's configuration and one on this month's. Which was which, and does the difference - * matter?" - */ - test("what produced an analysis is recoverable, and two configurations are distinguishable", async () => { - const result = await inOneWorld(async (s) => { - const { enquiry } = await s.writes.openEnquiry("does the pruning schedule move convergence?"); - const { observations: readings } = await s.writes.recordObservations({ - enquiry, - name: "sweep readings", - finding: "twelve runs across the schedule", - contentHash: "sha256:sweep", - }); - const { observations: older } = await s.writes.recordObservations({ - enquiry, - name: CONFIG, - finding: "opus-5, temperature 0, prompt v3", - contentHash: "sha256:cfg-v3", - }); - const { analysis: first } = await recordAnalysis(s.writes, { - enquiry, - method: "convergence-fit", - from: [readings, older], - concludes: [{ proposition: MOVES, finding: "convergence moves by ~3 steps" }], - }); - - const { observations: newer } = await s.writes.recordObservations({ - enquiry, - name: CONFIG, - finding: "opus-5, temperature 0.7, prompt v4", - contentHash: "sha256:cfg-v4", - }); - await recordAnalysis(s.writes, { - enquiry, - method: "convergence-fit", - from: [readings, newer], - concludes: [{ proposition: MOVES, finding: "convergence moves by ~3 steps" }], - }); - - const reader = await afterwards(); - return { - // Offering the older configuration against the older analysis, analysisClaims matches. - matched: await reader.reads.reproducibilityOf({ - analysis: first, - rebuilt: [ - { part: readings, hash: "sha256:sweep" }, - { part: older, hash: "sha256:cfg-v3" }, - ], - }), - // Offering the newer one against it does not. "Which configuration - // produced this" is answered by comparison, not by a signature. - mismatched: await reader.reads.reproducibilityOf({ - analysis: first, - rebuilt: [ - { part: readings, hash: "sha256:sweep" }, - { part: older, hash: "sha256:cfg-v4" }, - ], - }), - // And the configuration carries its dependants like any other input, - // so "what rests on this configuration" is the ordinary propagation - // question rather than a new kind of query. - rests: await reader.reads.whatDependsOn({ subject: older }), - }; - }); - - expect(result.matched.exact.map((p) => p.name).sort()).toEqual([CONFIG, "sweep readings"]); - expect(result.matched.reproducible).toBe(true); - expect(result.mismatched.differing.map((p) => p.name)).toEqual([CONFIG]); - expect(result.mismatched.reproducible).toBe(false); - expect(result.rests.claims.map((c) => c.asserts)).toEqual([MOVES]); - }); - /** * *"On what projected cost?"* */ diff --git a/tests/scenarios/s9_artefact_survived_provenance_didnt.test.ts b/tests/scenarios/s9_artefact_survived_provenance_didnt.test.ts index fb000ea4..cfacee44 100644 --- a/tests/scenarios/s9_artefact_survived_provenance_didnt.test.ts +++ b/tests/scenarios/s9_artefact_survived_provenance_didnt.test.ts @@ -95,98 +95,6 @@ async function aCachedConstructionWithOneUnrecordedPart() { } describe("S-9: the artefact survived; its provenance didn't", () => { - /** - * Afterward 1. "Which parts of this artefact are reproducible?" — three named exactly, one - * not. A part with no recorded hash is not a part that differs; it is a part nobody can - * check, and the two must not read alike. - */ - test("Afterward 1: three parts reproduce exactly, one cannot be checked at all", async () => { - const { parts, analysis } = await aCachedConstructionWithOneUnrecordedPart(); - - // Offered by part, not by name. Keying these by `logical_name` would have - // reintroduced, one function away, the identity defect this scenario is - // about -- and in S-9 of all places, where two parts share a name. - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis, - rebuilt: [ - { part: parts[0]!, hash: "sha256:aaa" }, - { part: parts[1]!, hash: "sha256:bbb" }, - { part: parts[2]!, hash: "sha256:ccc" }, - { part: parts[3]!, hash: "sha256:regenerated" }, - ], - }); - - expect(report.exact.map((p) => p.name).sort()).toEqual(["priors", "splits", "weights"]); - expect(report.unverifiable.map((p) => p.name)).toEqual([CONTROL]); - expect(report.differing.map((p) => p.name)).toEqual([]); - expect(report.reproducible).toBe(false); - - await captureConversation( - { - id: "S-9", - title: "the artefact survived; its provenance didn't", - about: - "A cached construction from an old study is rebuilt. Three of its four parts match their recorded hashes; the fourth has no hash at all, so nobody can check it, and that is not the same as its having come back different.", - }, - events, - ); - }); - - /** - * Afterward 2. "What depends on the unreproducible part?" — the downstream - * results, which now carry a provenance caveat rather than a clean bill. - */ - test("Afterward 2: what rests on the unverifiable part is enumerable", async () => { - await aCachedConstructionWithOneUnrecordedPart(); - - const dependents = await (await afterwards()).reads.whatDependsOn({ subject: CONTROL }); - expect(dependents.claims.map((c) => c.asserts)).toEqual([PROPOSITION]); - expect(dependents.enquiries.map((e) => e.pursuing)).toEqual([ - "does the accelerated path match the reference?", - ]); - }); - - /** - * Afterward 3, and the one the scenario exists for. "Is the regenerated version the same - * artefact?" — no. - */ - test("Afterward 3: a regenerated part does not inherit the original's dependents", async () => { - const { enquiry, parts } = await aCachedConstructionWithOneUnrecordedPart(); - const original = parts[3]!; - - // The researcher regenerates the control by inferring the old algorithm. - // Same name, because it is a regeneration of that part -- and a different - // thing, because nobody knows the original was made this way. - const { observations: regenerated } = await session.writes.recordObservations({ - enquiry, - name: CONTROL, - finding: "randomised control series, regenerated from an inferred algorithm", - contentHash: "sha256:regenerated", - }); - const { analysis: downstream } = await recordAnalysis(session.writes, { - enquiry, - method: "stage2-construction, rebuilt", - from: [regenerated], - concludes: [ - { - proposition: "the rebuild agrees with the cache", - finding: "agreement within 1e-6", - }, - ], - }); - - const reader = await afterwards(); - // The historical part still carries what always rested on it, and nothing - // that rests on the rebuild. - const historical = await reader.reads.whatDependsOn({ subject: original }); - expect(historical.claims.map((c) => c.asserts)).toEqual([PROPOSITION]); - - // And the regenerated part carries only its own. - const rebuilt = await reader.reads.whatDependsOn({ subject: regenerated }); - expect(rebuilt.claims.map((c) => c.asserts)).toEqual(["the rebuild agrees with the cache"]); - expect(downstream).toBeDefined(); - }); - /** * Afterward 4. "What would resolve this?" — an open question, still open. * A regeneration is a workaround, not an answer, and the record must not let @@ -217,101 +125,15 @@ describe("S-9: the artefact survived; its provenance didn't", () => { "what generated the historical random control?", ); expect(unresolved).toBeDefined(); - }); - - /** - * The refusal, stated on its own. Asking by name is fine while a name identifies one thing; - * once a part has been regenerated it does not, and answering about the union is how inferred - * provenance would inherit the original's standing. - */ - test("asking by name is refused once two artefacts share it", async () => { - const { enquiry } = await aCachedConstructionWithOneUnrecordedPart(); - // Before regenerating, the name is unambiguous and the question answerable. - expect( - (await session.reads.whatDependsOn({ subject: CONTROL })).claims.map((c) => c.asserts), - ).toEqual([PROPOSITION]); - - await session.writes.recordObservations({ - enquiry, - name: CONTROL, - finding: "randomised control series, regenerated from an inferred algorithm", - contentHash: "sha256:regenerated", - }); - - await expect((await afterwards()).reads.whatDependsOn({ subject: CONTROL })).rejects.toThrow( - /2 artefacts are named/, + await captureConversation( + { + id: "S-9", + title: "the artefact survived; its provenance didn't", + about: + "A cached construction from an old study has one part with no recorded hash, so nobody can check it. The researcher regenerates that part, and the question of what made the original stays open.", + }, + events, ); }); - - /** - * External review. A part the caller simply did not rebuild is not a part that came back - * different. - */ - test("a part that was not rebuilt is not a part that differs", async () => { - const { parts, analysis } = await aCachedConstructionWithOneUnrecordedPart(); - - // Only two of the three hashed parts were rebuilt. - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis, - rebuilt: [ - { part: parts[0]!, hash: "sha256:aaa" }, - { part: parts[1]!, hash: "sha256:bbb" }, - ], - }); - - expect(report.exact.map((p) => p.name).sort()).toEqual(["splits", "weights"]); - expect(report.differing.map((p) => p.name)).toEqual([]); - expect(report.notRebuilt.map((p) => p.name)).toEqual(["priors"]); - expect(report.unverifiable.map((p) => p.name)).toEqual([CONTROL]); - expect(report.reproducible).toBe(false); - }); - - /** - * External review, and the sharper half of it. A part that really did come - * back different must still say so — the fix above must not turn every - * mismatch into "you did not rebuild it". - */ - test("a part that was rebuilt and differs still reports as differing", async () => { - const { parts, analysis } = await aCachedConstructionWithOneUnrecordedPart(); - - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis, - rebuilt: [ - { part: parts[0]!, hash: "sha256:aaa" }, - { part: parts[1]!, hash: "sha256:DIFFERENT" }, - { part: parts[2]!, hash: "sha256:ccc" }, - ], - }); - - expect(report.exact.map((p) => p.name).sort()).toEqual(["priors", "weights"]); - expect(report.differing.map((p) => p.name)).toEqual(["splits"]); - expect(report.notRebuilt.map((p) => p.name)).toEqual([]); - }); - - /** - * A regeneration needs no artefact lineage to record its direction, because the direction is - * not durable in the first place: the regenerated part is created with an ordinary - * `recordObservations()` that names nothing historical, and `reproducibilityOf()` is a read - * that takes the historical parts as arguments and persists nothing. - */ - test("BOUNDARY: nothing durable says what a regeneration was reconstructing", async () => { - const { enquiry, parts } = await aCachedConstructionWithOneUnrecordedPart(); - const original = parts[3]!; - - const { observations: regenerated } = await session.writes.recordObservations({ - enquiry, - name: CONTROL, - finding: "randomised control series, regenerated from an inferred algorithm", - contentHash: "sha256:regenerated", - }); - - const reader = await afterwards(); - // What S-9 did establish, and all this test claims to pin: - expect(regenerated).not.toBe(original); - expect((await reader.reads.whatDependsOn({ subject: regenerated })).claims).toEqual([]); - expect( - (await reader.reads.whatDependsOn({ subject: original })).claims.map((c) => c.asserts), - ).toEqual([PROPOSITION]); - }); }); diff --git a/tests/scenarios/s9b_rebuild_or_fresh_work.test.ts b/tests/scenarios/s9b_rebuild_or_fresh_work.test.ts index ea366c29..cf780278 100644 --- a/tests/scenarios/s9b_rebuild_or_fresh_work.test.ts +++ b/tests/scenarios/s9b_rebuild_or_fresh_work.test.ts @@ -4,7 +4,13 @@ */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; +import { + ResearchSession, + inMemoryEventLog, + type Clock, + type EventSink, + type KnowledgeSurvey, +} from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; import { claimOf } from "../helpers/claims"; import { recordAnalysis } from "../helpers/analysis"; @@ -84,66 +90,19 @@ async function theCachedConstruction(s: ResearchSession) { return { enquiry, control, analysis, analysisClaims }; } -/** Replaces every natural-id counter with `N`, so two worlds compare on structure. */ -function normaliseIds(value: T): T { - return JSON.parse( - JSON.stringify(value).replace( - /"(Q|LOE|EU|EV|CLM|DEC|CRIT|CEVAL|GATE|REV|ART|COMP|TASK)_\d+"/g, - '"$1_N"', - ), - ) as T; +/** Every question's wording, under the bucket `whatIsKnown` put it in. */ +function bucketsOf(known: KnowledgeSurvey): Record { + const asks = (qs: readonly { asks: string }[]) => qs.map((q) => q.asks).sort(); + return { + established: asks(known.established), + provisional: asks(known.provisional), + unresolved: asks(known.unresolved), + untested: asks(known.untested), + accepted: asks(known.accepted), + }; } describe("S-9b: was this a rebuild, or new work?", () => { - /** - * The control, and it is doing the same job probe 1 does in the consumer slice: it proves the - * harness **can** return unequal answers for two worlds. - */ - test("two worlds that differ in what the record says are told apart", async () => { - const build = (recorded: string) => async (s: ResearchSession) => { - const { enquiry } = await theCachedConstruction(s); - const { observations: second } = await s.writes.recordObservations({ - enquiry, - name: "second control", - finding: "control series, second pass", - contentHash: recorded, - }); - const { analysis: rebuilt } = await recordAnalysis(s.writes, { - enquiry, - method: "stage2-construction, second control", - from: [second], - concludes: [ - { - proposition: "the second control agrees", - finding: "agreement within 1e-6", - }, - ], - }); - // The same rebuild offered in both worlds; only what the record holds - // differs. - return (await afterwards()).reads.reproducibilityOf({ - analysis: rebuilt, - rebuilt: [{ part: second, hash: "sha256:one" }], - }); - }; - const { a, b } = await inTwoWorlds(build("sha256:one"), build("sha256:two")); - - expect(a.exact.map((p) => p.name)).toEqual(["second control"]); - expect(a.reproducible).toBe(true); - expect(b.differing.map((p) => p.name)).toEqual(["second control"]); - expect(b.reproducible).toBe(false); - - await captureConversation( - { - id: "S-9b", - title: "was this a rebuild, or new work?", - about: - "A second control is recorded against an old cached construction. Whether it is a reconstruction of the original or independent fresh work is currently only wording, and the record reads the same either way.", - }, - events, - ); - }); - /** * Rung 1, and the finding. Two research situations that mean different things produce **the * same durable record**. @@ -166,13 +125,9 @@ describe("S-9b: was this a rebuild, or new work?", () => { const reader = await afterwards(); return { why: await reader.reads.whySupported({ claim: claimOf(rebuiltClaims, MATCHES) }), - known: (await reader.reads.whatIsKnown()).provisional.map((q) => q.asks).sort(), - // Identity normalised, the way `rebuilt` below already is. Natural ids - // are global sequences, so two paired worlds legitimately draw - // different ones -- comparing them raw would report a difference that - // is only the counter moving. What the comparison is for is whether - // anything *else* differs. - depends: normaliseIds(await reader.reads.whatDependsOn({ subject: second })), + known: bucketsOf(await reader.reads.whatIsKnown()), + // Natural ids are global sequences, so two paired worlds draw different ones; + // the comparison is over whether anything *else* differs. rebuilt: rebuilt.replace(/\d+/, "N"), }; }; @@ -186,11 +141,25 @@ describe("S-9b: was this a rebuild, or new work?", () => { // researcher happened to type. Attribution of a rebuild is currently // **only wording**, which is the seventh region in which identity has had // to be separated from what something says. + // Each answer holds something, so the equalities below compare contents, not two empties. + expect(a.why.support.length).toBeGreaterThan(0); + expect(Object.values(a.known).flat()).toContain( + "does the accelerated path match the reference?", + ); expect(a.why.support.length).toBe(b.why.support.length); expect(a.why.reverifiedBy).toEqual(b.why.reverifiedBy); expect(a.known).toEqual(b.known); - expect(a.depends).toEqual(b.depends); expect(a.rebuilt).toEqual(b.rebuilt); + + await captureConversation( + { + id: "S-9b", + title: "was this a rebuild, or new work?", + about: + "A second control is recorded against an old cached construction. Whether it is a reconstruction of the original or independent fresh work is currently only wording, and the record reads the same either way.", + }, + events, + ); }); /** @@ -222,32 +191,6 @@ describe("S-9b: was this a rebuild, or new work?", () => { expect(why.reverifiedBy).toEqual([]); }); - /** - * Rung 2, tested rather than argued: does a verb that already exists record the rebuild as an - * act with a target? - */ - test("the rebuild recorded through the verb that already exists", async () => { - const why = await inOneWorld(async (s) => { - const { enquiry, analysis } = await theCachedConstruction(s); - const { observations: regenerated } = await s.writes.recordObservations({ - enquiry, - name: CONTROL, - contentHash: "sha256:second", - finding: "randomised control series, regenerated from an inferred algorithm", - }); - const verified = await s.writes.reverify({ - historical: analysis, - enquiry, - method: "stage2-construction, rebuilt", - under: [regenerated], - concludes: { proposition: MATCHES, finding: "agreement within 1e-6" }, - }); - return (await afterwards()).reads.whySupported({ claim: claimOf(verified.claims, MATCHES) }); - }); - expect(why.support.length).toBe(1); - expect(why.reverifiedBy.map((r) => r.method)).toEqual(["stage2-construction, rebuilt"]); - }); - /** * A researcher opens the question of what generated the historical control and works on it: * three candidate algorithms tried, none reproduces the recorded series. That is real, @@ -289,99 +232,4 @@ describe("S-9b: was this a rebuild, or new work?", () => { // worked on through recordAnalysis(), which always minted a unit. expect(unresolved).toContain("does the accelerated path match the reference?"); }); - - /** - * Where rung 2 stops, stated precisely rather than gestured at. - */ - test("a rebuild that concludes nothing has no act to be recorded as", async () => { - await inOneWorld(async (s) => { - const { enquiry, analysis } = await theCachedConstruction(s); - const { observations: regenerated } = await s.writes.recordObservations({ - enquiry, - name: CONTROL, - contentHash: "sha256:second", - finding: "randomised control series, regenerated from an inferred algorithm", - }); - - // The researcher has rebuilt the control and concluded nothing from it. - // The only verb on the surface that records an act with a historical - // target insists on a conclusion to re-check. - await expect( - s.writes.reverify({ - historical: analysis, - enquiry, - method: "control regeneration", - under: [regenerated], - concludes: { - proposition: "the control was regenerated from an inferred algorithm", - finding: "series regenerated", - }, - }), - ).rejects.toThrow(/concluded nothing about/); - }); - }); - - /** - * What `reverify()` still does not answer, stated on its own so the remaining gap is not - * overstated or lost. - */ - test("what was this artefact rebuilding — still nothing answers", async () => { - await inOneWorld(async (s) => { - const { enquiry, analysis } = await theCachedConstruction(s); - const { observations: regenerated } = await s.writes.recordObservations({ - enquiry, - name: CONTROL, - contentHash: "sha256:second", - finding: "randomised control series, regenerated from an inferred algorithm", - }); - await s.writes.reverify({ - historical: analysis, - enquiry, - method: "stage2-construction, rebuilt", - under: [regenerated], - concludes: { proposition: MATCHES, finding: "agreement within 1e-6" }, - }); - - const reader = await afterwards(); - // Asking by name is refused, correctly. - await expect(reader.reads.whatDependsOn({ subject: CONTROL })).rejects.toThrow( - /2 artefacts are named/, - ); - - // Asking by reference answers about that artefact only. The assertion is on the report's - // **shape**, not on its values, and that is deliberate: a test that only checked `claims` - // would stay green even if a field naming what was rebuilt were added to this report by - // mistake. - const exact = await reader.reads.whatDependsOn({ subject: regenerated }); - expect(Object.keys(exact).sort()).toEqual([ - "claims", - "complete", - "enquiries", - "routesWalked", - "subject", - ]); - // And the echo is of the record asked about, not merely of its wording. - expect(exact.subject).toEqual(regenerated); - expect(exact.claims.map((c) => c.asserts)).toEqual([MATCHES]); - - // Same detector on the other read a consumer would reach for. The - // reproducibility report is offered per part and says which parts match; - // no field of it says what any part was an attempt to rebuild. - const report = await reader.reads.reproducibilityOf({ - analysis, - rebuilt: [{ part: regenerated, hash: "sha256:second" }], - }); - // `analysis` here is likewise the construction handed in, not an answer - // to what any part was rebuilding. - expect(Object.keys(report).sort()).toEqual([ - "analysis", - "differing", - "exact", - "notRebuilt", - "reproducible", - "unverifiable", - ]); - expect(report.analysis).toEqual(analysis); - }); - }); }); diff --git a/tests/scenarios/s9c_two_parts_one_name.test.ts b/tests/scenarios/s9c_two_parts_one_name.test.ts deleted file mode 100644 index fa7bf875..00000000 --- a/tests/scenarios/s9c_two_parts_one_name.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * S-9c — "Both reproduced and not, under one name." . - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const NAME = "control series"; - -/** - * Researcher: "We regenerated the control, and this analysis compares it against the original." - */ -async function anAnalysisComparingBothControls(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("do the two controls agree?"); - const { observations: original } = await s.writes.recordObservations({ - enquiry, - name: NAME, - finding: "the historical series", - contentHash: "sha256:orig", - }); - const { observations: regenerated } = await s.writes.recordObservations({ - enquiry, - name: NAME, - finding: "regenerated from an inferred algorithm", - contentHash: "sha256:regen", - }); - const { analysis: comparison } = await recordAnalysis(s.writes, { - enquiry, - method: "compare-controls", - from: [original, regenerated], - concludes: [{ proposition: "the controls agree", finding: "within tolerance" }], - }); - return { enquiry, original, regenerated, comparison }; -} - -describe("S-9c: two parts, one name", () => { - /** - * The reproducibility report identifies parts by reference, so a caller can tell which one is - * which — and a name that two parts share cannot silently merge them. - */ - test("a part that matched and a part that differed are distinguishable", async () => { - const { original, regenerated, comparison } = await anAnalysisComparingBothControls(session); - - const report = await (await afterwards()).reads.reproducibilityOf({ - analysis: comparison, - rebuilt: [ - { part: original, hash: "sha256:orig" }, - { part: regenerated, hash: "sha256:something-else" }, - ], - }); - - expect(report.exact).toEqual([{ part: original, name: NAME }]); - expect(report.differing).toEqual([{ part: regenerated, name: NAME }]); - expect(report.reproducible).toBe(false); - - // The names alone are identical, which is the whole point: identity is the - // reference, and the name is what a person reads. - expect(report.exact[0]?.name).toEqual(report.differing[0]?.name); - expect(report.exact[0]?.part).not.toEqual(report.differing[0]?.part); - - await captureConversation( - { - id: "S-9c", - title: "two parts, one name", - about: - "One analysis reads two control series recorded under the same name. On a rebuild one matches and one differs, and the report keeps them apart because it identifies parts by reference rather than by name.", - }, - events, - ); - }); - - /** The same for the two absences, which S-9 fought to keep apart. */ - test("unverifiable and not-rebuilt stay distinguishable under a shared name", async () => { - const { enquiry } = await anAnalysisComparingBothControls(session); - const { observations: noHash } = await session.writes.recordObservations({ - enquiry, - name: NAME, - finding: "a third copy, no hash recorded", - }); - const { analysis } = await recordAnalysis(session.writes, { - enquiry, - method: "second-look", - from: [noHash], - concludes: [ - { - proposition: "the third copy is unrecoverable", - finding: "no hash", - }, - ], - }); - - const report = await (await afterwards()).reads.reproducibilityOf({ analysis, rebuilt: [] }); - expect(report.unverifiable).toEqual([{ part: noHash, name: NAME }]); - expect(report.notRebuilt).toEqual([]); - }); -}); diff --git a/tests/scenarios/s9d_two_inputs_one_name.test.ts b/tests/scenarios/s9d_two_inputs_one_name.test.ts index 2f1cc56a..fb8d9f8a 100644 --- a/tests/scenarios/s9d_two_inputs_one_name.test.ts +++ b/tests/scenarios/s9d_two_inputs_one_name.test.ts @@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; import { openScenario, type Scenario } from "../helpers/scenario"; -import { claimNamed, whyOf } from "../helpers/claims"; +import { whyOf } from "../helpers/claims"; import { recordAnalysis } from "../helpers/analysis"; import { as, captureConversation } from "../helpers/conversation"; @@ -71,42 +71,13 @@ async function anAnalysisRestingOnBothControls(s: ResearchSession) { } describe("S-9d: resting on one thing, or two?", () => { - /** - * The control, and it does real work: it establishes that the two inputs are genuinely - * distinct in the record, so the collapse below is a fact about the read rather than about - * the fixture. - */ - test("the record holds two distinct inputs under the one name", async () => { - const { surviving, regenerated, analysis } = await anAnalysisRestingOnBothControls(session); - - const parts = await (await afterwards()).reads.reproducibilityOf({ - analysis, - rebuilt: [ - { part: surviving, hash: "sha256:surviving" }, - { part: regenerated, hash: "sha256:regenerated" }, - ], - }); - - expect(parts.exact.map((p) => p.part).sort()).toEqual([surviving, regenerated].sort()); - expect(parts.exact.map((p) => p.name)).toEqual([NAME, NAME]); - - await captureConversation( - { - id: "S-9d", - title: "resting on one thing, or two?", - about: - "A comparison reads the surviving fragment of a control series and the regenerated remainder. Both are recorded under the same name, and the record holds them as two inputs rather than one.", - }, - events, - ); - }); - /** * The question a researcher actually asks: *why does this conclusion count as supported?* — - * and the answer now names both inputs. + * and the answer names both inputs, from a second reader, so it is durable state. */ test("two inputs sharing a name are reported as two", async () => { const { surviving, regenerated } = await anAnalysisRestingOnBothControls(session); + expect(surviving).not.toEqual(regenerated); const why = await whyOf((await afterwards()).reads, DIVERGE); @@ -114,29 +85,15 @@ describe("S-9d: resting on one thing, or two?", () => { expect(why.restingOn.map((a) => a.part).sort()).toEqual([surviving, regenerated].sort()); expect(why.restingOn.map((a) => a.name)).toEqual([NAME, NAME]); expect(why.verdict).toBe("supported"); - }); - - /** - * The same claim from a second reader, so this is a statement about durable state rather than - * about a value the first call happened to return. - */ - test("the collapse is in the read, not in what was recorded", async () => { - const { surviving, regenerated } = await anAnalysisRestingOnBothControls(session); - const reader = await afterwards(); - - for (const part of [surviving, regenerated]) { - const rests = await reader.reads.whatDependsOn({ subject: part }); - expect(rests.claims.map((c) => c.asserts)).toEqual([DIVERGE]); - } - expect(surviving).not.toEqual(regenerated); - await expect(reader.reads.whatDependsOn({ subject: NAME })).rejects.toThrow( - /2 artefacts are named/, + await captureConversation( + { + id: "S-9d", + title: "resting on one thing, or two?", + about: + "A comparison reads the surviving fragment of a control series and the regenerated remainder. Both are recorded under the same name, and the record holds them as two inputs rather than one.", + }, + events, ); - - const restingOn = ( - await reader.reads.whySupported({ claim: await claimNamed(reader.reads, DIVERGE) }) - ).restingOn; - expect(restingOn.map((a) => a.part).sort()).toEqual([surviving, regenerated].sort()); }); }); diff --git a/tests/scenarios/s9e_reproducing_nothing.test.ts b/tests/scenarios/s9e_reproducing_nothing.test.ts deleted file mode 100644 index a9e362a7..00000000 --- a/tests/scenarios/s9e_reproducing_nothing.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * S-9e — "Did it reproduce?" asked about nothing. docs/consumer- - * contract/037_reproducibility_of_nothing_predictions.md - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { ResearchSession, inMemoryEventLog, type Clock, type EventSink } from "@labkit/core-domain"; -import { openScenario, type Scenario } from "../helpers/scenario"; -import { ref } from "@labkit/core-domain/report"; -import { recordAnalysis } from "../helpers/analysis"; -import { as, captureConversation } from "../helpers/conversation"; - -let scenario: Scenario; -let session: ResearchSession; -let events: EventSink; -const clock: Clock = { now: () => "2026-08-21T09:00:00.000Z" }; - -beforeAll(async () => { - scenario = await openScenario(); -}); -afterAll(async () => { - await scenario.close(); -}); -beforeEach(async () => { - events = inMemoryEventLog(); - session = new ResearchSession(await scenario.begin(), { - clock, - events, - attribution: as("Researcher"), - }); -}); -afterEach(async () => { - await scenario.end(); -}); - -async function afterwards(): Promise { - return new ResearchSession(await scenario.current(), { - clock, - events: inMemoryEventLog(), - }); -} - -const HOLDS = "the simulation converges"; - -/** - * Researcher: "That one was a pure simulation — it didn't read anything of ours. Can we say it - * reproduces?" - */ -async function anAnalysisThatConsumedNothing(s: ResearchSession) { - const { enquiry } = await s.writes.openEnquiry("does the simulation converge?"); - const { analysis, claims: analysisClaims } = await recordAnalysis(s.writes, { - enquiry, - method: "pure-sim", - from: [], - concludes: [{ proposition: HOLDS, finding: "it converges" }], - }); - return { enquiry, analysis, analysisClaims }; -} - -describe("S-9e: reproducing nothing", () => { - /** - * **The defect.** Nothing was rebuilt, because there was nothing to rebuild, and the report - * said the construction reproduces. - */ - test("an analysis that consumed nothing has not been shown to reproduce", async () => { - const { analysis } = await anAnalysisThatConsumedNothing(session); - - const report = await session.reads.reproducibilityOf({ analysis, rebuilt: [] }); - expect(report.reproducible).toBe(false); - expect(report.exact).toEqual([]); - expect(report.differing).toEqual([]); - expect(report.unverifiable).toEqual([]); - expect(report.notRebuilt).toEqual([]); - - // Afterward, from a second reader over the same graph. - const again = await (await afterwards()).reads.reproducibilityOf({ analysis, rebuilt: [] }); - expect(again.reproducible).toBe(false); - - await captureConversation( - { - id: "S-9e", - title: "reproducing nothing", - about: - "A pure simulation read none of the programme's own data. Asked whether it reproduces, the answer is no: nothing was rebuilt because there was nothing to rebuild.", - }, - events, - ); - }); - - /** - * **The other half, and a different answer.** A caller naming an analysis that was never - * created is not asking an unanswerable question — it is naming nothing. Every other read on - * the surface throws when its subject is absent. - */ - test("an analysis that does not exist is refused, not reported on", async () => { - await expect( - session.reads.reproducibilityOf({ analysis: ref("analysis", "COMP_999999"), rebuilt: [] }), - ).rejects.toThrow(/COMP_999999/); - }); - - /** - * The distinction is the point, so it is asserted rather than left in prose -- where a prose - * guard can be an assertion, it should be. - */ - test("an absent subject and an empty one are not the same answer", async () => { - const { analysis } = await anAnalysisThatConsumedNothing(session); - const read = await afterwards(); - - const empty = await read.reads.reproducibilityOf({ analysis, rebuilt: [] }); - let ghost = "(no throw)"; - try { - await read.reads.reproducibilityOf({ analysis: ref("analysis", "COMP_999999"), rebuilt: [] }); - } catch (e) { - ghost = (e as Error).message; - } - - expect(empty.reproducible).toBe(false); - expect(ghost).not.toBe("(no throw)"); - }); -});