diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index eebd0b74f..ba21703b1 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,13 @@ +## 2026-10-07 — lance-graph #1387 and #1390 MERGED; D-PEARL-PROD-0 on branch + +- #1387 (`e6612dc`): D-PUZZLE-0 step 3, crossword mask propagation (four arms, cui-bono ablation). #1390 (`6368d8c`): D-PEARL-IO-0 probe, the CE64 upper-half diagram and its comparison page, README picture. No contract change in either. +- D-PEARL-PROD-0 (branch `claude/epistemicstate5-ce64-bits-3oe0dq`, unmerged): Pearl's ladder moves into production. `lance_graph_planner::pearl::{reason, revise, hydrate}` dispatch on bits 40..42 and revise bits 59..63 from the measurement. + +### Current Contract Inventory — net delta (`certification.rs`) + +- `lance_graph_contract::certification::{CertificationModel, ModelBuilder, PopulationMask, NotGrounded, Sat, compare, count, distinct_sources, OBSERVATION, INTERVENTION, MIN_SOURCES}` — the P7a obligations behind each `Certification3` code, as integer folds over unit masks. Generic over `PopulationMask` (a sub-trait of `revision::EvidenceMask` adding count / full / unit): `u64` (64 units, the default) or `[u64; N]` (`64 * N` units; `[u64; 1024]` is one 64k-row cycle). `certification()` returns the strongest code the model satisfies; `observational_certification()` cannot return `Causes`; only the executed randomized arms (`causes()`, at least two distinct `InterventionBacked` sources) can. Receipts after the seal do not count. Moved from `cognitive-shader-driver/examples/shared/certification_model.rs`, behaviour unchanged (the P7a probe's 13 tests pass against it). +- `lance_graph_contract::dismech_evidence::{DISMECH_CONCEPT_ID, citation_quorum, CitationQuorum}` — the DisMech concept id (`0x0333`, the G a chain's classid routes by; fused in `lance_graph_ogar::parity`) and the fold of `(CitationKey, Supports)` stances on one relation into an `ontology_warrant::Quorum` over distinct citations (`PARTIAL` and `NO_EVIDENCE` abstain; two-sided citations are kept and counted in `both_ways`). + ## 2026-10-07 — lance-graph #1382 and #1384 MERGED; D-PUZZLE-0 step 3 on branch - #1382 (`bccc46e`): Sudoku population-fold probe. #1384 (`23e8d93`): shared fold module + crossword probes (steps 2/2b). No contract change in either. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 9a9ab054e..602a0ba96 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -1,3 +1,17 @@ +## 2026-10-07 — lance-graph PR #1390 (merged `6368d8c`, branch `claude/epistemicstate5-ce64-bits-3oe0dq`, head `fec0613`, 5 commits) — D-PEARL-IO-0: Pearl's ladder executed in and out of CausalEdge64 + +- **Added:** `pearl_ladder_probe.rs` (bits 40..42 select an operator; the measurement revises bits 59..63); P7a model moved to `examples/shared/certification_model.rs`; `docs/architecture/ce64-semantic-upper-half.md` + diagram; README picture. +- **Locked (test-pinned):** 14 tests, 12 disable runs red; SO stops below `Causes`, only executed arms earn `Causes`, counterfactual reactions and confounding never certify or demote. +- **Deferred / open:** production placement (D-PEARL-PROD-0); whole-node removal; contingency search beyond one cause; whether bits 43..52 take the diagram's reading (needs a version gate). +- **Confidence:** high for the dispatch and write-back rules; the counterfactual fixture is one measured chain. + +## 2026-10-07 — lance-graph PR #1387 (merged `e6612dc`, branch `claude/epistemicstate5-ce64-bits-3oe0dq`, head `9f8b5e4`, 9 commits) — D-PUZZLE-0 step 3: crossword propagation as Cartesian-addressed masking + +- **Added:** `crossword_mask_propagation_probe.rs`: four arms (token, Cartesian, literal, hybrid) on identical puzzles, `MooreSymbol8` (probe-local), creation, cui-bono ablation; `deepnsm-v2` dev-dependency of the driver. +- **Locked (test-pinned):** 20 tests, 12 disable runs red; the four arms reach the same fixed point. +- **Deferred / open:** creation beyond 5×5 with no repeated words; `MooreSymbol8` as a canonical tenant. +- **Confidence:** medium — one machine, release; timings per the rotated best-of-5 protocol. + ## 2026-10-07 — lance-graph PR #1384 (merged `23e8d93`, branch `claude/epistemicstate5-ce64-bits-3oe0dq`, head `94499c3`, 9 commits) — D-PUZZLE-0 steps 2/2b: crosswords on the Sudoku population fold - **Added:** `examples/shared/population_fold.rs` (the domain-free half: propagation reading, five `facts_population` questions, three folds, declaration gate; a fence test bans domain words); `crossword_population_fold_probe.rs` (synthetic 3-letter alphabet); `crossword_real_words_probe.rs` (DeepNSM v1 4096 + `academic_20k`, DeReKo top 20k from `DEREKO_PATH`; NYT-rule grids; Down-and-Across answers from `XWORD_ANSWERS_DIR`). diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 588285f1b..953e05b7b 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -48,8 +48,9 @@ Plan: `.claude/plans/2026-10-06-global-sudoku-replayable-orchestration-v1.md`. O | **D-EPI-CANON-0** | Canonical joint `EpistemicState5` reading of bits 59..63 in the contract | Shipped (#1381) | `contract::epistemic_state5`: Cartesian `Topology2 × Certification3`, `raw5 = t \| c << 2`, 24 meaningful / 8 reserved; per-factor facts; declarations + `project_state5`; `CausalEdge64::{epistemic_raw5, with_epistemic_raw5}`; non-declared-reading writers deprecated | | **D-EPI-POP-0** | Facts → 32-state population: the missing operator between one edge's coordinate (u5) and a semantic condition over all coordinates (u32) | Shipped (#1381) | `contract::epistemic_state5::{Population, facts_population, EpistemicState5::bit}`: `const fn`, no DSL / query object / planner. Tests pin exact sets (`CAUSES` = {20..23}, `IND_UNKNOWN \| RELATED` = {10,14,18,22}, `IND_KNOWN \| SUPPORTS` = {13,17,21}, `RELATED` = 8..23), operator == state enumeration over all 2048 requirements with no reserved code, conjunction == intersection; 2 disable runs red (any-of semantics; reserved read as fact-less) | | **D-EPI-LEGACY-DEPROJECT-0** | Deproject the remaining `ReasoningBand` / CE64-`TrustTexture` consumers: each goes to an exact coordinate, a `facts_population(..)`, MUL, or another subsystem (e.g. counterfactual context) — never a 1:1 replacement enum | Shipped (#1381) | `entries/2026-10-07-epi-legacy-deprojection.md`: 6 ReasoningBand caller sites (1 certification question, 1 non-interference witness → both exact coordinates; 2 context/obsolete → compat; 2 refusal witnesses), 0 production CE64 `TrustTexture` consumers, 4 `TrustTexture` types with no bridge; `EdgeRole::band` removed; hinge + V3 lift migrated; 4 code loops → `facts_population` | -| **D-PEARL-IO-0** | Pearl's ladder executed in and out of `CausalEdge64`: bits 40..42 select the operator, the measurement revises bits 59..63 | In PR | `cognitive-shader-driver/examples/pearl_ladder_probe.rs`, `entries/2026-10-07-pearl-io-0-ladder-in-and-out-of-ce64.md`: SO → P7a observational folds, PO → P7a executed arms (only path to `Causes`), SPO → `counterfactual_replay` (reaction, never a certification), SP → SO-vs-PO direction; `hydrate` gates `IndirectUnknown → IndirectKnown` on both bindings. CUI BONO script: `IndirectUnknown × Related` → `IndirectKnown × Related` → `IndirectKnown × Causes`. 14 tests, 12 disable runs red. P7a model moved to `examples/shared/certification_model.rs` unchanged | -| **D-PUZZLE-0** | Sudoku / crossword / chess on the same population algebra under domain laws | In progress | step 1 Sudoku shipped (#1382). Step 2 crossword (`crossword_population_fold_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-same-algebra.md`): domain-free half moved to `examples/shared/population_fold.rs` (fence test, Sudoku counts unchanged); 1,000,052 crossword edges, counts equal 3 ways; filter 0.30–0.43 ns/edge in both domains. Step 2b real words (`crossword_real_words_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-real-words.md`): English = DeepNSM v1 4096 + academic_20k (committed), German = DeReKo top 20k only from `DEREKO_PATH` (CC BY-NC, nothing committed); ~1.0M edges each, counts equal 3 ways. Steps 2/2b merged (#1384). Step 3 (`crossword_mask_propagation_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-mask-propagation.md`): Morton cell + `(slot:offset)` lanes, DeepNSM-v2 `WordId` masks, `MooreSymbol8` letters; four arms on identical puzzles (EN 5×5: token 3.0 µs ≈ hybrid 2.9, Cartesian 5.1, literal ~1,045); with no repeated words, creation stops at 5×5 with the 18–20k vocabularies; cui-bono ablation of givens. Next: chess in `stockfish-rs` (GPL) — how it reaches the shared half is open | +| **D-PEARL-PROD-0** | Pearl's ladder in production: `lance_graph_planner::pearl::{reason, revise, hydrate}` over `lance_graph_contract::certification` | In PR | the P7a folds become `contract::certification::{CertificationModel, ModelBuilder}`; the dispatch, write-back and topology gate become `planner::pearl`, taking the caller's declared reading. The 14 falsifier tests run in the planner suite (CI); 12 disable runs red against the production module. No runtime caller yet: nothing in production produces sealed populations or chains for it (`entries/2026-10-07-pearl-prod-0-production-wiring.md`) | +| **D-PEARL-IO-0** | Pearl's ladder executed in and out of `CausalEdge64`: bits 40..42 select the operator, the measurement revises bits 59..63 | Shipped (#1390) | `cognitive-shader-driver/examples/pearl_ladder_probe.rs`, `entries/2026-10-07-pearl-io-0-ladder-in-and-out-of-ce64.md`: SO → P7a observational folds, PO → P7a executed arms (only path to `Causes`), SPO → `counterfactual_replay` (reaction, never a certification), SP → SO-vs-PO direction; `hydrate` gates `IndirectUnknown → IndirectKnown` on both bindings. CUI BONO script: `IndirectUnknown × Related` → `IndirectKnown × Related` → `IndirectKnown × Causes`. 14 tests, 12 disable runs red. P7a model moved to `examples/shared/certification_model.rs` unchanged | +| **D-PUZZLE-0** | Sudoku / crossword / chess on the same population algebra under domain laws | In progress | step 1 Sudoku shipped (#1382). Step 2 crossword (`crossword_population_fold_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-same-algebra.md`): domain-free half moved to `examples/shared/population_fold.rs` (fence test, Sudoku counts unchanged); 1,000,052 crossword edges, counts equal 3 ways; filter 0.30–0.43 ns/edge in both domains. Step 2b real words (`crossword_real_words_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-real-words.md`): English = DeepNSM v1 4096 + academic_20k (committed), German = DeReKo top 20k only from `DEREKO_PATH` (CC BY-NC, nothing committed); ~1.0M edges each, counts equal 3 ways. Steps 2/2b merged (#1384). Step 3, merged #1387 (`crossword_mask_propagation_probe.rs`, `entries/2026-10-07-puzzle-0-crossword-mask-propagation.md`): Morton cell + `(slot:offset)` lanes, DeepNSM-v2 `WordId` masks, `MooreSymbol8` letters; four arms on identical puzzles (EN 5×5: token 3.0 µs ≈ hybrid 2.9, Cartesian 5.1, literal ~1,045); with no repeated words, creation stops at 5×5 with the 18–20k vocabularies; cui-bono ablation of givens. Next: chess in `stockfish-rs` (GPL) — how it reaches the shared half is open | | **D-EPI-MIG-0** | Migrate readers/writers to the canonical reading; #1378 → conformance | Shipped (#1381) | `entries/2026-10-07-epi-mig-0.md`: affordance law (per-factor compile) + P7a (identity packing) + 6 probes + dismech + V3 lift; conformance census 24/0/8 (was 3/5/16); 3 historical-band probes cannot migrate without invention | | **D-EPI-CONFLICT-0** | (superseded by D-EPI-MIG-0: probe converted to conformance) Pin the disagreement between the D-ACR-7-declared P7a band and the affordance law on CE64 bits 59..63 | Superseded | **Current:** the same probe is the conformance test, census 24 agree / 0 disagree / 8 reserved under the Cartesian V1 (D-EPI-MIG-0). **HISTORICAL — the pre-Cartesian pins below no longer describe the code:** `cognitive-shader-driver/examples/epistemic_reading_conflict_probe.rs`: contract-projected `Causes` reads as affordance code 20 `Related` (topology 0) or refuses (1..3), `COUNTERFACTUAL_PROBE` never eligible; `CAUSES` codes 1 and 17 sit on P7a `Open` / `CausalCandidate`; codes 25, 30 on P7a reserved bands; census over 24 cells = 3 agree, 5 disagree, 16 refuse; the affordance law measures where the contract refuses (undeclared / band `Absent`). Both readings run from shared code (`shared/affordance_law.rs`, and P7a's reading moved unchanged into `shared/certification_reading.rs`, used by `relational_certification_probe` too), never copied. Pins of current state, to become agreement tests when the issue closes (`ISS-EPISTEMIC-READINGS-DISAGREE-ON-BITS-61-63`); 5 tests, 6 disable runs red (3 on the affordance codebook, 3 on P7a's: `Contributes` entailment, a reserved code admitted, `Causes` recoded) | | **D-MBX-CUTOVER-0** | ShaderDriver on MailboxSoA without BindSpace | In PR | `entries/2026-10-06-shaderdriver-bindspace-cutover.md`: every ShaderDriver → BindSpace edge classified (production state / shared resource / fallback / test oracle / fossil / dead). In `run()` the only mailbox-mode BindSpace read was the ontology; outside it, metrics and the builder read the singleton and `backing()` silently fell back for any mailbox not under id 0. Cutover: optional BindSpace, explicit mailbox selection with no fallback, ontology on the driver, metrics from the selected substrate; no temporal change. `tests/mailbox_cutover.rs` 8 tests, 8 disable runs red | diff --git a/.claude/board/entries/2026-10-07-pearl-prod-0-production-wiring.md b/.claude/board/entries/2026-10-07-pearl-prod-0-production-wiring.md new file mode 100644 index 000000000..818b192fc --- /dev/null +++ b/.claude/board/entries/2026-10-07-pearl-prod-0-production-wiring.md @@ -0,0 +1,171 @@ +# 2026-10-07 — D-PEARL-PROD-0: Pearl's ladder in production + +**Code:** `lance_graph_contract::certification`, `lance_graph_planner::pearl`. +**Status:** TEST-PINNED (14 tests in the planner suite; 12 disable runs red +against the production module). CURRENT-CONTRACT for the certification folds. + +## Placement + +| piece | home | why there | +|---|---|---| +| certification obligations (P7a folds, builder) | `lance_graph_contract::certification` | pure mask arithmetic over contract types (`SupportLedger`, `DatasetVersion`, `Certification3`); the contract is zero-dependency | +| `reason` / `revise` / `hydrate` | `lance_graph_planner::pearl` | needs `CausalEdge64` (causal-edge) and `counterfactual_replay`; the planner is the crate that depends on both, the same reason `dismech_counterfactual` lives there | + +`revise` and `hydrate` take the caller's declared reading (`Epi5Declarations`, +class, rail, provenance) instead of the probe's fixed class; an undeclared +class is refused. + +## What changed for CI + +The probe's tests needed the driver's `with-planner` feature, and CI runs the +driver with default features only, so the 14 falsifiers never ran in CI. They +now run in `cargo test -p lance-graph-planner`. The examples keep their +behaviour: `examples/shared/certification_model.rs` re-exports the contract +module (P7a 13/13 and the reading-conflict probe 9/9 pass unchanged), and +`pearl_ladder_probe` prints the same script through the production modules. + +## Open + +- No runtime caller. Nothing in production builds a sealed population or a + sealed chain and hands it to `reason`; the API is in place for the first + producer. (`ISS-NO-EVIDENCE-WRITER-FOR-EPISTEMIC-STATE` describes the same + gap from the register's side.) +- ~~`CertificationModel` is capped at 64 units (one `u64` per mask).~~ Lifted in the same PR; see "Any population width" below. +- The planner carries four older clippy findings in `nested_bands.rs` and + `cache/nars_engine.rs`; CI does not gate planner clippy. `pearl.rs` is clean. + +## Rename (same PR) + +`dismech_replay` and `dismech_counterfactual` never read a DisMech predicate: +the ordinal is an opaque `u8` witness. They are now `chain_replay` and +`chain_counterfactual`. The one DisMech-specific piece — `chain_step_predicate`, +`UnmintedOrdinal`, `validate_chain` — moved to `dismech_admission` (later in this PR: `chain_admission`, keyed by classid; see below). The old +module paths stay as `#[deprecated]` re-export aliases, pinned by +`the_old_module_paths_still_name_the_same_items`. No behaviour change: planner +lib 450 passed / 3 ignored, `house_differential` 6/6, `reasoning_band_probe` 7/7. + +## Any population width (same PR) + +The certification folds use only intersection, difference and a population +count, so `CertificationModel` / `ModelBuilder` / `compare` / `count` are now +generic over `PopulationMask` — a sub-trait of the existing +`revision::EvidenceMask` (the mask `dismech_candidates` already uses) that adds +`count`, `full` and `unit`. `u64` is the default type parameter, so every +existing caller is unchanged; `[u64; N]` holds `64 * N` units, and +`[u64; 1024]` matches the 64k-row cycle `persist_sink` seals. `pearl::Evidence` +and `pearl::reason` take the same parameter. `ModelBuilder::new()` stays on +`u64` so it infers without annotation; `default()` builds any width. + +Falsifiers (contract `certification::tests`, planner `pearl::tests`): +- `every_carrier_width_certifies_a_fixture_identically`: four fixtures + (CausalCandidate, Open, Related, Causes) give the same six folds in `u64`, + `[u64; 2]` and `[u64; 1024]`. +- `units_past_the_first_word_are_counted` and + `a_population_wider_than_64_units_is_decided` (with an equal-rates silence + twin). Disable run: counting only word 0 of `[u64; N]` turns both red. +- `units_stop_at_capacity`, `the_builder_refuses_past_capacity`. +- `the_operators_read_a_population_wider_than_64_units`: SO earns + CausalCandidate, PO earns Causes and writes it back, and SP reports no + confounding, over 300 + 200 units past the first word. Null arms earn nothing. + +## What else was checked for reuse, and why it is not wired + +- **No producer of sealed evidence exists yet.** The shader driver emits edges + whose Pearl projection comes from resonance predicates, with no population + behind them; `persist_sink` is storage-only; `AuditedRelation` has no + callers. The natural first producers are `lance-graph-arm-discovery` + (`RowMasks` are row bitsets, so a mined rule A→B is an SO question over + exposed = rows with A, outcome = rows with B, capped observationally) and a + sealed 64k cycle. Neither is wired: arm-discovery has one source per + dataset, and `MIN_SOURCES = 2`, so a rule would be `TooFewSources` until it + is decided what counts as a distinct source (basins, environments, datasets). +- **`cache::nars_engine` "Pearl rung 2/3"** (`Inference::Intervention` / + `Counterfactual`) is truth arithmetic over two heads (abduction ×0.85, + deduction ×0.70), not executed arms or a replay. It writes truth and the + mantissa, never bits 59..63, so it does not bypass the certification rule; + it is not routed into `pearl` because it measures nothing `pearl` could + certify from. +- **`AuditedRelation::is_intervention_established`** treats one + `InterventionBacked` receipt plus a causal classification as established, + without arms and below `MIN_SOURCES`. That is a second, weaker definition of + the same question. It has no callers; left as is, recorded here. + +## Generic, specific via classid as SPO-G (same PR) + +A chain step's predicate ordinal only means something inside a vocabulary, +and the vocabulary is chosen by the chain's classid. The loco floor is +shared: `0x90` is DisMech `causes`, NARS recipe #1, and the first r2il op. +OGAR already routes this way (`VocabularyRegistry::resolve_classid`, +concept = `classid >> 16`, DisMech = `0x0333`); the admission check did not. + +- `planner::chain_admission` replaces `dismech_admission`. + `validate_chain(classid, chain)` and `chain_step_predicate(classid, step)` + route by the concept half (G) through `PALETTES` (today one entry, DisMech) + and refuse an unmirrored concept with `Unadmitted::UnknownPalette` instead of + reading its bytes as DisMech's. +- `contract::dismech_evidence::DISMECH_CONCEPT_ID = 0x0333`, fused in + `lance_graph_ogar::parity::assert_dismech_palette_parity` (passes against + OGAR; disable run with `0x0334` fails with the concept-id message). +- The deprecated `dismech_replay` alias keeps DisMech-bound wrappers, pinned to + answer exactly as admission under `DISMECH_CLASSID`. +- Tests: `the_same_byte_is_admitted_only_under_the_classid_whose_palette_mints_it` + (RO `0x0306` is refused, even for an empty chain) and + `only_the_concept_half_routes` (four app prefixes route the same; another + concept with the same low half does not). Disable run: `palette_of` ignoring + the classid turns both red. + +## Palette × evidence: citations into a quorum (same PR) + +`contract::dismech_evidence::citation_quorum` folds `(CitationKey, Supports)` +pairs for one relation into the contract's `ontology_warrant::Quorum`: + +- the source is the citation identity, so a citation repeated across rows is + one source and `PMID:1` / `ORPHA:1` are two; +- `SUPPORT` corroborates, `REFUTE` conflicts, a citation saying both counts on + both sides and is reported in `both_ways` (kept, not removed); +- `NO_EVIDENCE` is silence (the quorum's own rule); `PARTIAL` is silence by + policy pin, matching `dismech_candidates`, which keeps it inert. + +This is the per-relation input the W2b field map needs (`+` agreement, `−` +disagreement, `0` silence) and the one P7's downgrade rule read. The field +map itself (global sweep, convergence, node-level hydrate) is not built; W2b +is still a proposal awaiting scope. Disable runs: no deduplication, +`PARTIAL` as support, and two-sided citations counted once each turn their +named tests red. + +## reasoning_band_probe (#1360, P7) against the last 25 PRs + +| P7 feature | where it lives now | relation | +|---|---|---| +| Pearl mask selects the test (SO/PO/SPO/SP) | `planner::pearl::Operation::of` (#1391) | duplicate | +| a pass is computed, never supplied | `pearl::Measured` private fields (#1391) | duplicate | +| intervention: one trial, treated rate higher | `CertificationModel::causes` (#1369, ≥ 2 intervention sources) | superseded, stricter | +| counterfactual: removal attack | `chain_counterfactual` (W3); P7a: removal is not the causal test; SPO earns nothing in `pearl` | superseded | +| confounding via `simpsons_paradox_risk` | `pearl` SP | same check, different effect: P7 caps the band; `pearl` never demotes an intervention-certified `Causes` (f6) | +| contradicting `Quorum` caps (minority) or drops (majority) the band | no counterpart in `pearl` (rise-only) | **open**; #1379 demotes on `ContradictionPreserved`; #1368/#1380 fold observations into a quorum; `citation_quorum` now produces the input | +| band written through `ReasoningBand` names | deprecated (D-EPI-MIG-0) | probe-only legacy | + +Also related in the window: #1371 (Witness as an SPO-G sub-context under the +same `classid >> 16` rule; its open `ISS-MAILBOX-ROUTES-WITNESS-WITHOUT-GRAPH` +is the routing-ignores-G defect `chain_admission` avoids) and #1370 (recipe +eligibility read from the joint 59..63 code). + +OPEN: one demotion policy. `pearl` only raises, P7 lowered on contradiction, +#1379 lowers on a suspended revision. Which (if any) contradiction lowers a +certification is undecided. + +## Review fixes (codex + CodeRabbit on #1391) + +- `revise(&Measured, Reading)` takes no edge: `Measured` binds the edge it + measured, so one measurement cannot promote another edge. +- An SO measurement whose weakest rung cannot compare (`associated()` is + `Err`) reports `Ungrounded::Model`, so missing evidence is no longer read as + a grounded negative. +- `hydrate` promotes only when the edge is the `a → y` relation whose path it + checked. +- `Reaction::classify` compares terminal `(frequency, confidence)` only. The + old length comparison made `Inert` unreachable, since a cut always removes a + step. Measured: no cut on the test chains reaches `Inert` (the quantized + revision moves the truth even for a zero-confidence step), so the rule is + pinned at the classifier. +- Each fix has a test, and each fails under a disable run. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 8f6fc5d57..9c8f963e8 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -255 entries, 2026-08-06 .. 2026-10-07. +256 entries, 2026-08-06 .. 2026-10-07. | date | entry id | finding | file | |---|---|---|---| @@ -35,6 +35,7 @@ exactly one of them, never both. | 2026-10-07 | `D-PUZZLE-0-XWORD` | | [2026-10-07-puzzle-0-crossword-same-algebra.md](2026-10-07-puzzle-0-crossword-same-algebra.md) | | 2026-10-07 | `D-PUZZLE-0-XWORD-REAL` | | [2026-10-07-puzzle-0-crossword-real-words.md](2026-10-07-puzzle-0-crossword-real-words.md) | | 2026-10-07 | `D-PUZZLE-0-XWORD-MASK` | | [2026-10-07-puzzle-0-crossword-mask-propagation.md](2026-10-07-puzzle-0-crossword-mask-propagation.md) | +| 2026-10-07 | `D-PEARL-PROD-0` | | [2026-10-07-pearl-prod-0-production-wiring.md](2026-10-07-pearl-prod-0-production-wiring.md) | | 2026-10-07 | `D-PEARL-IO-0` | | [2026-10-07-pearl-io-0-ladder-in-and-out-of-ce64.md](2026-10-07-pearl-io-0-ladder-in-and-out-of-ce64.md) | | 2026-10-07 | `gated-gather-bit-schedule` | Production gather is 3.8× slow from a per-row branch; bit-gated visitation wins or ties at every density; ordinal vectors never win (R1 holds) | [2026-10-07-gated-gather-bit-schedule.md](2026-10-07-gated-gather-bit-schedule.md) | | 2026-10-07 | `D-EPI-MIG-INVENTORY` | | [2026-10-07-epi-mig-inventory.md](2026-10-07-epi-mig-inventory.md) | diff --git a/crates/cognitive-shader-driver/examples/README.md b/crates/cognitive-shader-driver/examples/README.md index 0fba453ae..55276c333 100644 --- a/crates/cognitive-shader-driver/examples/README.md +++ b/crates/cognitive-shader-driver/examples/README.md @@ -462,7 +462,7 @@ cargo run --release -p cognitive-shader-driver --example crossword_mask_propagat D-PEARL-IO-0. Pearl's ladder executed in and out of `CausalEdge64`. Bits 40..42 select an existing operator: SO runs the P7a observational folds, PO -the P7a executed randomized arms, SPO `dismech_counterfactual::counterfactual_replay` +the P7a executed randomized arms, SPO `chain_counterfactual::counterfactual_replay` with one route cut, SP the SO-vs-PO direction check. `revise` raises bits 59..63 only to what the operator earned; `hydrate` moves `IndirectUnknown` to `IndirectKnown` only when both bindings of the path are sealed. diff --git a/crates/cognitive-shader-driver/examples/pearl_ladder_probe.rs b/crates/cognitive-shader-driver/examples/pearl_ladder_probe.rs index 135f76f59..b8b91037b 100644 --- a/crates/cognitive-shader-driver/examples/pearl_ladder_probe.rs +++ b/crates/cognitive-shader-driver/examples/pearl_ladder_probe.rs @@ -18,7 +18,7 @@ //! //! Every operator is existing code: the P7a sealed model and its folds //! (`shared/certification_model.rs`, moved unchanged from -//! `relational_certification_probe`), `lance_graph_planner::dismech_counterfactual` +//! `relational_certification_probe`), `lance_graph_planner::chain_counterfactual` //! (one replay path for both arms), `CausalMask::simpsons_paradox_risk`, and //! the canonical `EpistemicState5` declarations. What this probe adds is the //! dispatch (`pearl::reason`), the write-back rule (`pearl::revise`) and the @@ -53,22 +53,25 @@ //! //! # Not decided here //! -//! - Production placement of `reason` / `revise`. This is a probe. +//! - Production placement: done (D-PEARL-PROD-0). The dispatch is +//! `lance_graph_planner::pearl`, the certification folds are +//! `lance_graph_contract::certification`; the falsifier tests live with them. //! - Whole-node removal of B: in a two-step chain B's only mechanisms are the //! two routes, so route cuts are run and node removal is not. //! - A responsibility framework beyond the single contingency. //! //! Run: `cargo run -p cognitive-shader-driver --features with-planner --example pearl_ladder_probe` -//! Tests: `cargo test -p cognitive-shader-driver --features with-planner --example pearl_ladder_probe` use causal_edge::edge::{CausalEdge64, InferenceType}; use causal_edge::pearl::CausalMask; use causal_edge::plasticity::PlasticityState; use causal_edge::tables::NarsTables; +use lance_graph_contract::band_reading::EdgeProvenance; use lance_graph_contract::causal_audit::SupportBasis; use lance_graph_contract::epistemic_state5::{Certification3, Topology2}; -use lance_graph_planner::dismech_counterfactual::{CutContext, DEFAULT_FREQUENCY_BAR}; -use lance_graph_planner::dismech_replay::{ChainStep, ComposeTables}; +use lance_graph_planner::chain_counterfactual::{CutContext, DEFAULT_FREQUENCY_BAR}; +use lance_graph_planner::chain_replay::{ChainStep, ComposeTables}; +use lance_graph_planner::pearl::{hydrate, reason, revise, Chain, Edit, Evidence, Reading}; #[path = "shared/certification_model.rs"] mod certification_model; @@ -78,305 +81,6 @@ mod certification_reading; use certification_model::*; use certification_reading::*; -// ── The operator layer ──────────────────────────────────────────────────── - -/// Dispatch, measurement and write-back. A separate module so that a -/// [`pearl::Measured`] can only be produced by running an operator: its -/// fields are private, so no caller outside this module can assert that an -/// intervention or a counterfactual passed. -mod pearl { - use super::certification_model::{compare, Model, NotGrounded}; - use super::certification_reading::{declarations, stamp, Contract, Refusal, CERT_CLASS, RAIL}; - use causal_edge::edge::CausalEdge64; - use causal_edge::pearl::CausalMask; - use lance_graph_contract::band_reading::EdgeProvenance; - use lance_graph_contract::epistemic_state5::{Certification3, Epi5Gen, Topology2}; - use lance_graph_planner::dismech_counterfactual::{counterfactual_replay, CutContext, Verdict}; - use lance_graph_planner::dismech_replay::ChainStep; - - /// The operator bits 40..42 select. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Operation { - /// `SO`: observational folds. - Association, - /// `PO`: executed randomized arms. - Intervention, - /// `SPO`: counterfactual replay with one route cut. - Counterfactual, - /// `SP`: observational direction against interventional direction. - ConfounderCheck, - /// A projection with no operator here (`None`, `O`, `P`, `S`). - NoOperator(CausalMask), - } - - impl Operation { - pub fn of(mask: CausalMask) -> Self { - match mask { - CausalMask::SO => Operation::Association, - CausalMask::PO => Operation::Intervention, - CausalMask::SPO => Operation::Counterfactual, - CausalMask::SP => Operation::ConfounderCheck, - other => Operation::NoOperator(other), - } - } - } - - /// Why an operator could not decide. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Ungrounded { - /// A P7a fold lacked what it needs. - Model(NotGrounded), - /// A counterfactual was asked for without a sealed chain. - NoChain, - /// A counterfactual was asked for without an edit. - NoEdit, - /// The edit names no step of the chain. - CutOutOfRange(usize), - /// The replay refused (sequence reservation). - Replay, - /// One direction of the confounder check is a tie. - NoDirection, - /// The projection selects no operator. - NoOperator, - } - - /// What one route cut did to the chain's answer, with both frequencies. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Reaction { - /// The verdict changed. - LoadBearing { factual: u8, counterfactual: u8 }, - /// The truth moved, the verdict did not. - TruthOnly { factual: u8, counterfactual: u8 }, - /// Nothing moved. - Inert { frequency: u8 }, - } - - /// A counterfactual edit. The base generation is never modified; the - /// operator replays the same chain with the edit applied. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Edit { - None, - /// Mask one route (one step) of the chain. - CutStep(usize), - } - - /// The sealed evidence an operator may read. Read-only. - pub struct Evidence<'a> { - pub model: &'a Model, - pub chain: Option>, - } - - /// A sealed chain for the counterfactual replay. - #[derive(Clone, Copy)] - pub struct Chain<'a> { - pub steps: &'a [ChainStep], - pub seed: CausalEdge64, - pub cx: CutContext<'a>, - } - - /// The result of running one operator. Only [`reason`] constructs it. - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct Measured { - operation: Operation, - earned: Option, - ungrounded: Option, - reaction: Option, - confounded: Option, - /// The counterfactual arm's terminal edge, kept as witness. Never - /// written back. - counterfactual_terminal: Option, - } - - impl Measured { - pub fn operation(&self) -> Operation { - self.operation - } - pub fn earned(&self) -> Option { - self.earned - } - pub fn ungrounded(&self) -> Option { - self.ungrounded - } - pub fn reaction(&self) -> Option { - self.reaction - } - pub fn confounded(&self) -> Option { - self.confounded - } - pub fn counterfactual_terminal(&self) -> Option { - self.counterfactual_terminal - } - - fn new(operation: Operation) -> Self { - Measured { - operation, - earned: None, - ungrounded: None, - reaction: None, - confounded: None, - counterfactual_terminal: None, - } - } - } - - /// Run the operator the edge's projection selects. Reads bits 40..42 and - /// nothing else of the edge: the epistemic state, mantissa and truth are - /// not inputs to the choice. - pub fn reason(edge: CausalEdge64, ev: &Evidence<'_>, edit: Edit) -> Measured { - let op = Operation::of(edge.causal_mask()); - let mut out = Measured::new(op); - let m = ev.model; - match op { - Operation::Association => match m.sourced() { - Err(e) => out.ungrounded = Some(Ungrounded::Model(e)), - Ok(()) => { - let c = m.certify_observational(); - if c != Contract::Open { - out.earned = Some(c.certification()); - } - } - }, - Operation::Intervention => match m.causes() { - Ok(true) => out.earned = Some(Certification3::Causes), - Ok(false) => {} - Err(e) => out.ungrounded = Some(Ungrounded::Model(e)), - }, - Operation::Counterfactual => { - let Some(chain) = ev.chain else { - out.ungrounded = Some(Ungrounded::NoChain); - return out; - }; - let Edit::CutStep(i) = edit else { - out.ungrounded = Some(Ungrounded::NoEdit); - return out; - }; - match counterfactual_replay(chain.steps, i, chain.seed, chain.cx) { - None => out.ungrounded = Some(Ungrounded::CutOutOfRange(i)), - Some(Err(_)) => out.ungrounded = Some(Ungrounded::Replay), - Some(Ok(cf)) => { - let f = cf.factual.last().map_or(0, |r| r.edge.frequency_u8()); - let c = cf - .counterfactual - .last() - .map_or(0, |r| r.edge.frequency_u8()); - out.reaction = Some(if cf.role.factual != cf.role.counterfactual { - Reaction::LoadBearing { - factual: f, - counterfactual: c, - } - } else if f != c || cf.factual.len() != cf.counterfactual.len() { - Reaction::TruthOnly { - factual: f, - counterfactual: c, - } - } else { - Reaction::Inert { frequency: f } - }); - out.counterfactual_terminal = cf.counterfactual.last().map(|r| r.edge); - debug_assert!(matches!( - cf.role.factual, - Verdict::Consistent | Verdict::Inconsistent - )); - } - } - } - Operation::ConfounderCheck => { - let obs = direction(m.outcome, m.universe & m.exposed, m.universe & !m.exposed); - let trial = direction(m.outcome, m.trial & m.assigned, m.trial & !m.assigned); - match (obs, trial) { - (Ok(Some(so)), Ok(Some(po))) => { - out.confounded = Some(CausalMask::simpsons_paradox_risk(so, po)); - } - (Err(e), _) | (_, Err(e)) => out.ungrounded = Some(Ungrounded::Model(e)), - _ => out.ungrounded = Some(Ungrounded::NoDirection), - } - } - Operation::NoOperator(_) => out.ungrounded = Some(Ungrounded::NoOperator), - } - out - } - - /// The direction triad `simpsons_paradox_risk` reads: bit 2 set when the - /// outcome is worse (here: rarer) in the exposed group. `None` on a tie. - fn direction(outcome: u64, a: u64, b: u64) -> Result, NotGrounded> { - if compare(outcome, a, b, true)? { - Ok(Some(0b000)) - } else if compare(outcome, b, a, true)? { - Ok(Some(0b100)) - } else { - Ok(None) - } - } - - /// Write a measurement back. Certification rises to what the operator - /// earned when that is strictly stronger; nothing else moves, and nothing - /// is ever lowered. - pub fn revise(edge: CausalEdge64, m: &Measured) -> Result { - let decl = declarations(); - let state = decl - .project_state5( - CERT_CLASS, - RAIL, - Epi5Gen::V1, - edge.epistemic_raw5(), - EdgeProvenance::V2Stamped, - ) - .map_err(Refusal::Reading)?; - let current = Contract::from_certification(state.certification()); - let next = match m.earned.map(Contract::from_certification) { - Some(c) if c != current && c.entails(current) => c, - _ => current, - }; - Ok(stamp(edge, next, state.topology())) - } - - /// What a candidate intermediate B turned out to be. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Hydration { - /// No binding of B to the path exists in the sealed chain. - ProposedOnly, - /// One of `A → B`, `B → Y` exists. - Partial, - /// Both bindings exist. - Hydrated, - } - - /// The topology gate. `IndirectUnknown` becomes `IndirectKnown` only when - /// both bindings `a → b` and `b → y` are present in the sealed chain. - /// Certification never moves here. - pub fn hydrate( - edge: CausalEdge64, - (a, b, y): (u8, u8, u8), - sealed: &[ChainStep], - ) -> Result<(CausalEdge64, Hydration), Refusal> { - let has = |s: u8, o: u8| sealed.iter().any(|(_, e)| e.s_idx() == s && e.o_idx() == o); - let found = match (has(a, b), has(b, y)) { - (true, true) => Hydration::Hydrated, - (false, false) => Hydration::ProposedOnly, - _ => Hydration::Partial, - }; - let state = declarations() - .project_state5( - CERT_CLASS, - RAIL, - Epi5Gen::V1, - edge.epistemic_raw5(), - EdgeProvenance::V2Stamped, - ) - .map_err(Refusal::Reading)?; - let topology = - if found == Hydration::Hydrated && state.topology() == Topology2::IndirectUnknown { - Topology2::IndirectKnown - } else { - state.topology() - }; - let contract = Contract::from_certification(state.certification()); - Ok((stamp(edge, contract, topology), found)) - } -} - -use pearl::*; - // ── Fixtures ────────────────────────────────────────────────────────────── const A: u8 = 10; @@ -539,6 +243,13 @@ fn topology(e: CausalEdge64) -> Topology2 { // ── The script ──────────────────────────────────────────────────────────── fn main() { + let decl = declarations(); + let rd = Reading { + declarations: &decl, + class: CERT_CLASS, + rail: RAIL, + provenance: EdgeProvenance::V2Stamped, + }; let rp = Replay::new(); let mut with_trial = related_population(); add_positive_trial(&mut with_trial); @@ -566,21 +277,21 @@ fn main() { show("START", start); let m = reason(start, &ev, Edit::None); - let e = revise(start, &m).unwrap(); + let e = revise(&m, rd).unwrap(); println!(" {:?} measured: earned {:?}", m.operation(), m.earned()); show("after OBSERVATION", e); - let (same, h) = hydrate(e, (A, 99, Y), &rp.steps).unwrap(); + let (same, h) = hydrate(e, (A, 99, Y), &rp.steps, rd).unwrap(); println!(" candidate 99 proposed: {h:?}"); show("after PROPOSING an unbound candidate", same); - let (e, h) = hydrate(e, (A, B, Y), &rp.steps).unwrap(); + let (e, h) = hydrate(e, (A, B, Y), &rp.steps, rd).unwrap(); println!(" candidate B: {h:?}"); show("after HYDRATING A -> B -> Y", e); let e = with_mask(e, CausalMask::PO); let m = reason(e, &ev, Edit::None); - let e = revise(e, &m).unwrap(); + let e = revise(&m, rd).unwrap(); println!( " PO measured: earned {:?}, ungrounded {:?}", m.earned(), @@ -591,7 +302,7 @@ fn main() { let e = with_mask(e, CausalMask::SPO); for (cut, route) in [(1usize, "B -> Y"), (0, "A -> B")] { let m = reason(e, &ev, Edit::CutStep(cut)); - let after = revise(e, &m).unwrap(); + let after = revise(&m, rd).unwrap(); println!( " SPO mask {route}: reaction {:?}, earned {:?}, cf terminal mantissa {:?}", m.reaction(), @@ -622,424 +333,5 @@ fn main() { }, Edit::None, ); - show(" randomized arms, PO", revise(q, &m).unwrap()); -} - -#[cfg(test)] -mod tests { - use super::*; - use causal_edge::layout::EPISTEMIC_MASK; - - fn evidence<'a>(model: &'a Model, rp: &'a Replay) -> Evidence<'a> { - Evidence { - model, - chain: Some(rp.chain()), - } - } - - /// Run one operator and write it back. - fn step( - e: CausalEdge64, - mask: CausalMask, - ev: &Evidence<'_>, - edit: Edit, - ) -> (CausalEdge64, Measured) { - let e = with_mask(e, mask); - let m = reason(e, ev, edit); - (revise(e, &m).expect("declared"), m) - } - - /// The whole script, pinned: the edge leaves with a different state only - /// where an operator earned it. - #[test] - fn cui_bono_script_moves_the_state_only_where_evidence_earned_it() { - let rp = Replay::new(); - let mut b = related_population(); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - let start = query( - CausalMask::SO, - Contract::Related, - Topology2::IndirectUnknown, - ); - - let (e, m) = step(start, CausalMask::SO, &ev, Edit::None); - assert_eq!(m.earned(), Some(Certification3::Related)); - assert_eq!( - (topology(e), certification(e)), - (Topology2::IndirectUnknown, Certification3::Related) - ); - - let (e, h) = hydrate(e, (A, B, Y), &rp.steps).unwrap(); - assert_eq!(h, Hydration::Hydrated); - assert_eq!( - (topology(e), certification(e)), - (Topology2::IndirectKnown, Certification3::Related) - ); - - let (e, m) = step(e, CausalMask::PO, &ev, Edit::None); - assert_eq!(m.earned(), Some(Certification3::Causes)); - assert_eq!( - (topology(e), certification(e)), - (Topology2::IndirectKnown, Certification3::Causes) - ); - - let (after, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(1)); - assert_eq!( - m.reaction(), - Some(Reaction::LoadBearing { - factual: 185, - counterfactual: 120 - }) - ); - assert_eq!(after.epistemic_raw5(), e.epistemic_raw5()); - let (_, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(0)); - assert_eq!( - m.reaction(), - Some(Reaction::TruthOnly { - factual: 185, - counterfactual: 225 - }) - ); - } - - /// F1. Observation alone never reaches `Causes`, even when the sealed - /// model also holds a positive trial the SO operator could have read. - #[test] - fn f1_observation_alone_never_promotes_to_causes() { - let mut fixtures = vec![ - related_population(), - robust_association(), - simpson_population(), - ]; - let mut many = robust_association(); - many.sources(SupportBasis::DirectlyObserved, &(3..40).collect::>()); - fixtures.push(many); - let rp = Replay::new(); - for mut b in fixtures { - add_positive_trial(&mut b); - let model = b.build(); - assert_eq!( - model.certify(), - Contract::Causes, - "the trial is there to be ignored" - ); - let ev = evidence(&model, &rp); - let (e, m) = step( - query(CausalMask::SO, Contract::Open, Topology2::Direct), - CausalMask::SO, - &ev, - Edit::None, - ); - assert_ne!(m.earned(), Some(Certification3::Causes)); - assert_ne!(certification(e), Certification3::Causes); - assert!( - m.earned().is_some(), - "anti-vacuity: SO must earn something here" - ); - } - } - - /// F2. Setting `SPO` certifies nothing: with no chain the operator is not - /// grounded and the state is unchanged. - #[test] - fn f2_setting_the_projection_certifies_nothing() { - let model = related_population().build(); - let ev = Evidence { - model: &model, - chain: None, - }; - for c in Contract::ALL { - let q = query(CausalMask::SPO, c, Topology2::IndirectUnknown); - let m = reason(q, &ev, Edit::CutStep(1)); - assert_eq!(m.ungrounded(), Some(Ungrounded::NoChain)); - assert_eq!(revise(q, &m).unwrap(), q); - } - } - - /// F3. A load-bearing counterfactual reaction does not become `Causes`. - #[test] - fn f3_a_load_bearing_cut_is_not_causes() { - let rp = Replay::new(); - let model = related_population().build(); - let ev = evidence(&model, &rp); - for c in [Contract::Open, Contract::Related, Contract::CausalCandidate] { - let q = query(CausalMask::SPO, c, Topology2::IndirectKnown); - let m = reason(q, &ev, Edit::CutStep(1)); - assert!(matches!(m.reaction(), Some(Reaction::LoadBearing { .. }))); - assert_eq!(m.earned(), None); - assert_eq!(certification(revise(q, &m).unwrap()), c.certification()); - } - } - - /// F4. The counterfactual arm is tagged −6 and is never written back as - /// observed truth: the revised edge keeps its own frequency, confidence - /// and mantissa. - #[test] - fn f4_the_counterfactual_branch_is_not_written_back() { - let rp = Replay::new(); - let model = related_population().build(); - let ev = evidence(&model, &rp); - let q = query(CausalMask::SPO, Contract::Related, Topology2::IndirectKnown); - let m = reason(q, &ev, Edit::CutStep(1)); - let t = m.counterfactual_terminal().expect("replayed"); - assert_eq!( - t.inference_mantissa(), - InferenceType::Counterfactual.to_mantissa() - ); - assert_ne!( - t.frequency_u8(), - q.frequency_u8(), - "anti-vacuity: the arm differs" - ); - let out = revise(q, &m).unwrap(); - assert_eq!(out.frequency_u8(), q.frequency_u8()); - assert_eq!(out.confidence_u8(), q.confidence_u8()); - assert_eq!(out.inference_mantissa(), q.inference_mantissa()); - } - - /// F5. Proposing B changes nothing; half a path changes nothing; only both - /// bindings move the topology, and never the certification. - #[test] - fn f5_a_proposed_beneficiary_is_not_evidence() { - let rp = Replay::new(); - let q = query( - CausalMask::SO, - Contract::Related, - Topology2::IndirectUnknown, - ); - let (e, h) = hydrate(q, (A, 99, Y), &rp.steps).unwrap(); - assert_eq!((e, h), (q, Hydration::ProposedOnly)); - let half = &rp.steps[..1]; - let (e, h) = hydrate(q, (A, B, Y), half).unwrap(); - assert_eq!((e, h), (q, Hydration::Partial)); - let (e, h) = hydrate(q, (A, B, Y), &rp.steps).unwrap(); - assert_eq!(h, Hydration::Hydrated); - assert_eq!(topology(e), Topology2::IndirectKnown); - assert_eq!(certification(e), Certification3::Related); - } - - /// F6. Simpson: SO and PO disagree in direction, SP reports it, and the - /// diagnostic does not demote an intervention-certified `Causes`. - #[test] - fn f6_simpson_does_not_demote_intervention_causes() { - let rp = Replay::new(); - let mut b = Builder::new(); - // Pooled: exposed rarer outcome (4/10 vs 7/10); trial: exposed better. - b.cell(0, true, 8, 2); - b.cell(0, false, 2, 0); - b.cell(1, true, 2, 2); - b.cell(1, false, 8, 7); - b.sources(SupportBasis::DirectlyObserved, &[1, 2]); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - let q = query(CausalMask::PO, Contract::Open, Topology2::Direct); - let (e, _) = step(q, CausalMask::PO, &ev, Edit::None); - assert_eq!(certification(e), Certification3::Causes); - let (after, m) = step(e, CausalMask::SP, &ev, Edit::None); - assert_eq!(m.confounded(), Some(true)); - assert_eq!(certification(after), Certification3::Causes); - // And SO on the same population does not lower it either. - let (after, _) = step(after, CausalMask::SO, &ev, Edit::None); - assert_eq!(certification(after), Certification3::Causes); - } - - /// F6 silence twin: same-direction populations are not reported - /// confounded. - #[test] - fn f6_agreeing_directions_are_not_confounded() { - let rp = Replay::new(); - let mut b = robust_association(); - add_positive_trial(&mut b); - let model = b.build(); - let (_, m) = step( - query(CausalMask::SP, Contract::Open, Topology2::Direct), - CausalMask::SP, - &evidence(&model, &rp), - Edit::None, - ); - assert_eq!(m.confounded(), Some(false)); - } - - /// F7. Y = A or B: removing A where B holds shows no reaction, the - /// contingency "B disabled" shows one, and the trial certifies `Causes`. - /// A null removal never demotes. - #[test] - fn f7_one_at_a_time_removal_is_not_the_definition_of_causality() { - let (trial, gate) = overdetermined_trial(); - let unit = (gate.a & gate.b).isolate_lowest_one(); - assert_ne!(unit, 0); - assert!(!gate.but_for(unit), "B keeps Y: no reaction"); - assert!( - gate.but_for_given(unit, gate.b), - "with B disabled, removing A removes Y" - ); - let a_only = (gate.a & !gate.b).isolate_lowest_one(); - assert!( - gate.but_for(a_only), - "anti-vacuity: removal can react at all" - ); - - let rp = Replay::new(); - let ev = Evidence { - model: &trial, - chain: Some(rp.chain()), - }; - let (e, _) = step( - query(CausalMask::PO, Contract::Open, Topology2::Direct), - CausalMask::PO, - &ev, - Edit::None, - ); - assert_eq!(certification(e), Certification3::Causes); - // A counterfactual with no verdict change on this edge must not lower it. - let (after, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(0)); - assert!(matches!(m.reaction(), Some(Reaction::TruthOnly { .. }))); - assert_eq!(certification(after), Certification3::Causes); - } - - /// F8. Every operator writes bits 59..63 only. - #[test] - fn f8_only_bits_59_to_63_move() { - let rp = Replay::new(); - let mut b = related_population(); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - let mut moved = 0; - for v in 0u8..8 { - let mask = CausalMask::from_bits(v); - for mantissa in [-6i8, 0, 6] { - let q = query(mask, Contract::Open, Topology2::IndirectUnknown) - .with_inference_mantissa(mantissa); - let m = reason(q, &ev, Edit::CutStep(1)); - let out = revise(q, &m).unwrap(); - assert_eq!((out.0 ^ q.0) & !EPISTEMIC_MASK, 0, "{mask:?} {mantissa}"); - moved += usize::from(out != q); - } - } - assert!(moved > 0, "anti-vacuity: some operator must move the state"); - } - - /// F9. Same sealed evidence, same edit: same measurement, same bits. - #[test] - fn f9_replay_is_deterministic() { - let rp = Replay::new(); - let mut b = related_population(); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - for v in 0u8..8 { - let q = query( - CausalMask::from_bits(v), - Contract::Related, - Topology2::IndirectKnown, - ); - for edit in [Edit::None, Edit::CutStep(0), Edit::CutStep(1)] { - let (m1, m2) = (reason(q, &ev, edit), reason(q, &ev, edit)); - assert_eq!(m1, m2); - assert_eq!(revise(q, &m1).unwrap(), revise(q, &m2).unwrap()); - } - } - } - - /// F10. "The intervention passed" cannot be asserted: intervention - /// receipts without executed arms are not grounded, and executed arms - /// without an effect earn nothing. (`Measured` has private fields, so a - /// caller cannot construct a passing result either.) - #[test] - fn f10_a_pass_must_be_executed_not_claimed() { - let rp = Replay::new(); - let mut b = related_population(); - b.sources(SupportBasis::InterventionBacked, &[10, 11]); - let claimed = b.build(); - let (e, m) = step( - query(CausalMask::PO, Contract::Related, Topology2::Direct), - CausalMask::PO, - &evidence(&claimed, &rp), - Edit::None, - ); - assert_eq!( - m.ungrounded(), - Some(Ungrounded::Model(NotGrounded::EmptyArm)) - ); - assert_eq!(certification(e), Certification3::Related); - - let mut b = related_population(); - b.arm(true, 6, 3); - b.arm(false, 6, 3); - b.sources(SupportBasis::InterventionBacked, &[10, 11]); - let null = b.build(); - let (e, m) = step( - query(CausalMask::PO, Contract::Related, Topology2::Direct), - CausalMask::PO, - &evidence(&null, &rp), - Edit::None, - ); - assert_eq!((m.earned(), m.ungrounded()), (None, None)); - assert_eq!(certification(e), Certification3::Related); - } - - /// F11. Bits 59..63 do not select the operation and carry no reasoning - /// band: two edges differing only there get the same measurement. - #[test] - fn f11_bits_59_to_63_are_epistemic_output_not_a_reasoning_band() { - let rp = Replay::new(); - let mut b = related_population(); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - for v in 0u8..8 { - let mask = CausalMask::from_bits(v); - let base = reason( - query(mask, Contract::Open, Topology2::Direct), - &ev, - Edit::CutStep(1), - ); - for c in Contract::ALL { - for t in Topology2::ALL { - assert_eq!(reason(query(mask, c, t), &ev, Edit::CutStep(1)), base); - } - } - } - let src = include_str!("pearl_ladder_probe.rs"); - assert!(!src.contains(concat!("Reasoning", "Band"))); - } - - /// F12. Projection, mantissa and epistemic state are separate: the - /// operation follows the projection whatever the mantissa says, and - /// revision keeps both. - #[test] - fn f12_projection_mantissa_and_state_are_not_aliases() { - let rp = Replay::new(); - let mut b = related_population(); - add_positive_trial(&mut b); - let model = b.build(); - let ev = evidence(&model, &rp); - for v in 0u8..8 { - let mask = CausalMask::from_bits(v); - for mantissa in [ - InferenceType::Intervention.to_mantissa(), - InferenceType::Counterfactual.to_mantissa(), - 0, - ] { - let q = query(mask, Contract::Open, Topology2::Direct) - .with_inference_mantissa(mantissa); - let m = reason(q, &ev, Edit::CutStep(1)); - assert_eq!(m.operation(), Operation::of(mask)); - let out = revise(q, &m).unwrap(); - assert_eq!(out.causal_mask(), mask); - assert_eq!(out.inference_mantissa(), mantissa); - } - } - // The counterfactual mantissa on an SO edge still runs association. - let q = query(CausalMask::SO, Contract::Open, Topology2::Direct) - .with_inference_mantissa(InferenceType::Counterfactual.to_mantissa()); - assert_eq!( - reason(q, &ev, Edit::None).operation(), - Operation::Association - ); - } + show(" randomized arms, PO", revise(&m, rd).unwrap()); } diff --git a/crates/cognitive-shader-driver/examples/reasoning_band_probe.rs b/crates/cognitive-shader-driver/examples/reasoning_band_probe.rs index ea591b281..fe7431a70 100644 --- a/crates/cognitive-shader-driver/examples/reasoning_band_probe.rs +++ b/crates/cognitive-shader-driver/examples/reasoning_band_probe.rs @@ -90,7 +90,7 @@ //! with different obligations (P7a `Causes` needs ≥ 2 intervention-backed //! sources; this probe's `Causal` needs one). The `Counterfactual` rung — //! survived a removal attack — is counterfactual evidence and belongs to -//! `lance_graph_planner::dismech_counterfactual`; P7a states that removal is +//! `lance_graph_planner::chain_counterfactual`; P7a states that removal is //! not the causal path. Kept as shipped (#1360). #![allow(deprecated)] diff --git a/crates/cognitive-shader-driver/examples/relational_certification_probe.rs b/crates/cognitive-shader-driver/examples/relational_certification_probe.rs index fff22e4ee..b616df351 100644 --- a/crates/cognitive-shader-driver/examples/relational_certification_probe.rs +++ b/crates/cognitive-shader-driver/examples/relational_certification_probe.rs @@ -186,10 +186,10 @@ fn monotonicity_census() -> Census { b.cell(1, false, b1.0, b1.1); b.sources(SupportBasis::DirectlyObserved, &[1, 2]); if drop && hit != 0 { - b.m.clean &= !(hit.isolate_lowest_one()); + b.model_mut().clean &= !(hit.isolate_lowest_one()); } if unordered && hit != 0 { - b.m.ordered &= !(hit.isolate_lowest_one()); + b.model_mut().ordered &= !(hit.isolate_lowest_one()); } let m = b.build(); c.models += 1; @@ -321,7 +321,7 @@ mod tests { let (_, hits) = b.cell(0, true, 6, 3); b.cell(0, false, 6, 1); b.sources(SupportBasis::DirectlyObserved, &[1, 2]); - b.m.clean &= !hits; + b.model_mut().clean &= !hits; let outlier = b.build(); assert_eq!(outlier.associated(), Ok(true)); assert_eq!(outlier.related(), Ok(false)); @@ -333,9 +333,9 @@ mod tests { h.cell(0, true, 4, 2); let (_, hidden) = h.cell(0, false, 4, 3); h.sources(SupportBasis::DirectlyObserved, &[1, 2]); - h.m.clean &= !hidden; + h.model_mut().clean &= !hidden; let hidden = h.build(); - assert_eq!(hidden.assoc_in(hidden.universe & hidden.clean), Ok(true)); + assert_eq!(hidden.assoc_in(&(hidden.universe & hidden.clean)), Ok(true)); assert_eq!(hidden.related(), Ok(false)); assert_eq!(hidden.certify(), Contract::Open); } @@ -519,7 +519,7 @@ mod tests { let mut m = b.build(); m.trial &= !sib_arm; // the identification design compares A with untreated assert_eq!(m.certify(), Contract::Causes); - assert_eq!(compare(m.outcome, a_arm, sib_arm, true), Ok(false)); + assert_eq!(compare(&m.outcome, &a_arm, &sib_arm, true), Ok(false)); } /// Receipts recorded after the model's seal do not count: certification diff --git a/crates/cognitive-shader-driver/examples/shared/certification_model.rs b/crates/cognitive-shader-driver/examples/shared/certification_model.rs index e940ccc34..d92ab6d2e 100644 --- a/crates/cognitive-shader-driver/examples/shared/certification_model.rs +++ b/crates/cognitive-shader-driver/examples/shared/certification_model.rs @@ -1,327 +1,34 @@ -//! The P7a sealed model (D-GSO-7a, #1369): unit-mask populations, the -//! satisfaction folds that decide each certification contract, and the -//! fixture builder. Moved here unchanged from `relational_certification_probe` -//! (where it is tested) so that `pearl_ladder_probe` executes the same folds -//! instead of a copy. Items are `pub` only so the including examples can -//! reach them; each uses a subset, hence the `dead_code` allowance. +//! The P7a sealed model for the examples (D-GSO-7a, #1369). +//! +//! The model, its satisfaction folds and its builder live in production as +//! `lance_graph_contract::certification` (D-PEARL-PROD-0). This module only +//! re-exports them under the names the examples were written against, adds the +//! fixtures, and reads certifications as the examples' working-name `Contract`. +//! Each including example uses a subset, hence the `dead_code` allowance. #![allow(dead_code)] use crate::certification_reading::Contract; -use lance_graph_contract::causal_audit::{ - EvidenceSourceId, SupportBasis, SupportLedger, SupportReceipt, +use lance_graph_contract::causal_audit::SupportBasis; +#[allow(unused_imports)] // each including example uses a subset +pub use lance_graph_contract::certification::{ + compare, CertificationModel as Model, ModelBuilder as Builder, NotGrounded, Sat, }; -use lance_graph_contract::scheduler::DatasetVersion; -// ── The sealed model and its satisfaction folds ─────────────────────────── - -/// Why a proposition could not be decided in the model. Not a third truth -/// value: the model lacks what the decision needs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotGrounded { - EmptyArm, - TooFewSources, - NoIdentificationDesign, - NoDeclaredStratum, -} - -/// Satisfied (`Ok(true)`), refuted (`Ok(false)`), or not grounded. -pub type Sat = Result; - -pub const OBSERVATION: &[SupportBasis] = - &[SupportBasis::DirectlyObserved, SupportBasis::TextAttested]; -pub const INTERVENTION: &[SupportBasis] = &[SupportBasis::InterventionBacked]; -/// Distinct sources a contract needs. Policy pin. -pub const MIN_SOURCES: usize = 2; - -/// A satisfaction fold over a sealed model. -pub type Evaluator = fn(&Model) -> Sat; - -/// One sealed model: unit masks over at most 64 units. -#[derive(Debug, Clone)] -pub struct Model { - pub seal: DatasetVersion, - pub universe: u64, - pub exposed: u64, - pub outcome: u64, - /// The declared comparison partition, fixed before the data are read. - pub strata: [u64; 4], - /// Units whose exposure precedes their outcome. - pub ordered: u64, - /// Units the robustness mask keeps. - pub clean: u64, - /// Randomized identification design. - pub trial: u64, - pub assigned: u64, - pub ledger: SupportLedger, - /// Which bases count as this model's evidence for the association - /// contracts. - pub bases: &'static [SupportBasis], -} - -pub fn count(m: u64) -> u64 { - u64::from(m.count_ones()) +/// The production certifications, read as the examples' `Contract` (same +/// codes). +pub trait Certify { + /// `CertificationModel::certification`. + fn certify(&self) -> Contract; + /// `CertificationModel::observational_certification`. + fn certify_observational(&self) -> Contract; } -/// rate(Y | a) compared with rate(Y | b) by cross-multiplication. -pub fn compare(outcome: u64, a: u64, b: u64, strict: bool) -> Sat { - let (na, nb) = (count(a), count(b)); - if na == 0 || nb == 0 { - return Err(NotGrounded::EmptyArm); - } - let (lhs, rhs) = (count(outcome & a) * nb, count(outcome & b) * na); - Ok(if strict { lhs > rhs } else { lhs >= rhs }) -} - -/// Distinct sources among `bases`, counting only receipts recorded at or -/// before `seal`: a sealed model is certified from the evidence it held, so a -/// later receipt cannot change what an earlier seal replays to. -pub fn distinct_sources( - ledger: &SupportLedger, - bases: &[SupportBasis], - seal: DatasetVersion, -) -> usize { - let mut seen: Vec = Vec::new(); - for r in ledger - .receipts() - .iter() - .filter(|r| r.at <= seal && bases.contains(&r.basis)) - { - if !seen.contains(&r.source) { - seen.push(r.source); - } - } - seen.len() -} - -/// Existential fold: satisfied if any grounded case is, refuted if every -/// grounded case is, not grounded if none is. -pub fn any(results: impl Iterator) -> Sat { - let mut grounded = false; - let mut first_err = NotGrounded::EmptyArm; - for r in results { - match r { - Ok(true) => return Ok(true), - Ok(false) => grounded = true, - Err(e) => first_err = e, - } - } - if grounded { - Ok(false) - } else { - Err(first_err) +impl Certify for Model { + fn certify(&self) -> Contract { + Contract::from_certification(self.certification()) } -} - -impl Model { - pub fn sourced(&self) -> Result<(), NotGrounded> { - if distinct_sources(&self.ledger, self.bases, self.seal) >= MIN_SOURCES { - Ok(()) - } else { - Err(NotGrounded::TooFewSources) - } - } - - pub fn assoc_in(&self, p: u64) -> Sat { - compare(self.outcome, p & self.exposed, p & !self.exposed, true) - } - - pub fn declared(&self) -> impl Iterator + '_ { - core::iter::once(self.universe).chain( - self.strata - .iter() - .filter(|s| **s != 0) - .map(move |s| s & self.universe), - ) - } - - pub fn associated(&self) -> Sat { - self.sourced()?; - any(self.declared().map(|p| self.assoc_in(p))) - } - - /// The marginal variant, kept to measure where the chain breaks. - pub fn associated_marginal(&self) -> Sat { - self.sourced()?; - self.assoc_in(self.universe) - } - - /// The robustness mask can only refute: the association must hold in the - /// full population and after the mask. - pub fn related(&self) -> Sat { - self.sourced()?; - any(self.declared().map(|p| { - let full = self.assoc_in(p)?; - let kept = self.assoc_in(p & self.clean)?; - Ok(full && kept) - })) - } - - pub fn contributes(&self) -> Sat { - self.sourced()?; - let mut strict = false; - let mut any_stratum = false; - for s in self.strata.iter().filter(|s| **s != 0) { - any_stratum = true; - let s = s & self.universe; - let (a, b) = (s & self.exposed, s & !self.exposed); - let (ka, kb) = (a & self.clean, b & self.clean); - if !compare(self.outcome, a, b, false)? || !compare(self.outcome, ka, kb, false)? { - return Ok(false); - } - strict |= compare(self.outcome, a, b, true)? && compare(self.outcome, ka, kb, true)?; - } - if !any_stratum { - return Err(NotGrounded::NoDeclaredStratum); - } - Ok(strict) - } - - pub fn causal_candidate(&self) -> Sat { - if !self.contributes()? { - return Ok(false); - } - let co = self.universe & self.exposed & self.outcome; - Ok(co & !self.ordered == 0) - } - - pub fn causes(&self) -> Sat { - match distinct_sources(&self.ledger, INTERVENTION, self.seal) { - 0 => return Err(NotGrounded::NoIdentificationDesign), - n if n < MIN_SOURCES => return Err(NotGrounded::TooFewSources), - _ => {} - } - compare( - self.outcome, - self.trial & self.assigned, - self.trial & !self.assigned, - true, - ) - } - - /// The randomized arms read as a population of their own. - pub fn trial_view(&self) -> Model { - Model { - universe: self.trial, - exposed: self.assigned, - strata: [self.trial, 0, 0, 0], - ordered: self.trial, - bases: INTERVENTION, - ..self.clone() - } - } - - /// The strongest contract the model satisfies. - pub fn certify(&self) -> Contract { - if self.causes() == Ok(true) { - return Contract::Causes; - } - self.certify_observational() - } - - /// The strongest contract the observational folds alone satisfy. `causes` - /// is not in the list, so no amount of observation returns `Causes`. - pub fn certify_observational(&self) -> Contract { - let rungs: [(Contract, Evaluator); 4] = [ - (Contract::CausalCandidate, Model::causal_candidate), - (Contract::Contributes, Model::contributes), - (Contract::Related, Model::related), - (Contract::Associated, Model::associated), - ]; - rungs - .iter() - .find(|(_, f)| f(self) == Ok(true)) - .map_or(Contract::Open, |(c, _)| *c) - } -} - -// ── Fixture builder ─────────────────────────────────────────────────────── - -pub struct Builder { - pub next: u32, - pub m: Model, -} - -impl Builder { - pub fn new() -> Self { - Builder { - next: 0, - m: Model { - seal: DatasetVersion(1), - universe: 0, - exposed: 0, - outcome: 0, - strata: [0; 4], - ordered: 0, - clean: u64::MAX, - trial: 0, - assigned: 0, - ledger: SupportLedger::new(), - bases: OBSERVATION, - }, - } - } - - pub fn units(&mut self, n: u32, hits: u32) -> (u64, u64) { - assert!(hits <= n && self.next + n <= 64, "fixture exceeds 64 units"); - let mut all = 0; - let mut hit = 0; - for i in 0..n { - let bit = 1u64 << (self.next + i); - all |= bit; - if i < hits { - hit |= bit; - } - } - self.next += n; - self.m.outcome |= hit; - (all, hit) - } - - /// Observational cell: `n` units in `stratum`, `hits` with the outcome. - pub fn cell(&mut self, stratum: usize, exposed: bool, n: u32, hits: u32) -> (u64, u64) { - let (all, hit) = self.units(n, hits); - self.m.universe |= all; - self.m.strata[stratum] |= all; - self.m.ordered |= all; - if exposed { - self.m.exposed |= all; - } - (all, hit) - } - - pub fn arm(&mut self, assigned: bool, n: u32, hits: u32) -> (u64, u64) { - let (all, hit) = self.units(n, hits); - self.m.trial |= all; - if assigned { - self.m.assigned |= all; - } - (all, hit) - } - - pub fn sources(&mut self, basis: SupportBasis, ids: &[u64]) -> &mut Self { - let at = self.m.seal; - self.sources_at(basis, ids, at) - } - - pub fn sources_at( - &mut self, - basis: SupportBasis, - ids: &[u64], - at: DatasetVersion, - ) -> &mut Self { - for id in ids { - self.m.ledger.record(SupportReceipt { - basis, - source: EvidenceSourceId(*id), - at, - strength: 128, - }); - } - self - } - - pub fn build(&self) -> Model { - self.m.clone() + fn certify_observational(&self) -> Contract { + Contract::from_certification(self.observational_certification()) } } diff --git a/crates/lance-graph-contract/src/certification.rs b/crates/lance-graph-contract/src/certification.rs new file mode 100644 index 000000000..0d5014cac --- /dev/null +++ b/crates/lance-graph-contract/src/certification.rs @@ -0,0 +1,588 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! `certification` — the obligations behind each `Certification3` code +//! (D-GSO-7a / P7a, #1369; production home since D-PEARL-PROD-0). +//! +//! A [`CertificationModel`] is one sealed population held as unit masks: who +//! was exposed, who had the outcome, the declared strata, the robustness mask, +//! the ordering, and a randomized trial. Each certification is an integer fold +//! over those masks: +//! +//! | code | `Certification3` | obligation | +//! |---|---|---| +//! | 1 | `Associated` | exposure raises the outcome in some declared population, from at least [`MIN_SOURCES`] distinct observational sources | +//! | 2 | `Related` | the association holds in a declared population and after the robustness mask | +//! | 3 | `Supports` | in every declared stratum exposure does not lower the outcome, before and after the mask, and raises it in at least one | +//! | 4 | `CausalCandidate` | `Supports`, and exposure precedes outcome on every co-occurring unit | +//! | 5 | `Causes` | at least [`MIN_SOURCES`] distinct `InterventionBacked` sources, and the executed randomized arms show the treated rate higher | +//! +//! Thresholds are policy pins. A receipt recorded after the model's seal does +//! not count. [`CertificationModel::observational_certification`] cannot return +//! `Causes`; only the executed arms can. The robustness mask can only refute. +//! Removal is not the causal test: under overdetermination the trial still +//! certifies `Causes` where removing one cause changes nothing. +//! +//! # Any population size, one set of folds +//! +//! The folds use only intersection, difference and a population count, so the +//! model is generic over [`PopulationMask`]. `u64` (64 units) is the default, +//! which keeps every existing caller unchanged; `[u64; N]` holds `64 * N` +//! units — `[u64; 1024]` is one 64k-row cycle. The obligations do not change +//! with the carrier: the same fixture certifies identically in every width +//! (pinned by `every_carrier_width_certifies_a_fixture_identically`). +//! +//! The probe that measured these obligations is +//! `cognitive-shader-driver/examples/relational_certification_probe.rs`. + +use crate::causal_audit::{EvidenceSourceId, SupportBasis, SupportLedger, SupportReceipt}; +use crate::epistemic_state5::Certification3; +use crate::revision::EvidenceMask; +use crate::scheduler::DatasetVersion; + +// ── The sealed model and its satisfaction folds ─────────────────────────── + +/// Why a proposition could not be decided in the model. Not a third truth +/// value: the model lacks what the decision needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotGrounded { + EmptyArm, + TooFewSources, + NoIdentificationDesign, + NoDeclaredStratum, +} + +/// Satisfied (`Ok(true)`), refuted (`Ok(false)`), or not grounded. +pub type Sat = Result; + +pub const OBSERVATION: &[SupportBasis] = + &[SupportBasis::DirectlyObserved, SupportBasis::TextAttested]; +pub const INTERVENTION: &[SupportBasis] = &[SupportBasis::InterventionBacked]; +/// Distinct sources a contract needs. Policy pin. +pub const MIN_SOURCES: usize = 2; + +/// A unit mask the certification folds can count: [`EvidenceMask`]'s set +/// algebra plus a population count, a full mask and single units. +/// +/// A sub-trait rather than new [`EvidenceMask`] methods, so a mask that only +/// needs the set algebra (candidate masking, revision) is not asked to count. +pub trait PopulationMask: EvidenceMask { + /// How many units the mask can hold. + const CAPACITY: u32; + /// Every unit set. + fn full() -> Self; + /// The number of units set. + fn count(&self) -> u64; + /// The mask holding only unit `i`, or `None` past [`Self::CAPACITY`]. + fn unit(i: u32) -> Option; +} + +impl PopulationMask for u64 { + const CAPACITY: u32 = 64; + fn full() -> Self { + u64::MAX + } + fn count(&self) -> u64 { + u64::from(self.count_ones()) + } + fn unit(i: u32) -> Option { + 1u64.checked_shl(i) + } +} + +impl PopulationMask for [u64; N] { + const CAPACITY: u32 = 64 * N as u32; + fn full() -> Self { + [u64::MAX; N] + } + fn count(&self) -> u64 { + self.iter().map(|w| u64::from(w.count_ones())).sum() + } + fn unit(i: u32) -> Option { + let word = (i / 64) as usize; + if word >= N { + return None; + } + let mut m = [0; N]; + m[word] = 1u64 << (i % 64); + Some(m) + } +} + +/// A satisfaction fold over a sealed model. +type Evaluator = fn(&CertificationModel) -> Sat; + +/// One sealed model: unit masks over at most [`PopulationMask::CAPACITY`] +/// units of `M`. +#[derive(Debug, Clone)] +pub struct CertificationModel { + pub seal: DatasetVersion, + pub universe: M, + pub exposed: M, + pub outcome: M, + /// The declared comparison partition, fixed before the data are read. + pub strata: [M; 4], + /// Units whose exposure precedes their outcome. + pub ordered: M, + /// Units the robustness mask keeps. + pub clean: M, + /// Randomized identification design. + pub trial: M, + pub assigned: M, + pub ledger: SupportLedger, + /// Which bases count as this model's evidence for the association + /// contracts. + pub bases: &'static [SupportBasis], +} + +/// The number of units in `m`. +pub fn count(m: &M) -> u64 { + m.count() +} + +/// rate(Y | a) compared with rate(Y | b) by cross-multiplication. +pub fn compare(outcome: &M, a: &M, b: &M, strict: bool) -> Sat { + let (na, nb) = (a.count(), b.count()); + if na == 0 || nb == 0 { + return Err(NotGrounded::EmptyArm); + } + let (lhs, rhs) = ( + outcome.intersection(a).count() * nb, + outcome.intersection(b).count() * na, + ); + Ok(if strict { lhs > rhs } else { lhs >= rhs }) +} + +/// Distinct sources among `bases`, counting only receipts recorded at or +/// before `seal`: a sealed model is certified from the evidence it held, so a +/// later receipt cannot change what an earlier seal replays to. +pub fn distinct_sources( + ledger: &SupportLedger, + bases: &[SupportBasis], + seal: DatasetVersion, +) -> usize { + let mut seen: Vec = Vec::new(); + for r in ledger + .receipts() + .iter() + .filter(|r| r.at <= seal && bases.contains(&r.basis)) + { + if !seen.contains(&r.source) { + seen.push(r.source); + } + } + seen.len() +} + +/// Existential fold: satisfied if any grounded case is, refuted if every +/// grounded case is, not grounded if none is. +fn any(results: impl Iterator) -> Sat { + let mut grounded = false; + let mut first_err = NotGrounded::EmptyArm; + for r in results { + match r { + Ok(true) => return Ok(true), + Ok(false) => grounded = true, + Err(e) => first_err = e, + } + } + if grounded { + Ok(false) + } else { + Err(first_err) + } +} + +impl CertificationModel { + pub fn sourced(&self) -> Result<(), NotGrounded> { + if distinct_sources(&self.ledger, self.bases, self.seal) >= MIN_SOURCES { + Ok(()) + } else { + Err(NotGrounded::TooFewSources) + } + } + + /// Exposed against unexposed within population `p`. + fn arms_in(&self, p: &M) -> (M, M) { + (p.intersection(&self.exposed), p.difference(&self.exposed)) + } + + pub fn assoc_in(&self, p: &M) -> Sat { + let (a, b) = self.arms_in(p); + compare(&self.outcome, &a, &b, true) + } + + pub fn declared(&self) -> impl Iterator + '_ { + core::iter::once(self.universe.clone()).chain( + self.strata + .iter() + .filter(|s| !s.is_empty()) + .map(move |s| s.intersection(&self.universe)), + ) + } + + pub fn associated(&self) -> Sat { + self.sourced()?; + any(self.declared().map(|p| self.assoc_in(&p))) + } + + /// The marginal variant, kept to measure where the chain breaks. + pub fn associated_marginal(&self) -> Sat { + self.sourced()?; + self.assoc_in(&self.universe) + } + + /// The robustness mask can only refute: the association must hold in the + /// full population and after the mask. + pub fn related(&self) -> Sat { + self.sourced()?; + any(self.declared().map(|p| { + let full = self.assoc_in(&p)?; + let kept = self.assoc_in(&p.intersection(&self.clean))?; + Ok(full && kept) + })) + } + + pub fn contributes(&self) -> Sat { + self.sourced()?; + let mut strict = false; + let mut any_stratum = false; + for s in self.strata.iter().filter(|s| !s.is_empty()) { + any_stratum = true; + let s = s.intersection(&self.universe); + let (a, b) = self.arms_in(&s); + let (ka, kb) = (a.intersection(&self.clean), b.intersection(&self.clean)); + let y = &self.outcome; + if !compare(y, &a, &b, false)? || !compare(y, &ka, &kb, false)? { + return Ok(false); + } + strict |= compare(y, &a, &b, true)? && compare(y, &ka, &kb, true)?; + } + if !any_stratum { + return Err(NotGrounded::NoDeclaredStratum); + } + Ok(strict) + } + + pub fn causal_candidate(&self) -> Sat { + if !self.contributes()? { + return Ok(false); + } + let co = self + .universe + .intersection(&self.exposed) + .intersection(&self.outcome); + Ok(co.is_subset_of(&self.ordered)) + } + + pub fn causes(&self) -> Sat { + match distinct_sources(&self.ledger, INTERVENTION, self.seal) { + 0 => return Err(NotGrounded::NoIdentificationDesign), + n if n < MIN_SOURCES => return Err(NotGrounded::TooFewSources), + _ => {} + } + compare( + &self.outcome, + &self.trial.intersection(&self.assigned), + &self.trial.difference(&self.assigned), + true, + ) + } + + /// The randomized arms read as a population of their own. + pub fn trial_view(&self) -> CertificationModel { + CertificationModel { + universe: self.trial.clone(), + exposed: self.assigned.clone(), + strata: [self.trial.clone(), M::empty(), M::empty(), M::empty()], + ordered: self.trial.clone(), + bases: INTERVENTION, + ..self.clone() + } + } + + /// The strongest certification the model satisfies: `Causes` from the + /// executed randomized arms, otherwise the strongest observational one. + pub fn certification(&self) -> Certification3 { + if self.causes() == Ok(true) { + return Certification3::Causes; + } + self.observational_certification() + } + + /// The strongest certification the observational folds alone satisfy. + /// `causes` is not in the list, so no amount of observation returns + /// `Causes`. + pub fn observational_certification(&self) -> Certification3 { + let rungs: [(Certification3, Evaluator); 4] = [ + (Certification3::CausalCandidate, Self::causal_candidate), + (Certification3::Supports, Self::contributes), + (Certification3::Related, Self::related), + (Certification3::Associated, Self::associated), + ]; + rungs + .iter() + .find(|(_, f)| f(self) == Ok(true)) + .map_or(Certification3::Open, |(c, _)| *c) + } +} + +// ── Builder ─────────────────────────────────────────────────────────────── + +/// Builds a [`CertificationModel`] unit by unit; [`ModelBuilder::units`] +/// panics past the mask's [`PopulationMask::CAPACITY`]. +pub struct ModelBuilder { + next: u32, + m: CertificationModel, +} + +/// `new()` builds the default 64-unit width, so it infers without annotation +/// (the `HashMap::new` / `HashMap::default` split); `default()` builds any +/// width, e.g. `ModelBuilder::<[u64; 16]>::default()`. +impl ModelBuilder { + pub fn new() -> Self { + Self::default() + } +} + +impl Default for ModelBuilder { + fn default() -> Self { + ModelBuilder { + next: 0, + m: CertificationModel { + seal: DatasetVersion(1), + universe: M::empty(), + exposed: M::empty(), + outcome: M::empty(), + strata: [M::empty(), M::empty(), M::empty(), M::empty()], + ordered: M::empty(), + clean: M::full(), + trial: M::empty(), + assigned: M::empty(), + ledger: SupportLedger::new(), + bases: OBSERVATION, + }, + } + } +} + +impl ModelBuilder { + /// `n` fresh units, the first `hits` of them with the outcome. + pub fn units(&mut self, n: u32, hits: u32) -> (M, M) { + assert!( + hits <= n && self.next + n <= M::CAPACITY, + "fixture exceeds {} units", + M::CAPACITY + ); + let mut all = M::empty(); + let mut hit = M::empty(); + for i in 0..n { + let bit = M::unit(self.next + i).expect("checked against CAPACITY"); + all = all.union(&bit); + if i < hits { + hit = hit.union(&bit); + } + } + self.next += n; + self.m.outcome = self.m.outcome.union(&hit); + (all, hit) + } + + /// Skip `n` units, so the next cell starts further into the mask. + pub fn skip(&mut self, n: u32) -> &mut Self { + assert!( + self.next + n <= M::CAPACITY, + "skip exceeds {} units", + M::CAPACITY + ); + self.next += n; + self + } + + /// Observational cell: `n` units in `stratum`, `hits` with the outcome. + pub fn cell(&mut self, stratum: usize, exposed: bool, n: u32, hits: u32) -> (M, M) { + let (all, hit) = self.units(n, hits); + let m = &mut self.m; + m.universe = m.universe.union(&all); + m.strata[stratum] = m.strata[stratum].union(&all); + m.ordered = m.ordered.union(&all); + if exposed { + m.exposed = m.exposed.union(&all); + } + (all, hit) + } + + pub fn arm(&mut self, assigned: bool, n: u32, hits: u32) -> (M, M) { + let (all, hit) = self.units(n, hits); + self.m.trial = self.m.trial.union(&all); + if assigned { + self.m.assigned = self.m.assigned.union(&all); + } + (all, hit) + } + + pub fn sources(&mut self, basis: SupportBasis, ids: &[u64]) -> &mut Self { + let at = self.m.seal; + self.sources_at(basis, ids, at) + } + + pub fn sources_at( + &mut self, + basis: SupportBasis, + ids: &[u64], + at: DatasetVersion, + ) -> &mut Self { + for id in ids { + self.m.ledger.record(SupportReceipt { + basis, + source: EvidenceSourceId(*id), + at, + strength: 128, + }); + } + self + } + + /// The model under construction, for masks the cell and arm helpers do + /// not set (the robustness mask, ordering). + pub fn model_mut(&mut self) -> &mut CertificationModel { + &mut self.m + } + + pub fn build(&self) -> CertificationModel { + self.m.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every fold of a model, so two models can be compared fold by fold. + fn folds(m: &CertificationModel) -> [Sat; 6] { + [ + m.associated(), + m.associated_marginal(), + m.related(), + m.contributes(), + m.causal_candidate(), + m.causes(), + ] + } + + /// The fixtures the P7a probe measured, written once over any width. + /// `offset` units are skipped first, so a fixture can sit past unit 64. + fn fixtures(offset: u32) -> Vec> { + let mut out = Vec::new(); + // Robust association, two observational sources. + let mut b = ModelBuilder::::default(); + b.skip(offset); + b.cell(0, true, 5, 4); + b.cell(0, false, 5, 1); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + out.push(b.build()); + // The same with one source: not grounded. + let mut b = ModelBuilder::::default(); + b.skip(offset); + b.cell(0, true, 5, 4); + b.cell(0, false, 5, 1); + b.sources(SupportBasis::DirectlyObserved, &[1]); + out.push(b.build()); + // Simpson: positive marginally, reversed inside each stratum. + let mut b = ModelBuilder::::default(); + b.skip(offset); + b.cell(0, true, 8, 6); + b.cell(0, false, 2, 2); + b.cell(1, true, 2, 0); + b.cell(1, false, 8, 1); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + out.push(b.build()); + // Executed arms with two intervention sources. + let mut b = ModelBuilder::::default(); + b.skip(offset); + b.cell(0, true, 4, 2); + b.cell(0, false, 4, 2); + b.arm(true, 6, 5); + b.arm(false, 6, 1); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + b.sources(SupportBasis::InterventionBacked, &[3, 4]); + out.push(b.build()); + out + } + + #[test] + fn every_carrier_width_certifies_a_fixture_identically() { + let narrow = fixtures::(0); + let two = fixtures::<[u64; 2]>(0); + let cycle = fixtures::<[u64; 1024]>(0); + let mut seen = Vec::new(); + for ((a, b), c) in narrow.iter().zip(&two).zip(&cycle) { + assert_eq!(folds(a), folds(b)); + assert_eq!(folds(a), folds(c)); + assert_eq!(a.certification(), b.certification()); + assert_eq!(a.certification(), c.certification()); + seen.push(a.certification()); + } + // Anti-vacuity: the fixtures span several certifications, so equal + // results are not equal because everything is `Open`. + assert_eq!( + seen, + [ + Certification3::CausalCandidate, + Certification3::Open, + // Simpson holds marginally and after the mask, so `Related`; + // the reversal inside the strata stops it short of `Supports`. + Certification3::Related, + Certification3::Causes, + ] + ); + } + + #[test] + fn units_past_the_first_word_are_counted() { + // The same fixtures, placed entirely beyond unit 64 (and, in the + // cycle-sized mask, beyond unit 60 000). A count that read only the + // first word would see empty arms and return `EmptyArm`. + let base = fixtures::(0); + let far = fixtures::<[u64; 2]>(64); + let farther = fixtures::<[u64; 1024]>(60_000); + for ((a, b), c) in base.iter().zip(&far).zip(&farther) { + assert_eq!(folds(a), folds(b)); + assert_eq!(folds(a), folds(c)); + } + assert!(far[0].universe[0] == 0 && far[0].universe[1] != 0); + } + + #[test] + fn a_population_wider_than_64_units_is_decided() { + // 60 exposed (45 hits) against 60 unexposed (15 hits): 120 units, which + // a `u64` model cannot hold. + let mut b = ModelBuilder::<[u64; 2]>::default(); + b.cell(0, true, 60, 45); + b.cell(0, false, 60, 15); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + assert_eq!(b.build().associated(), Ok(true)); + // Silence twin: equal rates over the same 120 units. + let mut b = ModelBuilder::<[u64; 2]>::default(); + b.cell(0, true, 60, 30); + b.cell(0, false, 60, 30); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + assert_eq!(b.build().associated(), Ok(false)); + } + + #[test] + fn units_stop_at_capacity() { + assert_eq!(::unit(63), Some(1 << 63)); + assert_eq!(::unit(64), None); + assert_eq!(<[u64; 2] as PopulationMask>::unit(64), Some([0, 1])); + assert_eq!(<[u64; 2] as PopulationMask>::unit(128), None); + } + + #[test] + #[should_panic(expected = "fixture exceeds 128 units")] + fn the_builder_refuses_past_capacity() { + let mut b = ModelBuilder::<[u64; 2]>::default(); + b.cell(0, true, 100, 50); + b.cell(0, false, 29, 10); + } +} diff --git a/crates/lance-graph-contract/src/dismech_evidence.rs b/crates/lance-graph-contract/src/dismech_evidence.rs index f146cf2f7..c8ee12f57 100644 --- a/crates/lance-graph-contract/src/dismech_evidence.rs +++ b/crates/lance-graph-contract/src/dismech_evidence.rs @@ -46,6 +46,7 @@ //! bibliographic identity is never synthesised from a title. use crate::content_store::ContentId; +use crate::ontology_warrant::Quorum; /// Source `causal_link_type` — the four states, 2 bits. /// @@ -483,6 +484,17 @@ impl BibliographyRecord { // ── The predicate palette mirror (D-DCR-1, W1 membrane) ──────────────────── +/// The DisMech concept id — `ogar_dismech::DISMECH_CONCEPT_ID`, the canon-high +/// half (G) of every DisMech classid (`0x0333_xxxx`). +/// +/// A predicate ordinal means nothing without it. `0x90` is `causes` here, but +/// the loco floor is shared: other vocabularies (the NARS recipes, the r2il +/// ops) also mint from `0x90`, and OGAR selects the vocabulary from the +/// node's classid (`VocabularyRegistry::resolve_classid`). Mirrored here for +/// the same reason [`DISMECH_PREDICATES`] is; the fuse is +/// `lance_graph_ogar::parity::assert_dismech_palette_parity`. +pub const DISMECH_CONCEPT_ID: u16 = 0x0333; + /// Floor of the DisMech predicate band — `ogar_dismech::CAUSES`. /// /// The palette mints contiguously upward from here, so resolving an ordinal is @@ -500,7 +512,7 @@ pub const DISMECH_PREDICATE_FLOOR: u8 = 0x90; /// # Why a replay core needs this /// /// A recorded causal chain addresses each step by a `u8` predicate ordinal -/// (`lance_graph_planner::dismech_replay::ChainStep`). Without a mirror the +/// (`lance_graph_planner::chain_replay::ChainStep`). Without a mirror the /// hot path carries a bare byte with no checkable domain, and the claim *"these /// ordinals ARE the dismech palette"* would be asserted only in prose. With it, /// a replay can refuse an ordinal outside the band, and the armed tier proves @@ -552,6 +564,75 @@ pub const fn is_dismech_predicate(ordinal: u8) -> bool { dismech_predicate(ordinal).is_some() } +// ── Evidence into a quorum (palette × evidence) ─────────────────────────── + +/// The citations behind one relation, folded into a [`Quorum`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CitationQuorum { + /// Distinct citations counted by direction. Silent citations are the ones + /// that only said `PARTIAL` or `NO_EVIDENCE`. + pub quorum: Quorum, + /// Citations that both support and refute the relation. They are counted + /// on both sides of the quorum, not removed: disagreement is information. + pub both_ways: u16, +} + +/// Fold cited stances on one relation into a [`Quorum`] over distinct +/// citations. +/// +/// The source is the citation identity ([`CitationKey`]), never the title or +/// the row: a citation repeated across rows is one source. Per citation: +/// +/// | the citation said | counts as | +/// |---|---| +/// | `SUPPORT` | corroborating | +/// | `REFUTE` | conflicting | +/// | both | both, and in [`CitationQuorum::both_ways`] | +/// | only `PARTIAL` / `NO_EVIDENCE` | silent | +/// +/// `NO_EVIDENCE` as silence is the quorum's own rule (abstention, never +/// dissent). **`PARTIAL` as silence is a policy pin**: it is weaker than +/// support in the source's own vocabulary, and `dismech_candidates` already +/// keeps it inert, so it is not counted as corroboration here either. +/// +/// The predicate the relation travels under (its palette ordinal) does not +/// enter: the fold is per relation, and the relation's identity is the +/// caller's key. +#[must_use] +pub fn citation_quorum<'a>( + stances: impl IntoIterator, +) -> CitationQuorum { + const SUPPORT: u8 = 1; + const REFUTE: u8 = 2; + let mut seen: Vec<(&CitationKey, u8)> = Vec::new(); + for (key, stance) in stances { + let bit = match stance { + Supports::Support => SUPPORT, + Supports::Refute => REFUTE, + Supports::Partial | Supports::NoEvidence => 0, + }; + match seen.iter_mut().find(|(k, _)| *k == key) { + Some((_, flags)) => *flags |= bit, + None => seen.push((key, bit)), + } + } + let mut out = CitationQuorum::default(); + for (_, flags) in seen { + let q = &mut out.quorum; + match flags { + 0 => q.silent = q.silent.saturating_add(1), + SUPPORT => q.corroborating = q.corroborating.saturating_add(1), + REFUTE => q.conflicting = q.conflicting.saturating_add(1), + _ => { + q.corroborating = q.corroborating.saturating_add(1); + q.conflicting = q.conflicting.saturating_add(1); + out.both_ways = out.both_ways.saturating_add(1); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -814,4 +895,63 @@ mod tests { assert_eq!(curie, format!("dismech:{name}"), "CURIE/name disagree"); } } + + fn key(raw: &str) -> CitationKey { + CitationKey::parse(raw).expect("non-blank") + } + + #[test] + fn a_citation_repeated_across_rows_is_one_source() { + let (a, b) = (key("PMID:1"), key("PMID:2")); + let q = citation_quorum([ + (&a, Supports::Support), + (&a, Supports::Support), + (&a, Supports::Support), + (&b, Supports::Support), + ]); + assert_eq!(q.quorum, Quorum::new(2, 0, 0)); + // Two independent sources: confidence 2/3, frequency 1. + let w = q.quorum.warrant(); + assert!((w.confidence - 2.0 / 3.0).abs() < 1e-6); + assert!((w.frequency - 1.0).abs() < 1e-6); + // Silence twin: the namespace is part of the identity, so PMID:1 and + // ORPHA:1 are two sources, not one. + let (p, o) = (key("PMID:1"), key("ORPHA:1")); + let q = citation_quorum([(&p, Supports::Support), (&o, Supports::Support)]); + assert_eq!(q.quorum.corroborating, 2); + } + + #[test] + fn partial_and_no_evidence_abstain_and_never_count_as_dissent() { + let (a, b, c) = (key("PMID:1"), key("PMID:2"), key("PMID:3")); + let q = citation_quorum([ + (&a, Supports::Support), + (&b, Supports::Partial), + (&c, Supports::NoEvidence), + ]); + assert_eq!(q.quorum, Quorum::new(1, 2, 0)); + // Abstention leaves the frequency untouched. + assert!((q.quorum.warrant().frequency - 1.0).abs() < 1e-6); + // A citation that also spoke is not silent. + let q = citation_quorum([(&a, Supports::NoEvidence), (&a, Supports::Refute)]); + assert_eq!(q.quorum, Quorum::new(0, 0, 1)); + // With nobody speaking there is no evidence, not agreement. + let q = citation_quorum([(&a, Supports::Partial), (&b, Supports::NoEvidence)]); + assert!(!q.quorum.has_evidence()); + } + + #[test] + fn a_citation_on_both_sides_is_kept_on_both_sides() { + let (a, b) = (key("PMID:1"), key("PMID:2")); + let q = citation_quorum([ + (&a, Supports::Support), + (&a, Supports::Refute), + (&b, Supports::Support), + ]); + assert_eq!(q.quorum, Quorum::new(2, 0, 1)); + assert_eq!(q.both_ways, 1); + // Silence twin: no citation said both, so nothing is reported. + let q = citation_quorum([(&a, Supports::Support), (&b, Supports::Refute)]); + assert_eq!(q.both_ways, 0); + } } diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 896af55b9..04d7b2a73 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -64,6 +64,7 @@ pub mod cam; pub mod canonical_node; pub mod causal_audit; pub mod causal_witness; +pub mod certification; pub mod class_view; /// D-V3-W6a — classid adoption-scan counting logic (`ClassidForm`, /// `classify_form`, `AdoptionCounts`, `count_adoption`). See diff --git a/crates/lance-graph-ogar/src/lib.rs b/crates/lance-graph-ogar/src/lib.rs index 666a5cae7..ffa3d1c82 100644 --- a/crates/lance-graph-ogar/src/lib.rs +++ b/crates/lance-graph-ogar/src/lib.rs @@ -213,7 +213,7 @@ pub mod parity { /// of predicates checked. Panics on any divergence. /// /// **This is the membrane half of D-DCR-1 (W1).** The replay core - /// (`lance_graph_planner::dismech_replay`) addresses each recorded step by + /// (`lance_graph_planner::chain_replay`) addresses each recorded step by /// a plain `u8` ordinal — it is in-workspace and cannot reach OGAR, which /// lives in this excluded armed tier. So the claim *"these ordinals ARE /// the dismech palette"* is proved HERE, against the real palette, rather @@ -248,6 +248,11 @@ pub mod parity { assert_eq!(row.1, p.name, "{:#04x}: reverse name mismatch", p.index.0); assert_eq!(row.2, p.curie, "{:#04x}: reverse CURIE mismatch", p.index.0); } + assert_eq!( + mirror::DISMECH_CONCEPT_ID, + ogar_dismech::DISMECH_CONCEPT_ID, + "the mirror's concept id (the G a chain's classid routes by) is not the palette's", + ); assert_eq!( mirror::DISMECH_PREDICATE_FLOOR, ogar_dismech::CAUSES.0, diff --git a/crates/lance-graph-planner/examples/house_differential.rs b/crates/lance-graph-planner/examples/house_differential.rs index acf9db126..096c4ac1c 100644 --- a/crates/lance-graph-planner/examples/house_differential.rs +++ b/crates/lance-graph-planner/examples/house_differential.rs @@ -161,7 +161,7 @@ use lance_graph_contract::counterfactual::deposit_counterfactual; use lance_graph_contract::escalation::InnerCouncil; use lance_graph_contract::recipes::recipe; -use lance_graph_planner::dismech_counterfactual::CounterfactualEdge; +use lance_graph_planner::chain_counterfactual::CounterfactualEdge; use lance_graph_planner::nars::tactics::{ASC_ID, CAS_ID, CR_ID, RCR_ID, TR_ID}; use lance_graph_planner::nars::{ asc_challenge, cas_abstract, cr_synthesize, rcr_abduce, AscOutcome, BeliefArena, CStmt, diff --git a/crates/lance-graph-planner/src/cache/nars_engine.rs b/crates/lance-graph-planner/src/cache/nars_engine.rs index 6a0f629c4..cf8200852 100644 --- a/crates/lance-graph-planner/src/cache/nars_engine.rs +++ b/crates/lance-graph-planner/src/cache/nars_engine.rs @@ -487,7 +487,7 @@ impl NarsEngine { /// `c_levels = 1` (the 128 KB fast path), and with ONE bucket every /// confidence maps to the same table: the weights are equal, so the /// frequency is a plain mean and `c_out` is the constant **170**. That is - /// the fixed point `dismech_counterfactual::DEFAULT_FREQUENCY_BAR` + /// the fixed point `chain_counterfactual::DEFAULT_FREQUENCY_BAR` /// already documents — *"measured across a weak 3-chain, a strong /// 4-chain, and a mixed one, the terminal confidence was 170 in every /// case"* — and it is why a confidence-based threshold there would be diff --git a/crates/lance-graph-planner/src/chain_admission.rs b/crates/lance-graph-planner/src/chain_admission.rs new file mode 100644 index 000000000..031859f68 --- /dev/null +++ b/crates/lance-graph-planner/src/chain_admission.rs @@ -0,0 +1,315 @@ +//! **Chain admission — the palette a chain is checked against comes from its +//! classid.** The one domain-aware step in front of chain replay. +//! +//! [`crate::chain_replay`] and [`crate::chain_counterfactual`] are +//! domain-agnostic: a step's predicate ordinal is an opaque `u8` they carry as +//! witness and never read. The ordinal only means something inside a +//! vocabulary, and the vocabulary is selected by the chain's classid — the G of +//! its SPO-G quad. `0x90` is `causes` under the DisMech concept (`0x0333`), but +//! the loco floor is shared: the NARS recipes and the r2il ops also mint from +//! `0x90`. Checking a bare byte against one palette would read every other +//! vocabulary's `0x90` as `causes`. +//! +//! So admission takes `(classid, chain)`, routes by the concept half of the +//! classid ([`graph_of_classid`]), and checks the steps against that palette. +//! This mirrors OGAR's `VocabularyRegistry::resolve_classid` for the palettes +//! the zero-dependency contract mirrors; today that is DisMech's +//! (`lance_graph_contract::dismech_evidence`). The fuse that proves the mirror +//! IS the palette, concept id included, is +//! `lance_graph_ogar::parity::assert_dismech_palette_parity`. + +use lance_graph_contract::dismech_evidence::{dismech_predicate, DISMECH_CONCEPT_ID}; + +use crate::chain_replay::ChainStep; + +/// One minted predicate: `(ordinal, name, curie)`. +pub type PredicateRow = (u8, &'static str, &'static str); + +/// A palette: ordinal to its minted row, `None` outside the band. +pub type Palette = fn(u8) -> Option<&'static PredicateRow>; + +/// The palettes this crate can admit against, keyed by concept (G). +/// +/// One entry per mirrored vocabulary. A concept with no entry is refused +/// ([`Unadmitted::UnknownPalette`]), never checked against another concept's +/// palette. +pub const PALETTES: &[(u16, Palette)] = &[(DISMECH_CONCEPT_ID, dismech_predicate)]; + +/// The DisMech classid under the bare concept, no app prefix. Any app prefix +/// routes the same way; G is the concept half alone. +pub const DISMECH_CLASSID: u32 = (DISMECH_CONCEPT_ID as u32) << 16; + +/// The graph coordinate of a classid: its canon-high concept half +/// (`contract::spog_tenants::graph_of`, for a bare classid). +#[must_use] +pub const fn graph_of_classid(classid: u32) -> u16 { + (classid >> 16) as u16 +} + +/// The palette a classid routes to, if this crate mirrors it. +#[must_use] +pub fn palette_of(classid: u32) -> Option { + let concept = graph_of_classid(classid); + PALETTES + .iter() + .find(|(c, _)| *c == concept) + .map(|(_, p)| *p) +} + +/// A step whose predicate ordinal names no minted predicate of its palette. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnmintedOrdinal { + /// Index of the offending step within the chain. + pub step: usize, + /// The ordinal that resolved to nothing. + pub ordinal: u8, +} + +/// Why a chain was not admitted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unadmitted { + /// The classid's concept has no palette this crate mirrors. + UnknownPalette { + /// The concept half of the classid. + concept: u16, + }, + /// A step's ordinal is not minted in the classid's palette. + Unminted(UnmintedOrdinal), +} + +/// Resolve one step's predicate under `classid`. +/// +/// # Errors +/// +/// [`Unadmitted::UnknownPalette`] when the classid routes to no mirrored +/// palette; [`Unadmitted::Unminted`] (with `step: 0`) when the ordinal is +/// outside the palette's band. `0xA3` under DisMech is `CANDIDATES`, the first +/// SEARCH op: a real slot, but not a causal predicate. +pub fn chain_step_predicate( + classid: u32, + step: ChainStep, +) -> Result<&'static PredicateRow, Unadmitted> { + let palette = palette_of(classid).ok_or(Unadmitted::UnknownPalette { + concept: graph_of_classid(classid), + })?; + palette(step.0).ok_or(Unadmitted::Unminted(UnmintedOrdinal { + step: 0, + ordinal: step.0, + })) +} + +/// Check that every step of a chain travels under a minted predicate of the +/// palette its classid routes to. +/// +/// # Why this is an ADMISSION check and NOT part of [`crate::chain_replay::replay_chain`] +/// +/// Review on #1120 proposed validating inside the replay loop and returning an +/// error before emitting a trace row. The defect it names is real — a chain +/// carrying `0xA3` (the palette's SEARCH band) is not a causal chain, and +/// replaying it produces a byte-identical, meaningless trace. But the remedy +/// belongs one step earlier, for a reason that goes to the plan's keystone: +/// +/// **Replay must not refuse history.** A recorded chain is a fact about what +/// was evaluated; the engine's job is to reproduce it, not to judge it. If the +/// palette ever drops or renumbers an ordinal, a replay that validated would +/// start returning `Err` for chains that were perfectly valid when recorded — +/// and "yesterday's evaluation replays today byte-for-byte" is precisely the +/// property the whole wave exists to hold. +/// +/// So the judgement happens once, when a chain is FIRST accepted, and replay +/// stays total over everything already admitted. +/// +/// # "Admission" means first acceptance — NOT loading a recording +/// +/// The previous wording listed *"loading a recording"* as an admission site, +/// which **contradicted the paragraph above it** — reloading an older durable +/// recording and validating it against today's palette rejects exactly the +/// history the split exists to keep replayable. Codex caught this on #1122; +/// the argument was right and the instruction beneath it was wrong. +/// +/// The rule, stated so the two cannot drift apart again: +/// +/// - **A chain arriving from outside** (a producer, a boundary, a new +/// recording being made) is validated against the CURRENT palette, here. +/// - **A chain being re-read from the durable log** is already admitted. It is +/// not re-validated — the fact that it was recorded IS its admission, under +/// whatever palette was current then. Replay it. +/// - A caller that genuinely needs to check an old recording must check it +/// against the palette version it was admitted under, which this function +/// cannot do: it has one palette, today's. That is a versioned-palette +/// capability nothing in this wave has, and inventing one here would be +/// worse than declining. +/// +/// Fails closed and reports WHICH step, so a rejection is actionable rather +/// than a boolean. +/// # Errors +/// +/// [`Unadmitted::UnknownPalette`] before any step is read when the classid +/// routes nowhere; otherwise the first unminted step, by index. +pub fn validate_chain(classid: u32, chain: &[ChainStep]) -> Result<(), Unadmitted> { + let palette = palette_of(classid).ok_or(Unadmitted::UnknownPalette { + concept: graph_of_classid(classid), + })?; + for (step, &entry) in chain.iter().enumerate() { + if palette(entry.0).is_none() { + return Err(Unadmitted::Unminted(UnmintedOrdinal { + step, + ordinal: entry.0, + })); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_replay::tests::{chain, compose_tables, edge, Lcg}; + use crate::chain_replay::{replay_chain, ComposeTables}; + use causal_edge::tables::NarsTables; + + #[test] + fn a_chain_steps_ordinal_names_a_minted_predicate_and_a_search_op_does_not() { + // The core cannot reach `ogar_dismech` (workspace boundary), so what + // it CAN pin is that a step's ordinal resolves through the contract + // mirror to the predicate the plan names — and that the byte one past + // the band does not. The mirror-IS-the-palette half is fused in + // `lance_graph_ogar::parity::assert_dismech_palette_parity`; neither + // half alone is the claim. + let mut rng = Lcg(0x51D3_7C4A_9B2E_6F08); + let w = edge(&mut rng); + assert_eq!( + chain_step_predicate(DISMECH_CLASSID, (0x90, w)) + .map(|p| p.1) + .ok(), + Some("causes"), + ); + assert_eq!( + chain_step_predicate(DISMECH_CLASSID, (0xA2, w)) + .map(|p| p.1) + .ok(), + Some("variant_of"), + ); + // Can-stay-silent, on a byte that is a REAL slot elsewhere in the + // palette rather than an arbitrary one: 0xA3 is `CANDIDATES`, the + // first search op. An empty-input silence case would prove nothing. + assert!(chain_step_predicate(DISMECH_CLASSID, (0xA3, w)).is_err()); + assert!(chain_step_predicate(DISMECH_CLASSID, (0x8F, w)).is_err()); + } + #[test] + fn a_chain_carrying_a_search_op_is_refused_at_admission_and_still_replays() { + // CodeRabbit #1120 asked that `replay_chain` itself reject an ordinal + // outside the predicate band. Both halves are pinned here, because the + // SPLIT is the decision, not either half alone. + let mut rng = Lcg(0x5EA2_C401_9D3B_77E6); + let mut c = chain(&mut rng, 5); + c[2].0 = 0xA3; // CANDIDATES — a real slot, in the SEARCH band + + // (a) admission REFUSES it, and names the step so the rejection is + // actionable rather than a boolean. + assert_eq!( + validate_chain(DISMECH_CLASSID, &c), + Err(Unadmitted::Unminted(UnmintedOrdinal { + step: 2, + ordinal: 0xA3 + })), + ); + // Anti-vacuity: the same chain without that step must PASS, or the + // check could be rejecting everything. + let mut clean = c.clone(); + clean[2].0 = 0x90; + assert_eq!(validate_chain(DISMECH_CLASSID, &clean), Ok(())); + + // (b) replay stays TOTAL over it. Replay must not refuse history: a + // recorded chain is a fact, and a replay that judged content would + // start returning Err for chains that were valid when recorded — + // against the keystone this whole wave exists to hold. + let tables = NarsTables::build(1); + let c_tabs = compose_tables(); + let tabs = ComposeTables { + s: &c_tabs[0], + p: &c_tabs[1], + o: &c_tabs[2], + }; + let seed = edge(&mut rng); + let t = replay_chain(&c, seed, &tables, tabs, 1, 10).expect("reservation fits"); + assert_eq!(t.len(), 5); + assert_eq!( + t[2].predicate, 0xA3, + "the witness records what was replayed" + ); + } + + #[test] + fn the_same_byte_is_admitted_only_under_the_classid_whose_palette_mints_it() { + // 0x90 is `causes` under DisMech and recipe #1 under the NARS recipe + // vocabulary. A chain is checked against the palette its classid + // routes to, never against DisMech by default. + let mut rng = Lcg(0x0333_0306_0000_0001); + let c = chain(&mut rng, 4); + assert_eq!(validate_chain(DISMECH_CLASSID, &c), Ok(())); + // RO relation bodies (0x0306) — a real concept with its own palette, + // not mirrored here: refused, not read as DisMech. + let ro_classid = 0x0306_0000; + assert_eq!( + validate_chain(ro_classid, &c), + Err(Unadmitted::UnknownPalette { concept: 0x0306 }), + ); + assert_eq!( + chain_step_predicate(ro_classid, c[0]), + Err(Unadmitted::UnknownPalette { concept: 0x0306 }), + ); + // Refused before any step is read: an empty chain under an unknown + // concept is refused too. + assert_eq!( + validate_chain(ro_classid, &[]), + Err(Unadmitted::UnknownPalette { concept: 0x0306 }), + ); + } + + #[test] + fn only_the_concept_half_routes() { + // G is the canon-high concept half. The app prefix (low half) selects a + // render skin and must not change which palette admits a chain. + let mut rng = Lcg(0x0333_1000_0005_0001); + let c = chain(&mut rng, 3); + for classid in [DISMECH_CLASSID, 0x0333_1000, 0x0333_0005, 0x0333_FFFF] { + assert_eq!(validate_chain(classid, &c), Ok(()), "{classid:#010x}"); + } + // Silence twin: the same low half under another concept routes nowhere. + assert!(validate_chain(0x0334_1000, &c).is_err()); + } + + #[test] + #[allow(deprecated)] + fn the_old_module_paths_still_name_the_same_items() { + // The rename keeps `dismech_replay` / `dismech_counterfactual` as + // deprecated aliases. The replay items are the SAME functions; the old + // DisMech-bound admission functions answer exactly as admission under + // the DisMech classid does. + let old_seq: fn(u64, usize) -> Option = crate::dismech_replay::next_base_seq; + let new_seq: fn(u64, usize) -> Option = crate::chain_replay::next_base_seq; + assert!(core::ptr::fn_addr_eq(old_seq, new_seq)); + assert_eq!( + crate::dismech_counterfactual::DEFAULT_FREQUENCY_BAR, + crate::chain_counterfactual::DEFAULT_FREQUENCY_BAR, + ); + let mut rng = Lcg(0xA11A_5000_0000_0001); + let mut c = chain(&mut rng, 4); + assert_eq!(crate::dismech_replay::validate_chain(&c), Ok(())); + c[1].0 = 0xA3; + assert_eq!( + crate::dismech_replay::validate_chain(&c), + Err(UnmintedOrdinal { + step: 1, + ordinal: 0xA3 + }), + ); + for b in [0x90u8, 0xA2, 0xA3, 0x8F] { + assert_eq!( + crate::dismech_replay::chain_step_predicate((b, c[0].1)), + chain_step_predicate(DISMECH_CLASSID, (b, c[0].1)).ok(), + ); + } + } +} diff --git a/crates/lance-graph-planner/src/dismech_counterfactual.rs b/crates/lance-graph-planner/src/chain_counterfactual.rs similarity index 98% rename from crates/lance-graph-planner/src/dismech_counterfactual.rs rename to crates/lance-graph-planner/src/chain_counterfactual.rs index bb009a51b..2142a8e8f 100644 --- a/crates/lance-graph-planner/src/dismech_counterfactual.rs +++ b/crates/lance-graph-planner/src/chain_counterfactual.rs @@ -1,7 +1,9 @@ -//! **D-DCR-3 (W3) — counterfactual replay.** `dismech-causal-replay-v1` §3 W3. +//! **Counterfactual chain replay — domain-agnostic.** Originally D-DCR-3 (W3) +//! of `dismech-causal-replay-v1` §3, renamed from `dismech_counterfactual`: +//! nothing here reads a DisMech predicate. //! //! The SAME W1 replay with one edge cut — Pearl rung 3. **Never a second -//! replay path:** [`crate::dismech_replay::replay_chain`] runs both the +//! replay path:** [`crate::chain_replay::replay_chain`] runs both the //! factual and the counterfactual arm, so a divergence can only come from the //! cut and never from two implementations drifting apart. //! @@ -47,7 +49,7 @@ use causal_edge::CausalEdge64; use lance_graph_contract::collapse_gate::MailboxId; use lance_graph_contract::counterfactual::EpisodicEdge; -use crate::dismech_replay::{replay_chain, ChainStep, ComposeTables, ReplayError, ReplayTraceRow}; +use crate::chain_replay::{replay_chain, ChainStep, ComposeTables, ReplayError, ReplayTraceRow}; /// The bridge `contract::counterfactual` documents as BLOCKED on workspace /// structure — `impl EpisodicEdge for CausalEdge64`. @@ -237,7 +239,7 @@ pub fn counterfactual_replay( // The counterfactual arm reserves its OWN durable range, immediately // after the factual one — the two traces coexist and must never share // a coordinate (`LocalCausalRow::cast_seq` uniqueness). - let cf_base = crate::dismech_replay::next_base_seq(base_seq, chain.len()).ok_or( + let cf_base = crate::chain_replay::next_base_seq(base_seq, chain.len()).ok_or( ReplayError::SequenceExhausted { base_seq, steps: chain.len(), diff --git a/crates/lance-graph-planner/src/dismech_replay.rs b/crates/lance-graph-planner/src/chain_replay.rs similarity index 77% rename from crates/lance-graph-planner/src/dismech_replay.rs rename to crates/lance-graph-planner/src/chain_replay.rs index dc1cb2815..02f8f0c94 100644 --- a/crates/lance-graph-planner/src/dismech_replay.rs +++ b/crates/lance-graph-planner/src/chain_replay.rs @@ -1,4 +1,9 @@ -//! **D-DCR-1 (W1) — the replay core.** `dismech-causal-replay-v1` §3 W1. +//! **Chain replay — the domain-agnostic core.** Originally D-DCR-1 (W1) of +//! `dismech-causal-replay-v1` §3, renamed from `dismech_replay` once it was +//! clear nothing here depends on DisMech: the predicate ordinal is carried as +//! an opaque `u8` witness and never read by the arithmetic. The DisMech +//! palette check, keyed by the chain's classid, lives in +//! [`crate::chain_admission`]. //! //! Replays a RECORDED causal chain against a seed, one packed step at a time, //! and emits a trace that `temporal.rs` can deinterlace. Replay, never @@ -8,24 +13,24 @@ //! //! # What this composes (and what it deliberately does not add) //! -//! Every moving part is already shipped; W1 is the wiring that proves they -//! compose (`F-RLR-2`: proposing a new carrier before `ogar_loco` is shown +//! Every moving part is already shipped; this module is the wiring that proves +//! they compose (`F-RLR-2`: proposing a new carrier before `ogar_loco` is shown //! insufficient is an automatic STOP): //! //! | part | provenance | //! |---|---| //! | the step kernel | [`NarsTables::revise`] + [`CausalEdge64::forward`] — the kernel W0 measured at ~35 ns | -//! | the step *address* | a `u8` predicate ordinal — the dismech palette's `FnIndex` (`0x90..=0xA2`) | +//! | the step *address* | a `u8` predicate ordinal — opaque here; for DisMech chains, the palette's `FnIndex` (`0x90..=0xA2`) | //! | the trace | [`crate::temporal::LocalCausalRow`], deinterlaced by the shipped helpers | //! -//! **The palette binds at the membrane, never here** (plan §2 constraint 6). +//! **A palette binds at the membrane, never here** (plan §2 constraint 6). //! `ogar-loco`/`ogar-dismech` are NOT dependencies of this crate and must not //! become ones: `lance-graph-planner` is in-workspace, while the OGAR surface //! lives in the workspace-EXCLUDED armed tier (`lance-graph-ogar`). A step -//! therefore carries the ordinal as a plain `u8` on the hot path, and the -//! claim *"these ordinals ARE the dismech palette"* is pinned by a -//! conformance test in that armed tier — where the real -//! `ogar_dismech::CAUSAL_PREDICATES` is reachable — rather than asserted here. +//! therefore carries the ordinal as a plain `u8` on the hot path. Which +//! ordinals a chain may carry is an ADMISSION question for the domain that +//! produced it, selected by the chain's classid: +//! [`crate::chain_admission::validate_chain`]. //! //! # The `cast_seq` precondition is load-bearing, not paperwork //! @@ -70,15 +75,17 @@ pub struct ReplayTraceRow { /// Index of the step within its chain — the address a perturbation is /// reported AT, so a diff names a position rather than a row. pub step: u32, - /// The DisMech predicate ordinal this step travelled under. + /// The predicate ordinal this step travelled under (for a DisMech chain, + /// a palette ordinal). /// /// **Carried as witness, not as an operand.** W1's arithmetic never reads /// it — but a trace that dropped it would not be a witness of the recorded /// program: two chains with identical weights and different relations /// (`causes` vs `protects_against`) would replay to byte-identical traces, /// and no consumer could reconstruct or validate what was actually - /// recorded. Resolve it through [`chain_step_predicate`] (or, at the - /// membrane, the real palette). + /// recorded. Resolve it under the chain's classid through + /// [`crate::chain_admission::chain_step_predicate`] (or, at the membrane, + /// the real palette). /// /// Added after review on #1120: the module previously claimed the ordinal /// was "carried into the trace's step index" — it was not carried at all, @@ -117,36 +124,19 @@ impl LocalCausalRow for ReplayTraceRow { /// substrate already reads every 12-byte payload as — `function` is the /// palette ordinal, `value` is the weight. /// -/// The ordinal's DOMAIN is checkable without leaving the workspace: -/// [`chain_step_predicate`] resolves it through the contract's zero-dep -/// palette mirror, and the armed tier fuses that mirror against the real +/// The ordinal's DOMAIN is not replay's: it is the vocabulary the chain's +/// classid selects (the G of its SPO-G quad). A step does not carry the +/// classid; a chain has one. [`crate::chain_admission`] resolves it, and for +/// DisMech the armed tier fuses the contract mirror against the real /// `ogar_dismech::RELATIONS`. pub type ChainStep = (u8, CausalEdge64); -/// Resolve a step's predicate ordinal to `(ordinal, name, curie)`, or `None` -/// when the byte names no minted DisMech predicate. -/// -/// **Why this exists rather than a bare `u8`.** The replay arithmetic does not -/// read the ordinal in W1 — it is the ADDRESS the step travels under, not an -/// operand — so nothing in the hot path would ever notice a corrupt one. That -/// is exactly why the domain needs a name: a chain carrying `0xA3` is not a -/// causal chain at all (`0xA3` is the palette's SEARCH band), and without this -/// a replay would trace it to a byte-identical, entirely meaningless result. -/// -/// The mirror is the contract's; the fuse that proves it IS the palette lives -/// in `lance_graph_ogar::parity::assert_dismech_palette_parity`, because -/// `ogar-dismech` is reachable only from that workspace-EXCLUDED armed tier. -/// Mirror here, authority there — the same split `ogar_codebook` already uses. -#[must_use] -pub fn chain_step_predicate(step: ChainStep) -> Option<&'static (u8, &'static str, &'static str)> { - lance_graph_contract::dismech_evidence::dismech_predicate(step.0) -} - /// A replay could not be performed as asked. /// /// Deliberately small: replay has exactly one way to fail, and it is a /// property of the ADDRESS SPACE the caller offered, never of the recorded -/// chain (see [`validate_chain`] for why a chain's content is not judged here). +/// chain (see [`crate::chain_admission::validate_chain`] for why a chain's +/// content is not judged here). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum ReplayError { @@ -179,70 +169,6 @@ impl fmt::Display for ReplayError { impl std::error::Error for ReplayError {} -/// A step whose predicate ordinal names no minted DisMech predicate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UnmintedOrdinal { - /// Index of the offending step within the chain. - pub step: usize, - /// The ordinal that resolved to nothing. - pub ordinal: u8, -} - -/// Check that every step of a chain travels under a minted DisMech predicate. -/// -/// # Why this is an ADMISSION check and NOT part of [`replay_chain`] -/// -/// Review on #1120 proposed validating inside the replay loop and returning an -/// error before emitting a trace row. The defect it names is real — a chain -/// carrying `0xA3` (the palette's SEARCH band) is not a causal chain, and -/// replaying it produces a byte-identical, meaningless trace. But the remedy -/// belongs one step earlier, for a reason that goes to the plan's keystone: -/// -/// **Replay must not refuse history.** A recorded chain is a fact about what -/// was evaluated; the engine's job is to reproduce it, not to judge it. If the -/// palette ever drops or renumbers an ordinal, a replay that validated would -/// start returning `Err` for chains that were perfectly valid when recorded — -/// and "yesterday's evaluation replays today byte-for-byte" is precisely the -/// property the whole wave exists to hold. -/// -/// So the judgement happens once, when a chain is FIRST accepted, and replay -/// stays total over everything already admitted. -/// -/// # "Admission" means first acceptance — NOT loading a recording -/// -/// The previous wording listed *"loading a recording"* as an admission site, -/// which **contradicted the paragraph above it** — reloading an older durable -/// recording and validating it against today's palette rejects exactly the -/// history the split exists to keep replayable. Codex caught this on #1122; -/// the argument was right and the instruction beneath it was wrong. -/// -/// The rule, stated so the two cannot drift apart again: -/// -/// - **A chain arriving from outside** (a producer, a boundary, a new -/// recording being made) is validated against the CURRENT palette, here. -/// - **A chain being re-read from the durable log** is already admitted. It is -/// not re-validated — the fact that it was recorded IS its admission, under -/// whatever palette was current then. Replay it. -/// - A caller that genuinely needs to check an old recording must check it -/// against the palette version it was admitted under, which this function -/// cannot do: it has one palette, today's. That is a versioned-palette -/// capability nothing in this wave has, and inventing one here would be -/// worse than declining. -/// -/// Fails closed and reports WHICH step, so a rejection is actionable rather -/// than a boolean. -pub fn validate_chain(chain: &[ChainStep]) -> Result<(), UnmintedOrdinal> { - for (step, &entry) in chain.iter().enumerate() { - if chain_step_predicate(entry).is_none() { - return Err(UnmintedOrdinal { - step, - ordinal: entry.0, - }); - } - } - Ok(()) -} - /// The single step, isolated so the replay and any future accelerator agree on /// what a step IS. Composes the two halves W0 measured, in that order: /// the table lookup (evidence fusion) then the packed forward (palette @@ -413,14 +339,14 @@ pub fn first_divergence(a: &[ReplayTraceRow], b: &[ReplayTraceRow]) -> Option u64 { self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1); @@ -431,7 +357,7 @@ mod tests { } } - fn compose_tables() -> Box<[[u8; 256 * 256]; 3]> { + pub(crate) fn compose_tables() -> Box<[[u8; 256 * 256]; 3]> { let mut t = Box::new([[0u8; 256 * 256]; 3]); for (k, tab) in t.iter_mut().enumerate() { for i in 0..256usize { @@ -443,7 +369,7 @@ mod tests { t } - fn edge(rng: &mut Lcg) -> CausalEdge64 { + pub(crate) fn edge(rng: &mut Lcg) -> CausalEdge64 { CausalEdge64::pack( rng.below(256) as u8, rng.below(256) as u8, @@ -458,10 +384,11 @@ mod tests { ) } - /// A chain whose predicate ordinals sit in the dismech palette's band. - /// The BAND is asserted here; that these exact ordinals are the palette's + /// A chain whose predicate ordinals sit in the DisMech palette's band (a + /// realistic fixture; replay itself never reads the ordinal). The BAND is + /// asserted here; that these exact ordinals are the palette's /// is the armed tier's conformance test, not this crate's claim. - fn chain(rng: &mut Lcg, len: usize) -> Vec { + pub(crate) fn chain(rng: &mut Lcg, len: usize) -> Vec { (0..len) .map(|_| ((0x90 + rng.below(0x13)) as u8, edge(rng))) .collect() @@ -603,27 +530,6 @@ mod tests { assert!(only_a.windows(2).all(|w| w[0].cast_seq() < w[1].cast_seq())); } #[test] - fn a_chain_steps_ordinal_names_a_minted_predicate_and_a_search_op_does_not() { - // The core cannot reach `ogar_dismech` (workspace boundary), so what - // it CAN pin is that a step's ordinal resolves through the contract - // mirror to the predicate the plan names — and that the byte one past - // the band does not. The mirror-IS-the-palette half is fused in - // `lance_graph_ogar::parity::assert_dismech_palette_parity`; neither - // half alone is the claim. - let mut rng = Lcg(0x51D3_7C4A_9B2E_6F08); - let w = edge(&mut rng); - assert_eq!(chain_step_predicate((0x90, w)).map(|p| p.1), Some("causes"),); - assert_eq!( - chain_step_predicate((0xA2, w)).map(|p| p.1), - Some("variant_of"), - ); - // Can-stay-silent, on a byte that is a REAL slot elsewhere in the - // palette rather than an arbitrary one: 0xA3 is `CANDIDATES`, the - // first search op. An empty-input silence case would prove nothing. - assert!(chain_step_predicate((0xA3, w)).is_none()); - assert!(chain_step_predicate((0x8F, w)).is_none()); - } - #[test] fn two_chains_differing_only_in_predicate_do_not_replay_identically() { // The P1 finding from #1120, pinned. Identical weights, one relation // swapped: `causes` (0x90) vs `protects_against` (0x95) — two @@ -785,50 +691,6 @@ mod tests { "this is the duplicate the old saturating helper would have handed out", ); } - #[test] - fn a_chain_carrying_a_search_op_is_refused_at_admission_and_still_replays() { - // CodeRabbit #1120 asked that `replay_chain` itself reject an ordinal - // outside the predicate band. Both halves are pinned here, because the - // SPLIT is the decision, not either half alone. - let mut rng = Lcg(0x5EA2_C401_9D3B_77E6); - let mut c = chain(&mut rng, 5); - c[2].0 = 0xA3; // CANDIDATES — a real slot, in the SEARCH band - - // (a) admission REFUSES it, and names the step so the rejection is - // actionable rather than a boolean. - assert_eq!( - validate_chain(&c), - Err(UnmintedOrdinal { - step: 2, - ordinal: 0xA3 - }), - ); - // Anti-vacuity: the same chain without that step must PASS, or the - // check could be rejecting everything. - let mut clean = c.clone(); - clean[2].0 = 0x90; - assert_eq!(validate_chain(&clean), Ok(())); - - // (b) replay stays TOTAL over it. Replay must not refuse history: a - // recorded chain is a fact, and a replay that judged content would - // start returning Err for chains that were valid when recorded — - // against the keystone this whole wave exists to hold. - let tables = NarsTables::build(1); - let c_tabs = compose_tables(); - let tabs = ComposeTables { - s: &c_tabs[0], - p: &c_tabs[1], - o: &c_tabs[2], - }; - let seed = edge(&mut rng); - let t = replay_chain(&c, seed, &tables, tabs, 1, 10).expect("reservation fits"); - assert_eq!(t.len(), 5); - assert_eq!( - t[2].predicate, 0xA3, - "the witness records what was replayed" - ); - } - #[test] fn a_reservation_that_cannot_fit_is_refused_before_a_partial_trace_exists() { // CodeRabbit #1120: `base_seq + i` panicked in debug and wrapped in diff --git a/crates/lance-graph-planner/src/lib.rs b/crates/lance-graph-planner/src/lib.rs index 313d2c8bf..11ef833c4 100644 --- a/crates/lance-graph-planner/src/lib.rs +++ b/crates/lance-graph-planner/src/lib.rs @@ -96,12 +96,54 @@ pub mod api; pub mod rung_horizon; pub mod temporal; -/// D-DCR-1 (W1) — the causal replay core over recorded dismech chains. -/// Composes the shipped step kernel + `temporal.rs` trace; adds no carrier. +/// Chain admission: the palette a chain is checked against comes from the +/// concept half (G) of its classid. +pub mod chain_admission; +/// Counterfactual chain replay (D-DCR-3 W3): one replay path for both arms. +pub mod chain_counterfactual; +/// D-DCR-1 (W1) — the causal replay core over recorded chains. Domain-agnostic: +/// composes the shipped step kernel + `temporal.rs` trace; adds no carrier. /// Plan: `.claude/plans/dismech-causal-replay-v1.md` §3 W1. +pub mod chain_replay; pub mod dismech_candidates; -pub mod dismech_counterfactual; -pub mod dismech_replay; +/// Former name of [`chain_counterfactual`], kept so existing imports compile. +#[deprecated(note = "renamed to `chain_counterfactual`")] +pub mod dismech_counterfactual { + pub use crate::chain_counterfactual::*; +} +/// Former name of [`chain_replay`], plus the DisMech-bound admission +/// functions it used to carry, kept so existing imports compile. New code uses +/// [`chain_admission`] with the chain's classid. +#[deprecated( + note = "renamed to `chain_replay`; admission moved to `chain_admission`, keyed by classid" +)] +pub mod dismech_replay { + pub use crate::chain_admission::UnmintedOrdinal; + use crate::chain_admission::{self, PredicateRow, Unadmitted, DISMECH_CLASSID}; + pub use crate::chain_replay::*; + + /// `chain_admission::chain_step_predicate` under the DisMech classid. + #[must_use] + pub fn chain_step_predicate(step: ChainStep) -> Option<&'static PredicateRow> { + chain_admission::chain_step_predicate(DISMECH_CLASSID, step).ok() + } + + /// `chain_admission::validate_chain` under the DisMech classid. + /// + /// # Errors + /// + /// The first step whose ordinal is not a minted DisMech predicate. + pub fn validate_chain(chain: &[ChainStep]) -> Result<(), UnmintedOrdinal> { + match chain_admission::validate_chain(DISMECH_CLASSID, chain) { + Ok(()) => Ok(()), + Err(Unadmitted::Unminted(u)) => Err(u), + Err(Unadmitted::UnknownPalette { .. }) => { + unreachable!("DISMECH_CLASSID routes to a mirrored palette") + } + } + } +} +pub mod pearl; // === Canonical OrchestrationBridge impl (dedup per contract) === // Implements `lance_graph_contract::orchestration::OrchestrationBridge` diff --git a/crates/lance-graph-planner/src/pearl.rs b/crates/lance-graph-planner/src/pearl.rs new file mode 100644 index 000000000..5598ddc5d --- /dev/null +++ b/crates/lance-graph-planner/src/pearl.rs @@ -0,0 +1,1182 @@ +//! **D-PEARL-PROD-0 — Pearl's ladder, in and out of `CausalEdge64`.** +//! +//! An edge enters with one `EpistemicState5` (bits 59..63). Its Pearl +//! projection (bits 40..42) selects which existing operator runs. The operator +//! measures, and only the measurement decides whether bits 59..63 change. +//! +//! ```text +//! CausalEdge64 ──bits 40..42──► reason ──► Measured ──revise──► bits 59..63 +//! +//! SO ► observational folds (certification::CertificationModel::observational_certification) +//! PO ► executed trial arms (CertificationModel::causes) +//! SPO ► counterfactual replay (crate::chain_counterfactual::counterfactual_replay) +//! SP ► SO direction vs PO direction (causal_edge::CausalMask::simpsons_paradox_risk) +//! ``` +//! +//! It lives in this crate because this is the crate that depends on both +//! `lance-graph-contract` (the certification obligations, `EpistemicState5`) +//! and `causal-edge` (`CausalEdge64`), the same reason +//! [`crate::chain_counterfactual`] lives here. +//! +//! # The write-back rule +//! +//! - **Certification only rises, and only to what the operator computed.** SO +//! can earn at most `CausalCandidate`. PO is the only path to `Causes`, and +//! only from executed randomized arms. SPO and SP earn nothing: a +//! counterfactual reaction and a confounding diagnostic are evidence beside +//! the certification, not certifications. +//! - **Topology changes only through [`hydrate`]**: `IndirectUnknown` becomes +//! `IndirectKnown` when both bindings `A → B` and `B → Y` exist in the sealed +//! chain. Proposing a candidate is not evidence. +//! - **Only bits 59..63 move.** The projection, mantissa, frequency and +//! confidence of the edge are inputs, never outputs. +//! - **A pass cannot be asserted.** [`Measured`] has private fields; only +//! [`reason`] produces one, by running the operator. +//! +//! # Measured on the probe that shaped this module +//! +//! `cognitive-shader-driver/examples/pearl_ladder_probe.rs` (D-PEARL-IO-0): +//! the counterfactual replay's verdict is a NARS revision over step +//! frequencies, so a cut can raise the terminal frequency; the reaction keeps +//! both frequencies. One-at-a-time removal is not the causal test: under +//! Y = A or B, removing A where B holds changes nothing while the trial +//! certifies `Causes`. + +use causal_edge::{CausalEdge64, CausalMask}; +use lance_graph_contract::band_reading::EdgeProvenance; +use lance_graph_contract::certification::{ + compare, CertificationModel, NotGrounded, PopulationMask, +}; +use lance_graph_contract::class_view::ClassId; +use lance_graph_contract::epistemic_state5::{ + Certification3, Epi5Declarations, Epi5Gen, Epi5ReadError, EpistemicState5, Topology2, +}; +use lance_graph_contract::rail_geometry::RailAxis; + +use crate::chain_counterfactual::{counterfactual_replay, CutContext}; +use crate::chain_replay::ChainStep; + +/// The operator bits 40..42 select. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operation { + /// `SO`: observational folds. + Association, + /// `PO`: executed randomized arms. + Intervention, + /// `SPO`: counterfactual replay with one route cut. + Counterfactual, + /// `SP`: observational direction against interventional direction. + ConfounderCheck, + /// A projection with no operator (`None`, `O`, `P`, `S`). + NoOperator(CausalMask), +} + +impl Operation { + /// The operator a projection selects. + #[must_use] + pub fn of(mask: CausalMask) -> Self { + match mask { + CausalMask::SO => Operation::Association, + CausalMask::PO => Operation::Intervention, + CausalMask::SPO => Operation::Counterfactual, + CausalMask::SP => Operation::ConfounderCheck, + other => Operation::NoOperator(other), + } + } +} + +/// Why an operator could not decide. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ungrounded { + /// A certification fold lacked what it needs. + Model(NotGrounded), + /// A counterfactual was asked for without a sealed chain. + NoChain, + /// A counterfactual was asked for without an edit. + NoEdit, + /// The edit names no step of the chain. + CutOutOfRange(usize), + /// The replay refused (sequence reservation). + Replay, + /// One direction of the confounder check is a tie. + NoDirection, + /// The projection selects no operator. + NoOperator, +} + +/// What one route cut did to the chain's answer, with both frequencies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reaction { + /// The verdict changed. + LoadBearing { factual: u8, counterfactual: u8 }, + /// The truth moved, the verdict did not. + TruthOnly { factual: u8, counterfactual: u8 }, + /// Nothing moved. + Inert { frequency: u8 }, +} + +impl Reaction { + /// Classify a cut from the two arms' terminal truths `(frequency, + /// confidence)`. Inert means both terminal truths are equal; the arms' + /// lengths are not compared, because a cut always removes a step. + #[must_use] + pub fn classify(load_bearing: bool, factual: (u8, u8), counterfactual: (u8, u8)) -> Self { + let (f, c) = (factual.0, counterfactual.0); + if load_bearing { + Reaction::LoadBearing { + factual: f, + counterfactual: c, + } + } else if factual != counterfactual { + Reaction::TruthOnly { + factual: f, + counterfactual: c, + } + } else { + Reaction::Inert { frequency: f } + } + } +} + +/// A counterfactual edit. The base chain is never modified; the operator +/// replays it with the edit applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit { + /// No edit. + None, + /// Mask one route (one step) of the chain. + CutStep(usize), +} + +/// The sealed evidence an operator may read. Read-only. +/// +/// Generic over the population's mask width: `u64` holds 64 units, `[u64; N]` +/// holds `64 * N`. The operators do not depend on the width. +pub struct Evidence<'a, M: PopulationMask = u64> { + /// The sealed population, with any executed randomized arms. + pub model: &'a CertificationModel, + /// The sealed chain, for counterfactual replay. + pub chain: Option>, +} + +/// A sealed chain for the counterfactual replay. +#[derive(Clone, Copy)] +pub struct Chain<'a> { + /// The recorded steps. + pub steps: &'a [ChainStep], + /// The replay's seed edge. + pub seed: CausalEdge64, + /// Tables, owner, sequence base and verdict bar, shared by both arms. + pub cx: CutContext<'a>, +} + +/// The result of running one operator. Only [`reason`] constructs it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Measured { + edge: CausalEdge64, + operation: Operation, + earned: Option, + ungrounded: Option, + reaction: Option, + confounded: Option, + counterfactual_terminal: Option, +} + +impl Measured { + fn new(edge: CausalEdge64, operation: Operation) -> Self { + Measured { + edge, + operation, + earned: None, + ungrounded: None, + reaction: None, + confounded: None, + counterfactual_terminal: None, + } + } + + /// The edge the operator measured. [`revise`] writes back to this edge + /// and no other. + #[must_use] + pub fn edge(&self) -> CausalEdge64 { + self.edge + } + /// The operator that ran. + #[must_use] + pub fn operation(&self) -> Operation { + self.operation + } + /// The certification the operator computed, if any. + #[must_use] + pub fn earned(&self) -> Option { + self.earned + } + /// Why the operator could not decide, if it could not. + #[must_use] + pub fn ungrounded(&self) -> Option { + self.ungrounded + } + /// The counterfactual reaction (`SPO` only). + #[must_use] + pub fn reaction(&self) -> Option { + self.reaction + } + /// Whether the observational and interventional directions disagree + /// (`SP` only). + #[must_use] + pub fn confounded(&self) -> Option { + self.confounded + } + /// The counterfactual arm's terminal edge, tagged −6. Witness only; + /// [`revise`] never writes it back. + #[must_use] + pub fn counterfactual_terminal(&self) -> Option { + self.counterfactual_terminal + } +} + +/// Run the operator the edge's projection selects. Reads bits 40..42 and +/// nothing else of the edge: the epistemic state, mantissa and truth are not +/// inputs to the choice. +#[must_use] +pub fn reason(edge: CausalEdge64, ev: &Evidence<'_, M>, edit: Edit) -> Measured { + let op = Operation::of(edge.causal_mask()); + let mut out = Measured::new(edge, op); + let m = ev.model; + match op { + Operation::Association => match m.sourced() { + Err(e) => out.ungrounded = Some(Ungrounded::Model(e)), + Ok(()) => { + let c = m.observational_certification(); + if c != Certification3::Open { + out.earned = Some(c); + } else if let Err(e) = m.associated() { + // No rung earned anything, and the weakest one could not + // even compare: missing evidence, not a negative result. + out.ungrounded = Some(Ungrounded::Model(e)); + } + } + }, + Operation::Intervention => match m.causes() { + Ok(true) => out.earned = Some(Certification3::Causes), + Ok(false) => {} + Err(e) => out.ungrounded = Some(Ungrounded::Model(e)), + }, + Operation::Counterfactual => { + let Some(chain) = ev.chain else { + out.ungrounded = Some(Ungrounded::NoChain); + return out; + }; + let Edit::CutStep(i) = edit else { + out.ungrounded = Some(Ungrounded::NoEdit); + return out; + }; + match counterfactual_replay(chain.steps, i, chain.seed, chain.cx) { + None => out.ungrounded = Some(Ungrounded::CutOutOfRange(i)), + Some(Err(_)) => out.ungrounded = Some(Ungrounded::Replay), + Some(Ok(cf)) => { + let f = cf.factual.last().map_or(0, |r| r.edge.frequency_u8()); + let c = cf + .counterfactual + .last() + .map_or(0, |r| r.edge.frequency_u8()); + out.reaction = Some(Reaction::classify( + cf.role.is_load_bearing(), + (f, conf(&cf.factual)), + (c, conf(&cf.counterfactual)), + )); + out.counterfactual_terminal = cf.counterfactual.last().map(|r| r.edge); + } + } + } + Operation::ConfounderCheck => { + let obs = direction( + &m.outcome, + &m.universe.intersection(&m.exposed), + &m.universe.difference(&m.exposed), + ); + let trial = direction( + &m.outcome, + &m.trial.intersection(&m.assigned), + &m.trial.difference(&m.assigned), + ); + match (obs, trial) { + (Ok(Some(so)), Ok(Some(po))) => { + out.confounded = Some(CausalMask::simpsons_paradox_risk(so, po)); + } + (Err(e), _) | (_, Err(e)) => out.ungrounded = Some(Ungrounded::Model(e)), + _ => out.ungrounded = Some(Ungrounded::NoDirection), + } + } + Operation::NoOperator(_) => out.ungrounded = Some(Ungrounded::NoOperator), + } + out +} + +/// The terminal confidence of a replay trace. +fn conf(trace: &[crate::chain_replay::ReplayTraceRow]) -> u8 { + trace.last().map_or(0, |r| r.edge.confidence_u8()) +} + +/// The direction triad `simpsons_paradox_risk` reads: bit 2 set when the +/// outcome is rarer in the exposed group. `None` on a tie. +fn direction(outcome: &M, a: &M, b: &M) -> Result, NotGrounded> { + if compare(outcome, a, b, true)? { + Ok(Some(0b000)) + } else if compare(outcome, b, a, true)? { + Ok(Some(0b100)) + } else { + Ok(None) + } +} + +/// The declared reading an edge's bits 59..63 are projected through: whose +/// class and rail declare `EpistemicState5`, and where the edge came from. +#[derive(Clone, Copy)] +pub struct Reading<'a> { + /// The declarations. + pub declarations: &'a Epi5Declarations, + /// The edge's class. + pub class: ClassId, + /// The rail the class declared the reading on. + pub rail: RailAxis, + /// Where the edge's bits came from. + pub provenance: EdgeProvenance, +} + +impl Reading<'_> { + fn project(&self, edge: CausalEdge64) -> Result { + self.declarations.project_state5( + self.class, + self.rail, + Epi5Gen::V1, + edge.epistemic_raw5(), + self.provenance, + ) + } +} + +fn stamp(edge: CausalEdge64, state: EpistemicState5) -> CausalEdge64 { + edge.with_epistemic_raw5(state.raw()) +} + +/// Write a measurement back to the edge it measured. Certification rises to +/// what the operator earned when that is strictly stronger; nothing else +/// moves, and nothing is ever lowered. +/// +/// Takes no edge: the write goes to [`Measured::edge`], so one measurement +/// cannot promote a different edge. +/// +/// # Errors +/// +/// The edge's bits 59..63 do not project under `reading`. +pub fn revise(m: &Measured, reading: Reading<'_>) -> Result { + let edge = m.edge; + let state = reading.project(edge)?; + let current = state.certification(); + let next = match m.earned { + Some(c) if c != current && c.entails(current) => c, + _ => current, + }; + Ok(stamp(edge, state.with_certification(next))) +} + +/// What a candidate intermediate B turned out to be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hydration { + /// No binding of B to the path exists in the sealed chain. + ProposedOnly, + /// One of `A → B`, `B → Y` exists. + Partial, + /// Both bindings exist. + Hydrated, +} + +/// The topology gate. `IndirectUnknown` becomes `IndirectKnown` only when both +/// bindings `a → b` and `b → y` are present in the sealed chain. Certification +/// never moves here. +/// +/// # Errors +/// +/// The edge's bits 59..63 do not project under `reading`. +pub fn hydrate( + edge: CausalEdge64, + (a, b, y): (u8, u8, u8), + sealed: &[ChainStep], + reading: Reading<'_>, +) -> Result<(CausalEdge64, Hydration), Epi5ReadError> { + let has = |s: u8, o: u8| sealed.iter().any(|(_, e)| e.s_idx() == s && e.o_idx() == o); + let found = match (has(a, b), has(b, y)) { + (true, true) => Hydration::Hydrated, + (false, false) => Hydration::ProposedOnly, + _ => Hydration::Partial, + }; + let state = reading.project(edge)?; + // The path must be this edge's own: `a → y` is the relation being hydrated. + let own_path = edge.s_idx() == a && edge.o_idx() == y; + if own_path && found == Hydration::Hydrated && state.topology() == Topology2::IndirectUnknown { + Ok(( + stamp(edge, state.with_topology(Topology2::IndirectKnown)), + found, + )) + } else { + Ok((edge, found)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_counterfactual::DEFAULT_FREQUENCY_BAR; + use crate::chain_replay::ComposeTables; + use causal_edge::edge::InferenceType; + use causal_edge::layout::EPISTEMIC_MASK; + use causal_edge::tables::NarsTables; + use causal_edge::PlasticityState; + use lance_graph_contract::causal_audit::SupportBasis; + use lance_graph_contract::certification::ModelBuilder; + use lance_graph_contract::epistemic_state5::Epi5Reading; + + const A: u8 = 10; + const B: u8 = 20; + const Y: u8 = 30; + const PREDICATE: u8 = 0x91; + const CLASS: ClassId = 0x0902; + const RAIL: RailAxis = RailAxis::Taxonomy; + + fn declarations() -> Epi5Declarations { + let mut d = Epi5Declarations::new(); + d.declare( + CLASS, + RAIL, + Epi5Reading { + generation: Epi5Gen::V1, + }, + ); + d + } + + fn reading(d: &Epi5Declarations) -> Reading<'_> { + Reading { + declarations: d, + class: CLASS, + rail: RAIL, + provenance: EdgeProvenance::V2Stamped, + } + } + + fn edge(s: u8, o: u8, freq: u8, conf: u8, mask: CausalMask) -> CausalEdge64 { + CausalEdge64::pack( + s, + PREDICATE, + o, + freq, + conf, + mask, + 0b101, + InferenceType::Deduction, + PlasticityState::S_HOT, + 0, + ) + } + + /// The queried relation `A → Y` under projection `mask`, at a given state. + fn query(mask: CausalMask, c: Certification3, t: Topology2) -> CausalEdge64 { + stamp( + edge(A, Y, 200, 200, mask), + EpistemicState5::new(Epi5Gen::V1, t, c), + ) + } + + fn with_mask(mut e: CausalEdge64, mask: CausalMask) -> CausalEdge64 { + e.set_causal_mask(mask); + e + } + + fn state(e: CausalEdge64) -> (Topology2, Certification3) { + let s = EpistemicState5::decode(Epi5Gen::V1, e.epistemic_raw5()).expect("valid code"); + (s.topology(), s.certification()) + } + + /// Associated pooled and after the robustness mask, lowered in stratum 1: + /// observationally `Related`, not `Supports`. + fn related_population() -> ModelBuilder { + let mut b = ModelBuilder::new(); + b.cell(0, true, 5, 4); + b.cell(0, false, 5, 1); + b.cell(1, true, 3, 1); + b.cell(1, false, 3, 2); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + b + } + + fn robust_association() -> ModelBuilder { + let mut b = ModelBuilder::new(); + b.cell(0, true, 5, 4); + b.cell(0, false, 5, 1); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + b + } + + fn add_positive_trial(b: &mut ModelBuilder) { + b.arm(true, 6, 5); + b.arm(false, 6, 1); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + } + + fn with_trial(mut b: ModelBuilder) -> CertificationModel { + add_positive_trial(&mut b); + b.build() + } + + /// The mediated chain `A → B → Y`, measured: weak first step, strong + /// second. Cutting `B → Y` drops below the bar; cutting `A → B` raises it. + fn chain_steps() -> Vec { + vec![ + (PREDICATE, edge(A, B, 40, 220, CausalMask::SPO)), + (PREDICATE, edge(B, Y, 250, 220, CausalMask::SPO)), + ] + } + + fn compose_tables() -> [Box<[u8; 256 * 256]>; 3] { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + core::array::from_fn(|_| { + let mut t = Box::new([0u8; 256 * 256]); + for v in t.iter_mut() { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1); + *v = ((x >> 33) % 256) as u8; + } + t + }) + } + + struct Replay { + tables: NarsTables, + compose: [Box<[u8; 256 * 256]>; 3], + steps: Vec, + } + + impl Replay { + fn new() -> Self { + Replay { + tables: NarsTables::build(1), + compose: compose_tables(), + steps: chain_steps(), + } + } + + fn chain(&self) -> Chain<'_> { + Chain { + steps: &self.steps, + seed: edge(A, Y, 200, 200, CausalMask::SPO), + cx: CutContext { + tables: &self.tables, + compose: ComposeTables { + s: &self.compose[0], + p: &self.compose[1], + o: &self.compose[2], + }, + owner: 3, + base_seq: 100, + bar: DEFAULT_FREQUENCY_BAR, + }, + } + } + + fn evidence<'a>(&'a self, model: &'a CertificationModel) -> Evidence<'a> { + Evidence { + model, + chain: Some(self.chain()), + } + } + } + + /// Run one operator under `mask` and write it back. + fn step( + e: CausalEdge64, + mask: CausalMask, + ev: &Evidence<'_>, + edit: Edit, + d: &Epi5Declarations, + ) -> (CausalEdge64, Measured) { + let e = with_mask(e, mask); + let m = reason(e, ev, edit); + (revise(&m, reading(d)).expect("declared"), m) + } + + /// Y = A or B per unit; the counterfactual edit is a mask. + struct OrGate { + a: u64, + b: u64, + } + + impl OrGate { + fn but_for(&self, unit: u64, disabled: u64) -> bool { + let b = self.b & !disabled; + ((self.a | b) ^ ((self.a & !unit) | b)) & unit != 0 + } + } + + fn overdetermined_trial() -> (CertificationModel, OrGate) { + let mut b = ModelBuilder::new(); + let (a_and_b, _) = b.arm(true, 3, 3); + let (a_only, _) = b.arm(true, 3, 3); + let (b_only, _) = b.arm(false, 3, 3); + b.arm(false, 3, 0); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + ( + b.build(), + OrGate { + a: a_and_b | a_only, + b: a_and_b | b_only, + }, + ) + } + + /// The CUI BONO script: the edge leaves with a different state only where + /// an operator earned it. + #[test] + fn cui_bono_script_moves_the_state_only_where_evidence_earned_it() { + let d = declarations(); + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + let start = query( + CausalMask::SO, + Certification3::Related, + Topology2::IndirectUnknown, + ); + + let (e, m) = step(start, CausalMask::SO, &ev, Edit::None, &d); + assert_eq!(m.earned(), Some(Certification3::Related)); + assert_eq!( + state(e), + (Topology2::IndirectUnknown, Certification3::Related) + ); + + let (e, h) = hydrate(e, (A, B, Y), &rp.steps, reading(&d)).unwrap(); + assert_eq!(h, Hydration::Hydrated); + assert_eq!( + state(e), + (Topology2::IndirectKnown, Certification3::Related) + ); + + let (e, m) = step(e, CausalMask::PO, &ev, Edit::None, &d); + assert_eq!(m.earned(), Some(Certification3::Causes)); + assert_eq!(state(e), (Topology2::IndirectKnown, Certification3::Causes)); + + let (after, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(1), &d); + assert_eq!( + m.reaction(), + Some(Reaction::LoadBearing { + factual: 185, + counterfactual: 120 + }) + ); + assert_eq!(after.epistemic_raw5(), e.epistemic_raw5()); + let (_, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(0), &d); + assert_eq!( + m.reaction(), + Some(Reaction::TruthOnly { + factual: 185, + counterfactual: 225 + }) + ); + } + + /// F1. Observation alone never reaches `Causes`, even when the sealed + /// model also holds a positive trial the SO operator could have read. + #[test] + fn f1_observation_alone_never_promotes_to_causes() { + let d = declarations(); + let rp = Replay::new(); + let mut many = robust_association(); + many.sources(SupportBasis::DirectlyObserved, &(3..40).collect::>()); + for b in [related_population(), robust_association(), many] { + let model = with_trial(b); + assert_eq!(model.certification(), Certification3::Causes); + let (e, m) = step( + query(CausalMask::SO, Certification3::Open, Topology2::Direct), + CausalMask::SO, + &rp.evidence(&model), + Edit::None, + &d, + ); + assert!(m.earned().is_some(), "anti-vacuity: SO must earn something"); + assert_ne!(m.earned(), Some(Certification3::Causes)); + assert_ne!(state(e).1, Certification3::Causes); + } + } + + /// F2. Setting `SPO` certifies nothing: with no chain the operator is not + /// grounded and the edge is unchanged. + #[test] + fn f2_setting_the_projection_certifies_nothing() { + let d = declarations(); + let model = related_population().build(); + let ev = Evidence { + model: &model, + chain: None, + }; + for c in Certification3::ALL { + let q = query(CausalMask::SPO, c, Topology2::IndirectUnknown); + let m = reason(q, &ev, Edit::CutStep(1)); + assert_eq!(m.ungrounded(), Some(Ungrounded::NoChain)); + assert_eq!(revise(&m, reading(&d)).unwrap(), q); + } + } + + /// F3. A load-bearing counterfactual reaction does not become `Causes`. + #[test] + fn f3_a_load_bearing_cut_is_not_causes() { + let d = declarations(); + let rp = Replay::new(); + let model = related_population().build(); + let ev = rp.evidence(&model); + for c in [ + Certification3::Open, + Certification3::Related, + Certification3::CausalCandidate, + ] { + let q = query(CausalMask::SPO, c, Topology2::IndirectKnown); + let m = reason(q, &ev, Edit::CutStep(1)); + assert!(matches!(m.reaction(), Some(Reaction::LoadBearing { .. }))); + assert_eq!(m.earned(), None); + assert_eq!(state(revise(&m, reading(&d)).unwrap()).1, c); + } + } + + /// F4. The counterfactual arm is tagged −6 and never written back. + #[test] + fn f4_the_counterfactual_branch_is_not_written_back() { + let d = declarations(); + let rp = Replay::new(); + let model = related_population().build(); + let q = query( + CausalMask::SPO, + Certification3::Related, + Topology2::IndirectKnown, + ); + let m = reason(q, &rp.evidence(&model), Edit::CutStep(1)); + let t = m.counterfactual_terminal().expect("replayed"); + assert_eq!( + t.inference_mantissa(), + InferenceType::Counterfactual.to_mantissa() + ); + assert_ne!(t.frequency_u8(), q.frequency_u8(), "anti-vacuity"); + let out = revise(&m, reading(&d)).unwrap(); + assert_eq!(out.frequency_u8(), q.frequency_u8()); + assert_eq!(out.confidence_u8(), q.confidence_u8()); + assert_eq!(out.inference_mantissa(), q.inference_mantissa()); + } + + /// F5. Proposing B changes nothing; half a path changes nothing; both + /// bindings move the topology and never the certification. + #[test] + fn f5_a_proposed_beneficiary_is_not_evidence() { + let d = declarations(); + let rp = Replay::new(); + let q = query( + CausalMask::SO, + Certification3::Related, + Topology2::IndirectUnknown, + ); + assert_eq!( + hydrate(q, (A, 99, Y), &rp.steps, reading(&d)).unwrap(), + (q, Hydration::ProposedOnly) + ); + assert_eq!( + hydrate(q, (A, B, Y), &rp.steps[..1], reading(&d)).unwrap(), + (q, Hydration::Partial) + ); + let (e, h) = hydrate(q, (A, B, Y), &rp.steps, reading(&d)).unwrap(); + assert_eq!(h, Hydration::Hydrated); + assert_eq!( + state(e), + (Topology2::IndirectKnown, Certification3::Related) + ); + } + + /// F6. SO and PO disagree in direction; SP reports it and does not demote + /// an intervention-certified `Causes`. Silence twin: agreeing directions + /// are not confounded. + #[test] + fn f6_simpson_does_not_demote_intervention_causes() { + let d = declarations(); + let rp = Replay::new(); + let mut b = ModelBuilder::new(); + b.cell(0, true, 8, 2); + b.cell(0, false, 2, 0); + b.cell(1, true, 2, 2); + b.cell(1, false, 8, 7); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + let model = with_trial(b); + let ev = rp.evidence(&model); + let (e, _) = step( + query(CausalMask::PO, Certification3::Open, Topology2::Direct), + CausalMask::PO, + &ev, + Edit::None, + &d, + ); + assert_eq!(state(e).1, Certification3::Causes); + let (after, m) = step(e, CausalMask::SP, &ev, Edit::None, &d); + assert_eq!(m.confounded(), Some(true)); + assert_eq!(state(after).1, Certification3::Causes); + let (after, _) = step(after, CausalMask::SO, &ev, Edit::None, &d); + assert_eq!(state(after).1, Certification3::Causes); + + let agreeing = with_trial(robust_association()); + let (_, m) = step( + query(CausalMask::SP, Certification3::Open, Topology2::Direct), + CausalMask::SP, + &rp.evidence(&agreeing), + Edit::None, + &d, + ); + assert_eq!(m.confounded(), Some(false)); + } + + /// F7. Y = A or B: removing A where B holds shows no reaction, removing A + /// with B disabled does, and the trial certifies `Causes`. A null reaction + /// never demotes. + #[test] + fn f7_one_at_a_time_removal_is_not_the_definition_of_causality() { + let d = declarations(); + let (trial, gate) = overdetermined_trial(); + let unit = (gate.a & gate.b).isolate_lowest_one(); + assert!(!gate.but_for(unit, 0)); + assert!(gate.but_for(unit, gate.b)); + assert!(gate.but_for((gate.a & !gate.b).isolate_lowest_one(), 0)); + + let rp = Replay::new(); + let ev = rp.evidence(&trial); + let (e, _) = step( + query(CausalMask::PO, Certification3::Open, Topology2::Direct), + CausalMask::PO, + &ev, + Edit::None, + &d, + ); + assert_eq!(state(e).1, Certification3::Causes); + let (after, m) = step(e, CausalMask::SPO, &ev, Edit::CutStep(0), &d); + assert!(matches!(m.reaction(), Some(Reaction::TruthOnly { .. }))); + assert_eq!(state(after).1, Certification3::Causes); + } + + /// F8. Every operator writes bits 59..63 only. + #[test] + fn f8_only_bits_59_to_63_move() { + let d = declarations(); + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + let mut moved = 0; + for v in 0u8..8 { + let mask = CausalMask::from_bits(v); + for mantissa in [-6i8, 0, 6] { + let q = query(mask, Certification3::Open, Topology2::IndirectUnknown) + .with_inference_mantissa(mantissa); + let out = revise(&reason(q, &ev, Edit::CutStep(1)), reading(&d)).unwrap(); + assert_eq!((out.0 ^ q.0) & !EPISTEMIC_MASK, 0, "{mask:?} {mantissa}"); + moved += usize::from(out != q); + } + } + assert!(moved > 0, "anti-vacuity"); + } + + /// F9. Same sealed evidence, same edit: same measurement, same bits. + #[test] + fn f9_replay_is_deterministic() { + let d = declarations(); + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + for v in 0u8..8 { + let q = query( + CausalMask::from_bits(v), + Certification3::Related, + Topology2::IndirectKnown, + ); + for edit in [Edit::None, Edit::CutStep(0), Edit::CutStep(1)] { + let (m1, m2) = (reason(q, &ev, edit), reason(q, &ev, edit)); + assert_eq!(m1, m2); + assert_eq!(revise(&m1, reading(&d)), revise(&m2, reading(&d))); + } + } + } + + /// F10. A pass must be executed: intervention receipts without arms are + /// not grounded, and executed arms without an effect earn nothing. + #[test] + fn f10_a_pass_must_be_executed_not_claimed() { + let d = declarations(); + let rp = Replay::new(); + let mut b = related_population(); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + let claimed = b.build(); + let (e, m) = step( + query(CausalMask::PO, Certification3::Related, Topology2::Direct), + CausalMask::PO, + &rp.evidence(&claimed), + Edit::None, + &d, + ); + assert_eq!( + m.ungrounded(), + Some(Ungrounded::Model(NotGrounded::EmptyArm)) + ); + assert_eq!(state(e).1, Certification3::Related); + + let mut b = related_population(); + b.arm(true, 6, 3); + b.arm(false, 6, 3); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + let null = b.build(); + let (e, m) = step( + query(CausalMask::PO, Certification3::Related, Topology2::Direct), + CausalMask::PO, + &rp.evidence(&null), + Edit::None, + &d, + ); + assert_eq!((m.earned(), m.ungrounded()), (None, None)); + assert_eq!(state(e).1, Certification3::Related); + } + + /// What a measurement computed, without the edge it is bound to. + type Outcome = ( + Operation, + Option, + Option, + Option, + Option, + Option, + ); + + fn outcome(m: &Measured) -> Outcome { + ( + m.operation(), + m.earned(), + m.ungrounded(), + m.reaction(), + m.confounded(), + m.counterfactual_terminal(), + ) + } + + /// F11. Bits 59..63 do not select the operation. + #[test] + fn f11_bits_59_to_63_are_output_not_a_selector() { + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + for v in 0u8..8 { + let mask = CausalMask::from_bits(v); + let base = reason( + query(mask, Certification3::Open, Topology2::Direct), + &ev, + Edit::CutStep(1), + ); + for c in Certification3::ALL { + for t in Topology2::ALL { + // Everything the operator computed is identical; only the + // bound input edge differs, by bits 59..63. + assert_eq!( + outcome(&reason(query(mask, c, t), &ev, Edit::CutStep(1))), + outcome(&base) + ); + } + } + } + } + + /// F12. Projection, mantissa and epistemic state are not aliases. + #[test] + fn f12_projection_mantissa_and_state_are_not_aliases() { + let d = declarations(); + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + for v in 0u8..8 { + let mask = CausalMask::from_bits(v); + for mantissa in [ + InferenceType::Intervention.to_mantissa(), + InferenceType::Counterfactual.to_mantissa(), + 0, + ] { + let q = query(mask, Certification3::Open, Topology2::Direct) + .with_inference_mantissa(mantissa); + let m = reason(q, &ev, Edit::CutStep(1)); + assert_eq!(m.operation(), Operation::of(mask)); + let out = revise(&m, reading(&d)).unwrap(); + assert_eq!(out.causal_mask(), mask); + assert_eq!(out.inference_mantissa(), mantissa); + } + } + } + + /// An undeclared class is refused, not read through a guess. + #[test] + fn an_undeclared_reading_is_refused() { + let d = Epi5Declarations::new(); + let model = related_population().build(); + let q = query(CausalMask::SO, Certification3::Open, Topology2::Direct); + let m = reason( + q, + &Evidence { + model: &model, + chain: None, + }, + Edit::None, + ); + assert!(revise(&m, reading(&d)).is_err()); + } + + #[test] + fn the_operators_read_a_population_wider_than_64_units() { + // 300 observational units and 200 executed trial units, placed past the + // first 64-unit word. A `u64` model cannot hold this population; the + // operators read it unchanged through the wide mask. + let d = declarations(); + let mut b = ModelBuilder::<[u64; 16]>::default(); + b.skip(64); + b.cell(0, true, 150, 120); + b.cell(0, false, 150, 30); + b.arm(true, 100, 70); + b.arm(false, 100, 20); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + let model = b.build(); + let ev = Evidence { + model: &model, + chain: None, + }; + + let so = query(CausalMask::SO, Certification3::Open, Topology2::Direct); + let m = reason(so, &ev, Edit::None); + assert_eq!(m.earned(), Some(Certification3::CausalCandidate)); + + let po = query(CausalMask::PO, Certification3::Open, Topology2::Direct); + let m = reason(po, &ev, Edit::None); + assert_eq!(m.earned(), Some(Certification3::Causes)); + let e = revise(&m, reading(&d)).expect("declared reading"); + assert_eq!(state(e).1, Certification3::Causes); + + let sp = query(CausalMask::SP, Certification3::Open, Topology2::Direct); + assert_eq!(reason(sp, &ev, Edit::None).confounded(), Some(false)); + + // Silence twin over the same width: null arms earn nothing. + let mut b = ModelBuilder::<[u64; 16]>::default(); + b.skip(64); + b.cell(0, true, 150, 75); + b.cell(0, false, 150, 75); + b.arm(true, 100, 50); + b.arm(false, 100, 50); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + b.sources(SupportBasis::InterventionBacked, &[10, 11]); + let null = b.build(); + let ev = Evidence { + model: &null, + chain: None, + }; + assert_eq!(reason(so, &ev, Edit::None).earned(), None); + assert_eq!(reason(po, &ev, Edit::None).earned(), None); + } + + #[test] + fn a_measurement_writes_back_only_to_the_edge_it_measured() { + // Codex #1391: a retained `Measured` must not promote another edge. + // `revise` takes no edge, so the write lands on `Measured::edge`. + let d = declarations(); + let rp = Replay::new(); + let model = with_trial(related_population()); + let ev = rp.evidence(&model); + let q = query(CausalMask::PO, Certification3::Open, Topology2::Direct); + let m = reason(q, &ev, Edit::None); + assert_eq!(m.edge(), q); + assert_eq!(m.earned(), Some(Certification3::Causes)); + let out = revise(&m, reading(&d)).unwrap(); + assert_eq!((out.0 ^ q.0) & !EPISTEMIC_MASK, 0); + assert_eq!(state(out).1, Certification3::Causes); + } + + #[test] + fn observation_with_nothing_to_compare_is_ungrounded_not_refuted() { + // Codex #1391: two observational sources but no units. Every rung's + // comparison has an empty arm, so this is missing evidence, not a + // negative result. + let rp = Replay::new(); + let mut b = ModelBuilder::new(); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + let empty = b.build(); + let so = query(CausalMask::SO, Certification3::Open, Topology2::Direct); + let m = reason(so, &rp.evidence(&empty), Edit::None); + assert_eq!(m.earned(), None); + assert_eq!( + m.ungrounded(), + Some(Ungrounded::Model(NotGrounded::EmptyArm)) + ); + // Silence twin: equal rates over real units is a grounded negative. + let mut b = ModelBuilder::new(); + b.cell(0, true, 5, 2); + b.cell(0, false, 5, 2); + b.sources(SupportBasis::DirectlyObserved, &[1, 2]); + let null = b.build(); + let m = reason(so, &rp.evidence(&null), Edit::None); + assert_eq!((m.earned(), m.ungrounded()), (None, None)); + } + + #[test] + fn hydration_only_promotes_the_edge_whose_path_it_is() { + // CodeRabbit #1391: the bindings A → B → Y exist, but the edge offered + // is X → Z. Its topology must not move. + let d = declarations(); + let rp = Replay::new(); + let other = stamp( + edge(77, 78, 200, 200, CausalMask::SO), + EpistemicState5::new( + Epi5Gen::V1, + Topology2::IndirectUnknown, + Certification3::Open, + ), + ); + let (out, h) = hydrate(other, (A, B, Y), &rp.steps, reading(&d)).unwrap(); + assert_eq!(h, Hydration::Hydrated); + assert_eq!(out, other); + // Silence twin: the edge whose path it is does move. + let own = query( + CausalMask::SO, + Certification3::Open, + Topology2::IndirectUnknown, + ); + let (out, _) = hydrate(own, (A, B, Y), &rp.steps, reading(&d)).unwrap(); + assert_eq!(state(out).0, Topology2::IndirectKnown); + } + + #[test] + fn a_cut_that_moves_nothing_is_inert() { + // CodeRabbit #1391: classification used to compare the arms' lengths, + // and a cut always shortens the chain, so `Inert` was unreachable. + assert_eq!( + Reaction::classify(false, (180, 200), (180, 200)), + Reaction::Inert { frequency: 180 } + ); + // Silence twins: confidence alone moving is TruthOnly; a load-bearing + // cut stays LoadBearing even with equal truths. + assert_eq!( + Reaction::classify(false, (180, 200), (180, 190)), + Reaction::TruthOnly { + factual: 180, + counterfactual: 180 + } + ); + assert_eq!( + Reaction::classify(true, (180, 200), (180, 200)), + Reaction::LoadBearing { + factual: 180, + counterfactual: 180 + } + ); + } +} diff --git a/docs/architecture/ce64-semantic-upper-half.md b/docs/architecture/ce64-semantic-upper-half.md index 13f5594ab..05f3ac83c 100644 --- a/docs/architecture/ce64-semantic-upper-half.md +++ b/docs/architecture/ce64-semantic-upper-half.md @@ -32,11 +32,12 @@ as current behaviour and the diagram's name as a proposed interpretation. ## What the code already enforces from the diagram - **"Pearl asks the question; it does not certify the answer."** - `cognitive-shader-driver/examples/pearl_ladder_probe.rs` (D-PEARL-IO-0) - dispatches on bits 40..42 and lets only the measured result revise bits - 59..63. Only an executed intervention (`PO`) can earn `Causes`. + `lance_graph_planner::pearl` (D-PEARL-PROD-0) dispatches on bits 40..42 and + lets only the measured result revise bits 59..63. Only an executed + intervention (`PO`) can earn `Causes`; the obligations are + `lance_graph_contract::certification`. - **"Ablation and EWA are interrogation mechanics, not resident meaning."** - The counterfactual replay (`lance_graph_planner::dismech_counterfactual`) + The counterfactual replay (`lance_graph_planner::chain_counterfactual`) tags its arm with the −6 mantissa and never writes it back as observed truth. EWA-style propagation lives in `crates/jc/src/ewa_sandwich.rs` and writes no edge field.