diff --git a/.claude/board/SUPERSESSION-INDEX.md b/.claude/board/SUPERSESSION-INDEX.md index 8b57f7542..8657cc50b 100644 --- a/.claude/board/SUPERSESSION-INDEX.md +++ b/.claude/board/SUPERSESSION-INDEX.md @@ -94,7 +94,7 @@ a licence to act on it. | **READ** | `mul-ewa-trust-propagation-v1` | `GateDecision` | PROPOSED — PLAN/BOARD ONLY. Measure-before-c | 1/3 | | **READ** | `nan-ci-mode-v1` | `GateDecision` | PROPOSAL — **`D-NCI-1..5` are unbuilt. No Na | 1/8 | | **READ** | `persistence-artifact-backed-commit-v1` | `persist_cycle` | RATIFIED (operator ruling 2026-08-09). Phase | 0/0 | -| **READ** | `post-teardown-buildup-survey-v1` | `StepMask` | SURVEY, read-only, plan-only (no code, no te | 1/5 | +| **READ** | `post-teardown-buildup-survey-v1` | `StepMask` | SURVEY, read-only, plan-only (no code, no te | 2/5 | | **READ** | `r2il-bpe-typed-genetic-recombination-v1` | `GateDecision` | PROPOSAL, §7's three falsifiers now RUN (see | 1/1 | | **READ** | `scientific-kg-substrate-v1` | `GateDecision` | PROPOSED — **scoping doc**, no code. Records | 3/9 | | **READ** | `self-reasoning-substrate-v1` | `GateDecision` | PROPOSED — doc-only. No code, no contract ch | 4/5 | diff --git a/.claude/board/entries/2026-10-09-d-rpf-conf-0-confidence-surface.md b/.claude/board/entries/2026-10-09-d-rpf-conf-0-confidence-surface.md index 07b6bd037..9b1ac81d4 100644 --- a/.claude/board/entries/2026-10-09-d-rpf-conf-0-confidence-surface.md +++ b/.claude/board/entries/2026-10-09-d-rpf-conf-0-confidence-surface.md @@ -109,6 +109,12 @@ not definitions.) Every call site used for a live/not-live claim was read. production functions. Every in-tree caller passes `build(1)`, except `tests/chain_confidence.rs` (16). So on the replay/counterfactual path the emitted truth is **B(1)**. + + > ⊘ Corrected by D-RPF-TABLE-1 (`2026-10-09-d-rpf-table-1-fast-path-cost.md`): + > the replay path emits whatever tables its caller passes, and every caller + > in the tree is a test, example or probe. No production code constructs a + > `CutContext` or reads `NarsEngine::tables`, so B(1) changes no production + > decision today. - Not in the census, found while verifying it: `contract/high_heel.rs:240` `revise_truth` is another LIN copy with a 0.99 cap. The census sweep of differently named pooling functions was incidental, not exhaustive, so this diff --git a/.claude/board/entries/2026-10-09-d-rpf-table-1-fast-path-cost.md b/.claude/board/entries/2026-10-09-d-rpf-table-1-fast-path-cost.md new file mode 100644 index 000000000..80cc2c442 --- /dev/null +++ b/.claude/board/entries/2026-10-09-d-rpf-table-1-fast-path-cost.md @@ -0,0 +1,272 @@ +# 2026-10-09 — D-RPF-TABLE-1: what did the confidence-inert fast path buy? + +**Status:** MEASURED. Tests, one bench example, comment corrections and two +isolated guards (T2, T4). No production semantics changed; no G1 or G2 +policy chosen. +**Harness:** `crates/lance-graph-planner/tests/d_rpf_table_1.rs` (numeric, +replay drift, decisions; 15 tests) and +`crates/lance-graph-planner/examples/d_rpf_table_1_bench.rs` (throughput). +**Parent:** `2026-10-09-d-rpf-conf-0-confidence-surface.md` (#1424). Its +T1/T3/R1/G4/R3 results are taken as given and not re-measured. + +Laws: **A** = `CausalEdge64::revision` (confidence-aware). **Bn** = +`NarsTables::build(n).revise`, n ∈ {1, 2, 4, 8, 16}. All through the real +implementations. A's replay uses `step_with` (= `replay_step` with the law as +a parameter) and A's decisions use `decide` (= the `counterfactual_replay` + +`Reaction::classify` rule); both are pinned bit-exact against the real +functions for every table, so A-vs-Bn differences come only from the law. + +## Where B1 actually runs (correcting #1424) + +#1424 said "the replay/counterfactual path emits B1". Read in code: + +- `replay_chain` / `counterfactual_replay` / `pearl::reason` take the tables + from the caller. **No production code constructs a `CutContext`.** Every + caller is a test, an example or a probe, and all of them pass `build(1)` + except `tests/chain_confidence.rs` (16). +- `NarsEngine::new` allocates `build(1)` (256 KiB per engine; production + instance in `strategy/chat_bundle.rs:42`), but **no production code reads + `NarsEngine::tables`**: `revise_fast` / `deduce_fast` have only test + callers, and `on_response` revises with `truth_revision` (law C). +- `cognitive-shader-driver` does a `tables.revise` per hit, but **discards + the result** (`_revised_truth`, "observed only"), and nothing calls + `with_nars_tables` outside tests. + +So today B1 changes **no production decision**. It is the default of every +replay fixture and probe and of `NarsEngine`. G2 is therefore a question about +what future wiring should use, and about what the existing fixtures pin. + +## History (classification) + +- `NarsTables` and the "16 for full precision" comment: `de61aa5d` + (2026-03-28, "CausalEdge64 — 64-bit causal neuron crate"). +- `NarsEngine::new → build(1)`: `1d355e16` (2026-03-31, "wire causal-edge + protocol into NarsEngine hot path"), commented "fast path: 1 c-level = + 128 KB" and "L1-resident". That hot path indexed the **deduction** table + until `6e5e674a` (2026-09-10). No benchmark accompanies either commit. +- `replay_step` with the table: `badd0894` (2026-08-31, D-DCR-1). Its message + cites "the kernel W0 measured at ~35 ns"; `dcr_w0_replay_budget` timed the + table+forward kernel at `build(1)` only and never compared it with direct + revision. + +Classification: **a deliberate production default chosen on an unmeasured +speed/size assumption**, not a benchmark shortcut. It is not an accident, and +it was never measured against the law it replaces. + +## Axis 1 — numeric surface vs A (R3 domain, 1,638,400 cells) + +| law | tables | bytes | cells ≠ A | broad ≠ A | broad max dF | broad max dC | mean dF | mean dC | p50/p95/p99 dF | p50/p95/p99 dC | distinct c | +|---|---|---|---|---|---|---|---|---|---|---|---| +| B1 | 1 | 262,144 | 1,636,550 | 1,598,385 | 128 | 168 | 32.72 | 47.65 | 24/99/123 | 46/98/136 | 1 | +| B2 | 4 | 655,360 | 1,634,496 | 1,596,341 | 127 | 100 | 16.76 | 25.25 | 9/65/99 | 23/56/68 | 3 | +| B4 | 16 | 2,228,224 | 1,629,527 | 1,591,392 | 125 | 55 | 8.93 | 12.92 | 4/35/66 | 12/28/35 | 10 | +| B8 | 64 | 8,519,680 | 1,616,783 | 1,578,704 | 121 | 28 | 4.68 | 6.51 | 2/18/37 | 6/14/17 | 32 | +| B16 | 256 | 33,685,504 | 1,585,273 | 1,547,346 | 113 | 14 | 2.41 | 3.27 | 1/9/19 | 3/7/9 | 92 | + +Mean error roughly halves per doubling. No level approaches A: even B16 +differs on 96.8% of cells, and **broad max dF stays above 100 at every level**, +because a bucket midpoint misstates the relative weight of two inputs whose +confidences sit far apart inside their buckets. + +## Axis 2 — replay drift (256 chains × 64 steps, two corpora) + +`Uniform` spans the grid; `W0` is the distribution `dcr_w0_replay_budget` +sized the kernel on (f 128..=255, c 128..=227). + +- **Every table diverges from A at step 1** on 245–256 of 256 chains. No chain + under any table stays equal to A for 64 steps. +- Mean |dF| / |dC| at step 64 (hundredths in the pin): + +| law | Uniform dF | Uniform dC | W0 dF | W0 dC | +|---|---|---|---|---| +| B1 | 43.86 | 83.25 | 16.34 | 81.00 | +| B2 | 42.75 | 45.57 | 16.33 | 32.00 | +| B4 | 39.53 | 24.14 | 11.86 | 18.91 | +| B8 | 33.77 | 12.52 | 6.56 | 9.74 | +| B16 | 28.05 | 6.20 | 3.63 | 4.69 | + +- Frequency error **grows** with chain length on the uniform corpus at every + resolution (B16: 2.2 at step 1, 28.1 at step 64). Error is not monotone in + steps on W0 (B16 peaks at step 16). +- Only F/C ever differ: palettes, mask, direction, plasticity, witness and + Epi5 agree with A at every step (pinned). So the table cannot move Epi5 or + Pearl operator selection. +- A itself saturates on the uniform corpus: 43/256 chains are at c = 255 by + step 64 (frequency frozen from then on), and 13 steps hit the G1 cell. W0 + never does. + +## Axis 3 — decisions (every cut of 256 chains) + +Flips against A. "verdict" = the chain's own frequency-bar verdict; +"LB" = load-bearing; "class" = `Reaction` variant; "c-only" = A said TruthOnly +with equal frequencies and the law said Inert. + +| corpus, len | law | cuts | verdict | LB | class | c-only | +|---|---|---|---|---|---|---| +| Uniform, 8 | B1 | 2048 | 776 (37.9%) | 244 (11.9%) | 905 (44.2%) | 16 | +| Uniform, 8 | B2 | 2048 | 552 | 205 | 751 | 13 | +| Uniform, 8 | B4 | 2048 | 368 | 183 | 646 | 13 | +| Uniform, 8 | B8 | 2048 | 168 | 105 | 502 | 13 | +| Uniform, 8 | B16 | 2048 | 96 (4.7%) | 77 (3.8%) | 426 (20.8%) | 15 | +| Uniform, 2 | B1 | 512 | 104 | 125 | 156 | 1 | +| Uniform, 2 | B16 | 512 | 6 | 9 | 35 | 2 | +| W0, 8 | B1 | 2048 | 0 | 0 | 632 (30.9%) | 54 | +| W0, 8 | B16 | 2048 | 0 | 0 | 238 (11.6%) | 45 | + +(All 20 rows are pinned in `AXIS3`.) + +- **The W0 zeros are structural, not stability.** Every W0 frequency is + ≥ 128, so every weighted mean is too, and no chain can fall below the 128 + bar under any law (pinned in `w0_corpus_cannot_flip_the_frequency_bar`). +- **The repository's own load-bearing fixture is load-bearing under B1 only.** + `chain_counterfactual::cutting_a_load_bearing_edge_flips_the_verdict` + (seed 200/200, steps 250/250 then 40/30, cut 0) gives B1 133 → 120, across + the bar. A gives 246 → 194, and B2…B16 give 207–244 → 184–198: both arms + clear the bar, so the cut is **not** load-bearing. The fixture was found by + sweeping under B1 and pins a property of B1, not of revision. +- `DEFAULT_FREQUENCY_BAR`'s doc justifies reading frequency because + confidence "saturates" at 170 in every chain. 170 is not saturation: it is + B1 discarding both confidences and returning one constant. Under A, + confidence varies along every chain. The frequency-over-confidence choice may + still be right; its stated evidence is B1's constant. +- Chain admission reads no truth, so the law cannot affect it. + +## Axis 4 — throughput + +Machine: 4 vCPU Xeon @ 2.1 GHz; per core 48 KiB L1d, 2 MiB L2; 260 MiB shared +L3. Median of 7 runs per cell; the table shows the median of 3 such runs (the +spread across runs is in the transcript and stays within the noted ranges). + +| law | memory | micro unif ns | micro w0 ns | replay dep ns | replay batch ns | cold ns | +|---|---|---|---|---|---|---| +| A | 0 | 12.1 | 12.1 | 32.7 | 15.7 | 11.5 | +| B1 | 256 KiB | 3.4 | 8.5 | 7.4 | 7.0 | 19.0 | +| B2 | 640 KiB | 3.8 | 8.6 | 7.4 | 7.4 | 28.2 | +| B4 | 2.1 MiB | 13.4 | 3.6 | 7.7 | 7.2 | 36.8 | +| B8 | 8.1 MiB | 17.5 | 6.0 | 8.4 | 7.3 | 51.7 | +| B16 | 32.1 MiB | 29.2 | 12.4 | 8.9 | 9.8 | 60.3 | +| A-slow (anti-vacuity) | 0 | ~480 | ~470 | ~500 | ~475 | ~455 | + +Reading: + +- **Replay, hot table:** every resolution is 3.7–4.4× faster than A on the + dependent replay and 1.6–2.2× faster on the batch replay. The + dependent replay is latency-bound and A's float divisions sit on the chain; + the table is one load. B16 is within ~1.2× of B1 here. +- **Random access (micro, uniform):** only B1 and B2 beat A (3.5×). B4 ties A; + **B8 and B16 are slower than A** (17.5 and 29.2 ns vs 12.1), because random + indices miss L2. +- **Cold:** **A beats every table**, B1 included (11.5 vs 19.0 ns). +- The replay rows use 64 cyclic weights (the W0 probe's shape), so the touched + table entries stay hot. A replay whose weights are spread over the grid + behaves like the micro-uniform row, not the replay row. +- Unexplained, recorded: B1/B2 micro are slower on `w0` inputs than on + `uniform` (8.5 vs 3.4 ns) and B4 the reverse, consistently across runs. + +## Pareto table + +| law | memory | replay dep / batch ns | micro unif ns | cold ns | broad max dF / dC | mean dF / dC | Uniform len 8 flips: verdict / LB / class | +|---|---|---|---|---|---|---|---| +| A | 0 | 32.7 / 15.7 | 12.1 | 11.5 | 0 / 0 | 0 / 0 | 0 / 0 / 0 | +| B1 | 256 KiB | 7.4 / 7.0 | 3.4 | 19.0 | 128 / 168 | 32.7 / 47.6 | 776 / 244 / 905 | +| B2 | 640 KiB | 7.4 / 7.4 | 3.8 | 28.2 | 127 / 100 | 16.8 / 25.3 | 552 / 205 / 751 | +| B4 | 2.1 MiB | 7.7 / 7.2 | 13.4 | 36.8 | 125 / 55 | 8.9 / 12.9 | 368 / 183 / 646 | +| B8 | 8.1 MiB | 8.4 / 7.3 | 17.5 | 51.7 | 121 / 28 | 4.7 / 6.5 | 168 / 105 / 502 | +| B16 | 32.1 MiB | 8.9 / 9.8 | 29.2 | 60.3 | 113 / 14 | 2.4 / 3.3 | 96 / 77 / 426 | + +## The pre-registered boundaries, evaluated + +| boundary | result | +|---|---| +| B1 survives if ≥ 1.5× faster in replay **and** planner outcomes stable | **Fails.** The speed half holds (2.2–4.4× replay, 3.6× random micro). The stability half does not: 37.9% verdict flips, 11.9% load-bearing flips and 44.2% Reaction-class flips on uniform 8-step chains, and the repository's own load-bearing fixture flips. | +| Some Bn faster while decision-stable | **Fails at every n.** B16 is the closest: 4.7% verdict, 3.8% LB, 20.8% class flips on uniform 8-step chains. | +| Table approach wounded if A replay ≥ best table / 1.1 | **Not wounded on hot replay** (A is 2–4× slower there). **Wounded off the hot path:** on random access B8/B16 are slower than A, B4 ties, and on a cold cache A beats every table. | +| B16 "full precision" falsified if material divergence remains | **Falsified.** 96.8% of cells differ, broad max dF 113, decision flips at every length. Comments corrected to "maximum table resolution". | + +So the answer to "what did we buy by throwing confidence away": **4.4× +throughput on a hot, dependent replay (2.2× batched), and 3.6× on random lookups — for a law +that flips about 4 in 10 verdicts on spread inputs.** Paying for resolution +does not rescue it: B16 keeps the hot-replay speed, loses the random-access +and cold speed, and still flips decisions. + +## The replay contract (item 11) + +`replay_step` runs `forward` (opcode = the weight's inference field: palettes +compose, mask intersects, direction and plasticity from the weight, F/C from +that opcode's truth function), then overwrites F/C with the table's revision. + +- They are different operations: forward computes the **rule** the weight + declares (deduction, induction, …); the table computes **revision** of the + running truth with the weight. The overwrite is deliberate (`badd0894`: + "no second truth register"). +- forward's own F/C computation is discarded every step. Its other outputs + are kept, and its opcode decode still matters (it faults on Counterfactual + and Intervention weights). Possible compute waste, not removed here. +- The output edge's inference field names the weight's opcode (e.g. + Deduction) while its F/C carry a revision. Recorded as a label/value + mismatch, not changed. + +## Comment corrections landed + +- `tables.rs`: "Use 16 for full precision" → "maximum table resolution … NOT + numerical full precision", with the measured gap; "Fits L1 cache" and + "32 MB — fits L2" replaced by the measured cache picture. +- `lib.rs`: tables are 128 KiB (u16 entries), not 64 KB, and do not fit L1. +- `nars_engine.rs`: "(128 KB, L1 cache resident)" and "(full precision)" + corrected. No behaviour change. + +## Independent guards (#1423) + +- **T2** (`causal-edge/tests/t2_pearl_subset_mask.rs`): PO (0b011) < SO + (0b101) numerically while PO ranks above SO; PO and SO are incomparable + subsets; no `field >= k` threshold selects "Intervention or above"; a paired + test shows the check passes for a numeric ranking. +- **T4** (`causal-edge/tests/t4_learn_plasticity_patterns.rs`): `learn` over + all eight plasticity patterns keeps frozen planes, moves hot planes, and + passes Pearl/Direction/Inference/Witness/Epi5 bit-exactly. + +## Disable runs (all red as required) + +- B1 replaced by A → 8 tests fail (surface, drift, flips, fixture, both + mirror tests). +- All `c_levels` forced to 1 → surface, drift, flips and fixture pins fail. +- Real `replay_step` stops writing the table result → both mirror-faithfulness + tests fail. (The drift/decision pins measure the laws through the mirror, so + they do not move; the mirror tests are what tie them to production.) +- Flip counter made blind to confidence → the confidence-only fixture, the + flips pin and the decision-mirror test fail. +- Bench: A-slow measures ~40× slower than A, so the harness separates kernels. +- T4: frozen-S check removed → fails; `learn` clearing the Pearl bits → fails. +- T2: rung read from the numeric field → 2 of 4 fail. + +## Kill conditions for what this leaves open + +- **B1 as a default** is dead unless a workload is shown whose inputs keep + frequencies on one side of every decision bar, *and* whose decisions do not + read `Reaction`'s class. W0 shows the first half can hold; the second did not + (30.9% class flips). +- **A table at any resolution** survives only for hot, dependent replay where + the decisions it feeds are frequency-bar verdicts on inputs that cannot + straddle the bar. Otherwise A is as fast or faster (random, cold) and exact. +- **The load-bearing fixture** must be re-derived under whichever law + production adopts. Under A it is not load-bearing. +- **`DEFAULT_FREQUENCY_BAR`'s rationale** must be re-stated without B1's + constant before any change to the replay law. + +``` +STATUS: measured | OUTCOME: B1 buys 2-4.4x hot-replay and 3.6x random-lookup +speed and loses ~38% of verdicts on spread inputs; no table resolution is +decision-stable; A wins cold and beats B8/B16 on random access; no production +code reads any NarsTables revision today; B16 "full precision" corrected; +T2/T4 guards landed | OPEN: G2 (which law future wiring uses), G1, +load-bearing fixture and frequency-bar rationale under the chosen law, +forward's discarded truth +``` + +MIRROR +- BLIND SPOT: two synthetic corpora; no real recorded chain set exists in the + tree to replay. The replay bench uses 64 cyclic weights, which flatters every + table. +- BIAS CHECK: the question was framed against B1, which made "B16 fixes it" + the easy counter-story; the micro-uniform and cold rows are what refute it. +- STILL OPEN: why B1/B2 micro lookups are slower on narrower inputs. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index b65fce9a6..7bb767a15 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,10 +25,11 @@ 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. -280 entries, 2026-08-06 .. 2026-10-09. +281 entries, 2026-08-06 .. 2026-10-09. | date | entry id | finding | file | |---|---|---|---| +| 2026-10-09 | `D-RPF-TABLE-1` | | [2026-10-09-d-rpf-table-1-fast-path-cost.md](2026-10-09-d-rpf-table-1-fast-path-cost.md) | | 2026-10-09 | `D-RPF-CONF-0` | | [2026-10-09-d-rpf-conf-0-confidence-surface.md](2026-10-09-d-rpf-conf-0-confidence-surface.md) | | 2026-10-08 | `register128-signed-readings` | | [2026-10-08-register128-signed-readings.md](2026-10-08-register128-signed-readings.md) | | 2026-10-08 | `D-ART-2` | | [2026-10-08-recipe-probe-phaselut-foldcost.md](2026-10-08-recipe-probe-phaselut-foldcost.md) | diff --git a/crates/causal-edge/src/lib.rs b/crates/causal-edge/src/lib.rs index 8a504d69f..c79fdedb0 100644 --- a/crates/causal-edge/src/lib.rs +++ b/crates/causal-edge/src/lib.rs @@ -39,7 +39,7 @@ //! Frequency (u8): f = val / 255.0, 256 quantization levels. //! Confidence (u8): c = val / 255.0, 256 quantization levels. //! At u8 resolution, revision/deduction/induction/abduction can be -//! precomputed as 256×256 lookup tables (64 KB each, fits L1 cache). +//! precomputed as 256×256 lookup tables (128 KiB each, u16 entries). //! //! ## Plasticity //! diff --git a/crates/causal-edge/src/tables.rs b/crates/causal-edge/src/tables.rs index 8d08519e2..d4019a7a9 100644 --- a/crates/causal-edge/src/tables.rs +++ b/crates/causal-edge/src/tables.rs @@ -3,8 +3,10 @@ //! At u8 quantization (256 levels for f and c), every NARS operation //! can be precomputed as a 256×256 lookup table. //! -//! Size per table: 256 × 256 × 2 bytes (output f,c packed as u16) = 128 KB. -//! Fits L1 cache. Every NARS inference becomes a single memory read. +//! Size per table: 256 × 256 × 2 bytes (output f,c packed as u16) = 128 KiB. +//! Every NARS inference becomes a single memory read. Note that 128 KiB is +//! larger than a typical L1d (48 KiB on the D-RPF-TABLE-1 bench host); where a +//! table actually sits is measured there, not assumed here. /// Packed (frequency_u8, confidence_u8) output as u16. /// Low byte = frequency, high byte = confidence. @@ -34,7 +36,9 @@ pub fn unpack_c(p: PackedTruth) -> u8 { /// For the full 4D table (f1, c1, f2, c2), we use a two-level approach: /// 1. Quantize c into 16 buckets (4 bits) /// 2. Build 16×16 = 256 tables of 256×256 entries each -/// Total: 256 × 128KB = 32 MB — fits L2 cache. +/// +/// Total: 256 × 128 KiB = 32 MiB, which on the D-RPF-TABLE-1 bench host is +/// larger than L2 (2 MiB per core) and lives in L3. /// /// For the fast path, we use a single table with average c: /// revision_fast[f1 * 256 + f2] → packed truth for c = c_mean. @@ -53,8 +57,13 @@ impl NarsTables { /// /// `c_levels`: number of confidence quantiles for revision tables. /// Total footprint is `(c_levels² + 1) × 128 KB` — the deduction table is - /// always allocated, independent of `c_levels`. Use 16 for full precision - /// (~32.1 MB), 1 for fast path (256 KB, not 128 KB). + /// always allocated, independent of `c_levels`. 16 is the **maximum table + /// resolution** (16 confidence buckets per side, ~32.1 MB), NOT numerical + /// full precision: each bucket uses its midpoint confidence, and against + /// direct revision (`CausalEdge64::revision`) 16 levels still differ on + /// ~97% of cells and by up to 113 frequency codes away from the boundary + /// (D-RPF-TABLE-1, `lance-graph-planner/tests/d_rpf_table_1.rs`). 1 is the + /// fast path (256 KB, not 128 KB) and ignores confidence entirely. pub fn build(c_levels: usize) -> Self { let c_levels = c_levels.clamp(1, 16); diff --git a/crates/causal-edge/tests/t2_pearl_subset_mask.rs b/crates/causal-edge/tests/t2_pearl_subset_mask.rs new file mode 100644 index 000000000..16eb72a90 --- /dev/null +++ b/crates/causal-edge/tests/t2_pearl_subset_mask.rs @@ -0,0 +1,58 @@ +//! T2 (CE64 coresearch, 2026-10-08): bits 40..42 are a SUBSET mask over the +//! S/P/O planes, not an ordinal ladder. Reading the field as a number and +//! testing `>= k` ranks the masks wrongly. + +use causal_edge::CausalMask; + +/// The rung a mask's own documentation assigns it, parsed from +/// `pearl_level()`, or `None` for masks the ladder does not name. +fn rung(m: CausalMask) -> Option { + let s = m.pearl_level(); + s.strip_prefix("Level ") + .and_then(|r| r.chars().next()) + .and_then(|c| c.to_digit(10)) + .map(|d| d as u8) +} + +#[test] +fn po_is_numerically_below_so_but_ranks_above_it() { + let (po, so) = (CausalMask::PO as u8, CausalMask::SO as u8); + assert!(po < so, "PO={po:#05b} SO={so:#05b}"); + assert_eq!(rung(CausalMask::PO), Some(2)); + assert_eq!(rung(CausalMask::SO), Some(1)); + assert!(rung(CausalMask::PO) > rung(CausalMask::SO)); +} + +/// PO and SO are incomparable subsets: neither contains the other, so no +/// numeric order on the field can agree with the rung order for both. +#[test] +fn po_and_so_are_incomparable_subsets() { + let (po, so) = (CausalMask::PO as u8, CausalMask::SO as u8); + assert_ne!(po & so, po); + assert_ne!(po & so, so); + assert_eq!(CausalMask::SPO as u8, po | so); +} + +/// Whether some `field >= k` admits exactly the named masks whose rank is at +/// least that of PO, under the ranking `rank`. +fn some_threshold_matches(rank: impl Fn(CausalMask) -> Option) -> bool { + let named = [CausalMask::SO, CausalMask::PO, CausalMask::SPO]; + (0u8..=8).any(|k| { + named + .iter() + .all(|&m| ((m as u8) >= k) == (rank(m) >= rank(CausalMask::PO))) + }) +} + +/// No threshold on the raw field selects "Intervention or above". +#[test] +fn no_numeric_threshold_reproduces_the_ladder() { + assert!(!some_threshold_matches(rung)); +} + +/// The check can pass: if the rung WERE the field's numeric value, a +/// threshold would work. Without this, the test above could not fail. +#[test] +fn the_threshold_check_passes_for_a_numeric_ranking() { + assert!(some_threshold_matches(|m| Some(m as u8))); +} diff --git a/crates/causal-edge/tests/t4_learn_plasticity_patterns.rs b/crates/causal-edge/tests/t4_learn_plasticity_patterns.rs new file mode 100644 index 000000000..e0ed90c6b --- /dev/null +++ b/crates/causal-edge/tests/t4_learn_plasticity_patterns.rs @@ -0,0 +1,91 @@ +//! T4 (CE64 coresearch, 2026-10-08): `learn` against `isa::contracts::LEARN`, +//! bit-exact over all eight plasticity patterns. +//! +//! The contract declares Pearl, Direction, Inference, Witness and Epistemic +//! as pass-through from A. Per plane, a FROZEN plane keeps A's archetype and +//! a HOT plane adopts the observation's when the observation is more +//! confident. The fixture keeps the revised confidence below 0.7, so the +//! plasticity field itself is not changed by the confidence transitions. +//! +//! The masks below are the v2 bit layout (plasticity 50..52, witness and +//! Epi5 53..63); v1 puts plasticity at 49..51 and temporal at 52..63, which +//! `learn` rewrites. Gated like the other layout-specific contract tests. +#![cfg(feature = "causal-edge-v2-layout")] + +use causal_edge::edge::InferenceType; +use causal_edge::{CausalEdge64, CausalMask, PlasticityState}; + +/// Bits 40..49 (Pearl, Direction, Inference) and 53..63 (Witness, Epi5). +const PASS: u64 = (0x3FF << 40) | (0x7FF << 53); +const PLAST_SHIFT: u32 = 50; + +#[allow(deprecated)] // v2 `pack` ignores temporal; not under test +fn edge(s: u8, p: u8, o: u8, c: u8, plast: PlasticityState) -> CausalEdge64 { + let mut e = CausalEdge64::pack( + s, + p, + o, + 200, + c, + CausalMask::SO, + 0b101, + InferenceType::Abduction, + plast, + 0, + ); + // Non-zero witness and epistemic bits, so pass-through is observable. + e.0 |= 0b101_1010_1101_u64 << 53; + e +} + +#[test] +fn learn_passes_frozen_planes_and_declared_fields_for_every_pattern() { + for bits in 0u8..8 { + let plast = PlasticityState::from_bits(bits); + let a = edge(1, 2, 3, 51, plast); // c = 0.2 + let obs = edge(9, 8, 7, 77, PlasticityState::ALL_HOT); // c ~ 0.3 + let mut out = a; + out.learn(obs, 0); + + assert_eq!( + out.0 & PASS, + a.0 & PASS, + "pass-through, plasticity {bits:03b}" + ); + assert_eq!( + out.0 >> PLAST_SHIFT & 0b111, + u64::from(bits), + "plasticity {bits:03b}" + ); + let want = |hot: bool, mine: u8, theirs: u8| if hot { theirs } else { mine }; + assert_eq!( + out.s_idx(), + want(plast.s_hot(), 1, 9), + "S, plasticity {bits:03b}" + ); + assert_eq!( + out.p_idx(), + want(plast.p_hot(), 2, 8), + "P, plasticity {bits:03b}" + ); + assert_eq!( + out.o_idx(), + want(plast.o_hot(), 3, 7), + "O, plasticity {bits:03b}" + ); + assert!( + out.confidence_u8() > 51 && out.confidence_u8() < 179, + "{}", + out.confidence_u8() + ); + } +} + +/// The fixture can see a change: a hot plane really does move, so the +/// frozen-plane assertions above are not satisfied by `learn` doing nothing. +#[test] +fn learn_moves_a_hot_plane() { + let mut out = edge(1, 2, 3, 51, PlasticityState::ALL_HOT); + out.learn(edge(9, 8, 7, 77, PlasticityState::ALL_HOT), 0); + assert_eq!((out.s_idx(), out.p_idx(), out.o_idx()), (9, 8, 7)); +} diff --git a/crates/lance-graph-planner/examples/d_rpf_table_1_bench.rs b/crates/lance-graph-planner/examples/d_rpf_table_1_bench.rs new file mode 100644 index 000000000..cb835309b --- /dev/null +++ b/crates/lance-graph-planner/examples/d_rpf_table_1_bench.rs @@ -0,0 +1,331 @@ +//! **D-RPF-TABLE-1, axis 4: what does the revision table buy in throughput?** +//! +//! ```text +//! cargo run -p lance-graph-planner --example d_rpf_table_1_bench --release +//! ``` +//! +//! Compares direct confidence-aware revision (A, `CausalEdge64::revision`) +//! with `NarsTables::build(n).revise` for n in {1, 2, 4, 8, 16}, at two +//! levels: +//! +//! - **micro**: one revision per input pair, inputs read from a 1 Mi-entry +//! array so nothing constant-folds, results summed into a sink. +//! - **replay**: the real `replay_step` for tables; for A, the same step with +//! the table lookup replaced by `CausalEdge64::revision` +//! (`tests/d_rpf_table_1.rs` proves that step bit-exact against +//! `replay_step` for every table). `dep` is one dependent 64-step chain +//! looped (latency-bound, the W0 shape); `batch` interleaves 256 chains so +//! independent steps can overlap (throughput-bound). +//! +//! Two input distributions: `uniform` (whole grid) and `w0` (f 128..=255, +//! c 128..=227, the distribution `dcr_w0_replay_budget` sized the kernel on). +//! `cold` scrubs a 512 MiB buffer before a short run, so the first table +//! accesses miss every cache level this machine has. +//! +//! **Anti-vacuity:** `A-slow` adds a deliberate f64 recompute to every A +//! revision. The harness must show it slower than A, or it cannot tell +//! kernels apart at all. +//! +//! Each number is the median of 7 runs. + +use std::hint::black_box; +use std::time::Instant; + +use causal_edge::edge::InferenceType; +use causal_edge::tables::{unpack_c, unpack_f, NarsTables}; +use causal_edge::{CausalEdge64, CausalMask, PlasticityState}; +use lance_graph_planner::chain_replay::{replay_step, ComposeTables}; + +const N: usize = 1 << 20; +const RUNS: usize = 7; +const LEVELS: [usize; 5] = [1, 2, 4, 8, 16]; + +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + fn byte(&mut self, lo: u8, hi: u8) -> u8 { + lo + (self.next() % (hi as u64 - lo as u64 + 1)) as u8 + } +} + +#[derive(Clone, Copy)] +enum Dist { + Uniform, + W0, +} + +impl Dist { + fn pick(self, r: &mut Lcg) -> (u8, u8) { + match self { + Dist::Uniform => (r.byte(0, 255), r.byte(0, 255)), + Dist::W0 => (r.byte(128, 255), r.byte(128, 227)), + } + } +} + +const OPS: [InferenceType; 4] = [ + InferenceType::Deduction, + InferenceType::Induction, + InferenceType::Abduction, + InferenceType::Revision, +]; + +#[allow(deprecated)] // v2 `pack` ignores temporal +fn edge(f: u8, c: u8, op: InferenceType) -> CausalEdge64 { + CausalEdge64::pack( + 1, + 2, + 3, + f, + c, + CausalMask::SPO, + 0, + op, + PlasticityState::ALL_HOT, + 0, + ) +} + +fn pairs(d: Dist, seed: u64) -> Vec<(CausalEdge64, CausalEdge64)> { + let mut r = Lcg(seed); + (0..N) + .map(|_| { + let (f1, c1) = d.pick(&mut r); + let (f2, c2) = d.pick(&mut r); + ( + edge(f1, c1, InferenceType::Revision), + edge(f2, c2, InferenceType::Revision), + ) + }) + .collect() +} + +fn median(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +/// The revision a step uses: A, A-slow, or a table. +#[derive(Clone, Copy)] +enum Law<'a> { + A, + ASlow, + Table(&'a NarsTables), +} + +#[inline(always)] +fn revise(law: Law<'_>, a: CausalEdge64, b: CausalEdge64) -> (u8, u8) { + match law { + Law::A => { + let r = a.revision(b); + (r.frequency_u8(), r.confidence_u8()) + } + Law::ASlow => { + let r = a.revision(b); + // Deliberate extra work: an f64 recompute of the same law. + let (c1, c2) = ( + a.confidence_u8() as f64 / 255.0, + b.confidence_u8() as f64 / 255.0, + ); + let mut x = c1 / (1.0 - c1 + 1e-9) + c2 / (1.0 - c2 + 1e-9); + for _ in 0..16 { + x = black_box(x).sqrt() * x.cbrt(); + } + let bump = u8::from(x.is_nan()); + (r.frequency_u8() ^ bump, r.confidence_u8()) + } + Law::Table(t) => { + let p = t.revise( + a.frequency_u8(), + a.confidence_u8(), + b.frequency_u8(), + b.confidence_u8(), + ); + (unpack_f(p), unpack_c(p)) + } + } +} + +fn micro(law: Law<'_>, input: &[(CausalEdge64, CausalEdge64)]) -> f64 { + let ns: Vec = (0..RUNS) + .map(|_| { + let t0 = Instant::now(); + let mut sink = 0u64; + for &(a, b) in input { + let (f, c) = revise(law, black_box(a), black_box(b)); + sink = sink.wrapping_add(f as u64 + ((c as u64) << 8)); + } + black_box(sink); + t0.elapsed().as_secs_f64() * 1e9 / input.len() as f64 + }) + .collect(); + median(ns) +} + +/// The replay step for `law`: `replay_step` for tables, the same body with +/// `revision` for A. +#[inline(always)] +fn step( + law: Law<'_>, + running: CausalEdge64, + w: CausalEdge64, + c: ComposeTables<'_>, +) -> CausalEdge64 { + match law { + Law::Table(t) => replay_step(running, w, t, c).expect("executable"), + _ => { + let (f, cc) = revise(law, running, w); + let mut out = running.forward(w, c.s, c.p, c.o).expect("executable"); + out.set_frequency_u8(f); + out.set_confidence_u8(cc); + out + } + } +} + +fn weights(d: Dist, n: usize, seed: u64) -> Vec { + let mut r = Lcg(seed); + (0..n) + .map(|_| { + let (f, c) = d.pick(&mut r); + edge(f, c, OPS[(r.next() % 4) as usize]) + }) + .collect() +} + +/// One dependent chain over 64 cyclic weights (the W0 shape). +fn replay_dep(law: Law<'_>, w: &[CausalEdge64], seed: CausalEdge64, c: ComposeTables<'_>) -> f64 { + let iters = N; + let ns: Vec = (0..RUNS) + .map(|_| { + let mut running = seed; + let t0 = Instant::now(); + for i in 0..iters { + running = step(law, running, black_box(w[i & 63]), c); + } + black_box(running.0); + t0.elapsed().as_secs_f64() * 1e9 / iters as f64 + }) + .collect(); + median(ns) +} + +/// 256 independent chains advanced in lockstep. +fn replay_batch( + law: Law<'_>, + w: &[CausalEdge64], + seeds: &[CausalEdge64], + c: ComposeTables<'_>, +) -> f64 { + let rounds = N / seeds.len(); + let ns: Vec = (0..RUNS) + .map(|_| { + let mut run = seeds.to_vec(); + let t0 = Instant::now(); + for r in 0..rounds { + for (j, x) in run.iter_mut().enumerate() { + *x = step(law, *x, black_box(w[(r * 7 + j) % w.len()]), c); + } + } + black_box(run[0].0); + t0.elapsed().as_secs_f64() * 1e9 / (rounds * seeds.len()) as f64 + }) + .collect(); + median(ns) +} + +/// First accesses after a 512 MiB scrub, so the table is out of every cache. +fn cold(law: Law<'_>, input: &[(CausalEdge64, CausalEdge64)]) -> f64 { + let mut scrub = vec![0u8; 512 << 20]; + let ns: Vec = (0..RUNS) + .map(|k| { + for (i, b) in scrub.iter_mut().enumerate().step_by(64) { + *b = b.wrapping_add((i + k) as u8); + } + black_box(&scrub); + let n = 4096; + let t0 = Instant::now(); + let mut sink = 0u64; + for &(a, b) in &input[k * n..(k + 1) * n] { + let (f, c) = revise(law, black_box(a), black_box(b)); + sink = sink.wrapping_add(f as u64 + ((c as u64) << 8)); + } + black_box(sink); + t0.elapsed().as_secs_f64() * 1e9 / n as f64 + }) + .collect(); + median(ns) +} + +fn compose_tables() -> [Box<[u8; 256 * 256]>; 3] { + let mut r = Lcg(0x9E37_79B9_7F4A_7C15); + core::array::from_fn(|_| { + let mut t = Box::new([0u8; 256 * 256]); + for v in t.iter_mut() { + *v = r.next() as u8; + } + t + }) +} + +fn main() { + let ct = compose_tables(); + let compose = ComposeTables { + s: &ct[0], + p: &ct[1], + o: &ct[2], + }; + let built: Vec = LEVELS.iter().map(|&n| NarsTables::build(n)).collect(); + let mut laws: Vec<(String, Law<'_>, usize)> = + vec![("A".into(), Law::A, 0), ("A-slow".into(), Law::ASlow, 0)]; + for (n, t) in LEVELS.iter().zip(&built) { + laws.push((format!("B{n}"), Law::Table(t), t.byte_size())); + } + + println!( + "{:<7} {:>10} | {:>13} {:>13} | {:>13} {:>13} | {:>13} {:>13} | {:>13}", + "law", + "bytes", + "micro unif", + "micro w0", + "replay dep u", + "replay dep w0", + "replay bat u", + "replay bat w0", + "cold unif" + ); + for d in [Dist::Uniform, Dist::W0] { + // Warm the inputs once so page faults are not charged to the first law. + black_box(pairs(d, 1).len()); + } + let pu = pairs(Dist::Uniform, 0xA11C_E5ED); + let pw = pairs(Dist::W0, 0xB0B_5EED); + let wu = weights(Dist::Uniform, 64, 0x51DE); + let ww = weights(Dist::W0, 64, 0x051E_D270_B5A1_11E5); + let su: Vec<_> = pairs(Dist::Uniform, 7) + .iter() + .take(256) + .map(|p| p.0) + .collect(); + let sw: Vec<_> = pairs(Dist::W0, 7).iter().take(256).map(|p| p.0).collect(); + for (name, law, bytes) in &laws { + println!( + "{:<7} {:>10} | {:>10.2} ns {:>10.2} ns | {:>10.2} ns {:>10.2} ns | {:>10.2} ns {:>10.2} ns | {:>10.2} ns", + name, + bytes, + micro(*law, &pu), + micro(*law, &pw), + replay_dep(*law, &wu, su[0], compose), + replay_dep(*law, &ww, sw[0], compose), + replay_batch(*law, &wu, &su, compose), + replay_batch(*law, &ww, &sw, compose), + cold(*law, &pu), + ); + } +} diff --git a/crates/lance-graph-planner/src/cache/nars_engine.rs b/crates/lance-graph-planner/src/cache/nars_engine.rs index 099564308..f9de444bd 100644 --- a/crates/lance-graph-planner/src/cache/nars_engine.rs +++ b/crates/lance-graph-planner/src/cache/nars_engine.rs @@ -431,11 +431,12 @@ pub fn style_score( /// Closed-loop NARS feedback engine. /// -/// Dual-path: hot path uses NarsTables (u16 lookup, L1 cache), +/// Dual-path: hot path uses NarsTables (u16 lookup), /// cold path uses f32 Truth for configuration and analysis. pub struct NarsEngine { pub distances: SpoDistances, - /// Precomputed NARS lookup tables (128 KB, L1 cache resident). + /// Precomputed NARS lookup tables (256 KiB at the default 1 level: one + /// 128 KiB revision table plus the 128 KiB deduction table). /// Hot path: revision/deduction as single memory read. pub tables: NarsTables, /// Skepticism: grows with consecutive high-confidence outputs. @@ -461,7 +462,8 @@ impl NarsEngine { /// `(c_levels² + 1) × 128 KB` — the revision tables plus the always-present /// 128 KB deduction table: **1 → 256 KB** (the `new` default, confidence /// inert — NOT 128 KB; the deduction table is allocated unconditionally), - /// **4 → ~2.1 MB**, **16 → ~32.1 MB** (full precision). Pick deliberately; + /// **4 → ~2.1 MB**, **16 → ~32.1 MB** (maximum table resolution, still not equal to direct + /// revision — D-RPF-TABLE-1). Pick deliberately; /// the default is the fast path, not the accurate one. pub fn with_c_levels(distances: SpoDistances, c_levels: usize) -> Self { Self { diff --git a/crates/lance-graph-planner/tests/d_rpf_table_1.rs b/crates/lance-graph-planner/tests/d_rpf_table_1.rs new file mode 100644 index 000000000..30d5402da --- /dev/null +++ b/crates/lance-graph-planner/tests/d_rpf_table_1.rs @@ -0,0 +1,881 @@ +//! D-RPF-TABLE-1: does the confidence-inert `NarsTables::build(1)` fast path +//! earn its semantic cost? +//! +//! Three of the four axes live here (numeric surface, replay drift, +//! downstream decisions); the fourth, throughput, is the +//! `d_rpf_table_1_bench` example, because a timing is not a test assertion. +//! +//! Laws compared, all through their real implementations: +//! - A `CausalEdge64::revision` (confidence-aware ISA revision) +//! - Bn `NarsTables::build(n).revise` for n in {1, 2, 4, 8, 16} +//! +//! The replay for A cannot go through `replay_step`, which takes a table. It +//! goes through `step_with`, which is `replay_step`'s body with the truth law +//! as a parameter. `step_with_a_table_is_replay_step` proves the mirror is +//! bit-exact against the real `replay_step` for every table resolution, so a +//! difference between A and Bn can only come from the law. +//! +//! Report: `.claude/board/entries/2026-10-09-d-rpf-table-1-fast-path-cost.md`. + +use causal_edge::edge::InferenceType; +use causal_edge::isa::IsaFault; +use causal_edge::tables::{unpack_c, unpack_f, NarsTables}; +use causal_edge::{CausalEdge64, CausalMask, PlasticityState}; +use lance_graph_planner::chain_counterfactual::{ + counterfactual_replay, cut_step, CutContext, DEFAULT_FREQUENCY_BAR, +}; +use lance_graph_planner::chain_replay::{replay_step, ChainStep, ComposeTables}; +use lance_graph_planner::pearl::Reaction; + +const LEVELS: [usize; 5] = [1, 2, 4, 8, 16]; + +/// A revision law over the u8 truth grid. +type Law<'a> = Box (u8, u8) + 'a>; + +#[allow(deprecated)] // v2 `pack` ignores temporal; not under test +fn edge(f: u8, c: u8, op: InferenceType) -> CausalEdge64 { + CausalEdge64::pack( + 1, + 2, + 3, + f, + c, + CausalMask::SPO, + 0, + op, + PlasticityState::ALL_HOT, + 0, + ) +} + +fn law_a() -> Law<'static> { + Box::new(|f1, c1, f2, c2| { + let r = + edge(f1, c1, InferenceType::Revision).revision(edge(f2, c2, InferenceType::Revision)); + (r.frequency_u8(), r.confidence_u8()) + }) +} + +fn law_table(t: &NarsTables) -> Law<'_> { + Box::new(move |f1, c1, f2, c2| { + let p = t.revise(f1, c1, f2, c2); + (unpack_f(p), unpack_c(p)) + }) +} + +fn tables() -> Vec { + LEVELS.iter().map(|&n| NarsTables::build(n)).collect() +} + +// ─── Axis 1: the numeric surface ──────────────────────────────────────── + +/// The R3 domain of D-RPF-CONF-0, for comparability. +const FS: [u8; 5] = [0, 51, 128, 204, 255]; +const CELLS: u64 = (FS.len() * FS.len() * 256 * 256) as u64; + +fn is_boundary(c: u8) -> bool { + c == 0 || c >= 254 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Surface { + differ: u64, + differ_broad: u64, + max_df: u8, + max_dc: u8, + max_df_broad: u8, + max_dc_broad: u8, + sum_df: u64, + sum_dc: u64, + /// p50 / p95 / p99 of |dF| and |dC| over all cells. + pct_df: [u8; 3], + pct_dc: [u8; 3], + distinct_c: usize, +} + +fn percentiles(hist: &[u64; 256]) -> [u8; 3] { + let mut out = [0u8; 3]; + for (k, q) in [0.50f64, 0.95, 0.99].iter().enumerate() { + let want = (q * CELLS as f64).ceil() as u64; + let mut acc = 0; + for (v, n) in hist.iter().enumerate() { + acc += n; + if acc >= want { + out[k] = v as u8; + break; + } + } + } + out +} + +fn surface(law: &Law<'_>, reference: &Law<'_>) -> Surface { + let mut s = Surface { + differ: 0, + differ_broad: 0, + max_df: 0, + max_dc: 0, + max_df_broad: 0, + max_dc_broad: 0, + sum_df: 0, + sum_dc: 0, + pct_df: [0; 3], + pct_dc: [0; 3], + distinct_c: 0, + }; + let (mut hf, mut hc) = ([0u64; 256], [0u64; 256]); + let mut seen_c = [false; 256]; + for &f1 in &FS { + for &f2 in &FS { + for c1 in 0..=255u8 { + for c2 in 0..=255u8 { + let (fa, ca) = reference(f1, c1, f2, c2); + let (fb, cb) = law(f1, c1, f2, c2); + seen_c[cb as usize] = true; + let (df, dc) = (fa.abs_diff(fb), ca.abs_diff(cb)); + hf[df as usize] += 1; + hc[dc as usize] += 1; + s.sum_df += df as u64; + s.sum_dc += dc as u64; + s.max_df = s.max_df.max(df); + s.max_dc = s.max_dc.max(dc); + if df != 0 || dc != 0 { + s.differ += 1; + if !is_boundary(c1) && !is_boundary(c2) { + s.differ_broad += 1; + s.max_df_broad = s.max_df_broad.max(df); + s.max_dc_broad = s.max_dc_broad.max(dc); + } + } + } + } + } + } + s.pct_df = percentiles(&hf); + s.pct_dc = percentiles(&hc); + s.distinct_c = seen_c.iter().filter(|x| **x).count(); + s +} + +fn all_surfaces() -> Vec { + let a = law_a(); + let ts = tables(); + ts.iter().map(|t| surface(&law_table(t), &a)).collect() +} + +#[test] +fn axis1_report() { + let ts = tables(); + for (n, t) in LEVELS.iter().zip(&ts) { + println!( + "B{n:<2} tables={:>3} bytes={:>9}", + t.revision.len(), + t.byte_size() + ); + } + for (n, s) in LEVELS.iter().zip(all_surfaces()) { + println!( + "B{n:<2} differ={:>7} broad={:>7} max=({},{}) broad_max=({},{}) \ + mean=({:.3},{:.3}) p50/95/99 dF={:?} dC={:?} distinct_c={}", + s.differ, + s.differ_broad, + s.max_df, + s.max_dc, + s.max_df_broad, + s.max_dc_broad, + s.sum_df as f64 / CELLS as f64, + s.sum_dc as f64 / CELLS as f64, + s.pct_df, + s.pct_dc, + s.distinct_c + ); + } +} + +// ─── Axes 2 and 3: replay drift and downstream decisions ──────────────── + +/// `replay_step`'s body with the truth law as a parameter: forward for the +/// palettes, mask, direction and plasticity, then the law's truth written +/// back. `step_with_a_table_is_replay_step` pins it against the real one. +fn step_with( + law: &Law<'_>, + running: CausalEdge64, + weight: CausalEdge64, + compose: ComposeTables<'_>, +) -> Result { + let (f, c) = law( + running.frequency_u8(), + running.confidence_u8(), + weight.frequency_u8(), + weight.confidence_u8(), + ); + let mut out = running.forward(weight, compose.s, compose.p, compose.o)?; + out.set_frequency_u8(f); + out.set_confidence_u8(c); + Ok(out) +} + +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + fn byte(&mut self, lo: u8, hi: u8) -> u8 { + lo + (self.next() % (hi as u64 - lo as u64 + 1)) as u8 + } +} + +fn compose_tables() -> [Box<[u8; 256 * 256]>; 3] { + let mut r = Lcg(0x9E37_79B9_7F4A_7C15); + core::array::from_fn(|_| { + let mut t = Box::new([0u8; 256 * 256]); + for v in t.iter_mut() { + *v = r.next() as u8; + } + t + }) +} + +/// The ops a recorded weight can carry that the ISA executes. +const OPS: [InferenceType; 4] = [ + InferenceType::Deduction, + InferenceType::Induction, + InferenceType::Abduction, + InferenceType::Revision, +]; + +/// Two workloads. `Uniform` spans the whole truth grid. `W0` is the +/// distribution `dcr_w0_replay_budget` measured the replay kernel on +/// (f in 128..=255, c in 128..=227), i.e. the workload the fast path was +/// sized against. +#[derive(Debug, Clone, Copy)] +enum Corpus { + Uniform, + W0, +} + +struct Chain { + seed: CausalEdge64, + steps: Vec, +} + +fn corpus(kind: Corpus, chains: usize, len: usize) -> Vec { + let mut r = Lcg(match kind { + Corpus::Uniform => 0x0D17_AB1E_0000_0001, + Corpus::W0 => 0x051E_D270_B5A1_11E5, + }); + let pick = |r: &mut Lcg| match kind { + Corpus::Uniform => (r.byte(0, 255), r.byte(0, 255)), + Corpus::W0 => (r.byte(128, 255), r.byte(128, 227)), + }; + (0..chains) + .map(|_| { + let (f, c) = pick(&mut r); + let seed = edge(f, c, InferenceType::Revision); + let steps = (0..len) + .map(|_| { + let (f, c) = pick(&mut r); + let op = OPS[(r.next() % 4) as usize]; + (0x90u8, edge(f, c, op)) + }) + .collect(); + Chain { seed, steps } + }) + .collect() +} + +/// Every running truth after each step. +fn trajectory(law: &Law<'_>, ch: &Chain, compose: ComposeTables<'_>) -> Vec { + let mut running = ch.seed; + ch.steps + .iter() + .map(|&(_, w)| { + running = step_with(law, running, w, compose).expect("ops are executable"); + running + }) + .collect() +} + +fn compose_of(t: &[Box<[u8; 256 * 256]>; 3]) -> ComposeTables<'_> { + ComposeTables { + s: &t[0], + p: &t[1], + o: &t[2], + } +} + +/// The mirror is the real replay step. Without this every A-vs-Bn replay +/// number below could be a difference between two step functions. +#[test] +fn step_with_a_table_is_replay_step() { + let c = compose_tables(); + let compose = compose_of(&c); + let chains = corpus(Corpus::Uniform, 64, 64); + for t in tables() { + let law = law_table(&t); + for ch in &chains { + let (mut mine, mut real) = (ch.seed, ch.seed); + for &(_, w) in &ch.steps { + mine = step_with(&law, mine, w, compose).unwrap(); + real = replay_step(real, w, &t, compose).unwrap(); + assert_eq!(mine, real, "c_levels={}", t.c_levels); + } + } + } +} + +const CHECKPOINTS: [usize; 7] = [1, 2, 4, 8, 16, 32, 64]; + +#[derive(Debug, Clone, PartialEq)] +struct Drift { + /// Per checkpoint: (chains whose truth differs from A, mean |dF|, mean |dC|). + at: Vec<(usize, f64, f64)>, + /// Chains whose truth first differs from A at step 1. + first_at_step1: usize, + /// Chains that never differ within 64 steps. + never: usize, + /// Median first-divergence step over the chains that diverge. + median_first: usize, + /// Fields other than F/C that differ from A at any step (palettes, mask, + /// direction, plasticity, Epi5). Must be zero: the law touches only F/C. + other_field_diffs: usize, +} + +const FC_MASK: u64 = 0xFFFF << 24; + +fn drift(law: &Law<'_>, chains: &[Chain], compose: ComposeTables<'_>) -> Drift { + let a = law_a(); + let mut at = vec![(0usize, 0f64, 0f64); CHECKPOINTS.len()]; + let mut firsts = Vec::new(); + let (mut first_at_step1, mut never, mut other) = (0, 0, 0); + for ch in chains { + let ta = trajectory(&a, ch, compose); + let tb = trajectory(law, ch, compose); + let first = ta.iter().zip(&tb).position(|(x, y)| x != y); + match first { + None => never += 1, + Some(0) => { + first_at_step1 += 1; + firsts.push(1); + } + Some(i) => firsts.push(i + 1), + } + other += ta + .iter() + .zip(&tb) + .filter(|(x, y)| (x.0 & !FC_MASK) != (y.0 & !FC_MASK)) + .count(); + for (k, &cp) in CHECKPOINTS.iter().enumerate() { + let (x, y) = (ta[cp - 1], tb[cp - 1]); + if x != y { + at[k].0 += 1; + } + at[k].1 += x.frequency_u8().abs_diff(y.frequency_u8()) as f64; + at[k].2 += x.confidence_u8().abs_diff(y.confidence_u8()) as f64; + } + } + for e in &mut at { + e.1 /= chains.len() as f64; + e.2 /= chains.len() as f64; + } + firsts.sort_unstable(); + Drift { + at, + first_at_step1, + never, + median_first: firsts.get(firsts.len() / 2).copied().unwrap_or(0), + other_field_diffs: other, + } +} + +#[test] +fn axis2_report() { + let c = compose_tables(); + let compose = compose_of(&c); + let ts = tables(); + for kind in [Corpus::Uniform, Corpus::W0] { + let chains = corpus(kind, 256, 64); + for (n, t) in LEVELS.iter().zip(&ts) { + let d = drift(&law_table(t), &chains, compose); + println!( + "{kind:?} B{n:<2} first@1={} never={} median_first={} other={}", + d.first_at_step1, d.never, d.median_first, d.other_field_diffs + ); + for (cp, (k, df, dc)) in CHECKPOINTS.iter().zip(&d.at) { + println!(" step {cp:>2}: differ {k:>3}/256 mean dF {df:6.2} mean dC {dc:6.2}"); + } + } + } +} + +/// How often A itself reaches c = 255 (after which its frequency is frozen: +/// `w = MAX` dominates every later revision) and how often a step hits the +/// singular cell (255,255), which is G1 and collapses to (0,0). +fn a_saturation(chains: &[Chain], compose: ComposeTables<'_>) -> (Vec, usize) { + let a = law_a(); + let mut sat = vec![0usize; CHECKPOINTS.len()]; + let mut singular = 0; + for ch in chains { + let t = trajectory(&a, ch, compose); + let mut prev = ch.seed; + for (i, (e, &(_, w))) in t.iter().zip(&ch.steps).enumerate() { + if prev.confidence_u8() == 255 && w.confidence_u8() == 255 { + singular += 1; + } + prev = *e; + if let Some(k) = CHECKPOINTS.iter().position(|&cp| cp == i + 1) { + if e.confidence_u8() == 255 { + sat[k] += 1; + } + } + } + } + (sat, singular) +} + +#[test] +fn axis2_a_saturation_report() { + let c = compose_tables(); + let compose = compose_of(&c); + for kind in [Corpus::Uniform, Corpus::W0] { + let (sat, singular) = a_saturation(&corpus(kind, 256, 64), compose); + println!("{kind:?}: A at c=255 by checkpoint {sat:?} of 256; singular steps {singular}"); + } +} + +// ─── Axis 3: decisions ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Class { + LoadBearing, + TruthOnly, + Inert, +} + +fn class(r: Reaction) -> Class { + match r { + Reaction::LoadBearing { .. } => Class::LoadBearing, + Reaction::TruthOnly { .. } => Class::TruthOnly, + Reaction::Inert { .. } => Class::Inert, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Decision { + factual: bool, + cut: bool, + load_bearing: bool, + reaction: Reaction, + /// The two arms' terminal frequencies are equal, so a TruthOnly can only + /// have come from confidence. + freq_equal: bool, +} + +fn terminal( + law: &Law<'_>, + seed: CausalEdge64, + steps: &[ChainStep], + compose: ComposeTables<'_>, +) -> (u8, u8) { + let ch = Chain { + seed, + steps: steps.to_vec(), + }; + trajectory(law, &ch, compose) + .last() + .map_or((0, 0), |e| (e.frequency_u8(), e.confidence_u8())) +} + +/// The cut decision exactly as `chain_counterfactual` + `pearl::reason` +/// make it: verdict = terminal frequency >= the bar (an empty arm is +/// Inconsistent and reads (0,0)); load-bearing = the verdicts differ; +/// reaction = `Reaction::classify` on the two terminal truths. +fn decide(law: &Law<'_>, ch: &Chain, index: usize, compose: ComposeTables<'_>) -> Decision { + let cut = cut_step(&ch.steps, index).expect("in range"); + let f = terminal(law, ch.seed, &ch.steps, compose); + let c = if cut.is_empty() { + (0, 0) + } else { + terminal(law, ch.seed, &cut, compose) + }; + let factual = !ch.steps.is_empty() && f.0 >= DEFAULT_FREQUENCY_BAR; + let cut_ok = !cut.is_empty() && c.0 >= DEFAULT_FREQUENCY_BAR; + let load_bearing = factual != cut_ok; + Decision { + factual, + cut: cut_ok, + load_bearing, + reaction: Reaction::classify(load_bearing, f, c), + freq_equal: f.0 == c.0, + } +} + +/// The decision mirror is the real counterfactual path for every table. +#[test] +fn decide_with_a_table_is_counterfactual_replay() { + let c = compose_tables(); + let compose = compose_of(&c); + for t in tables() { + let law = law_table(&t); + for ch in corpus(Corpus::Uniform, 64, 8).iter() { + for i in 0..ch.steps.len() { + let d = decide(&law, ch, i, compose); + let cf = counterfactual_replay( + &ch.steps, + i, + ch.seed, + CutContext { + tables: &t, + compose, + owner: 3, + base_seq: 100, + bar: DEFAULT_FREQUENCY_BAR, + }, + ) + .unwrap() + .unwrap(); + assert_eq!(d.load_bearing, cf.role.is_load_bearing()); + let tf = cf + .factual + .last() + .map(|r| (r.edge.frequency_u8(), r.edge.confidence_u8())); + let tc = cf + .counterfactual + .last() + .map(|r| (r.edge.frequency_u8(), r.edge.confidence_u8())); + assert_eq!( + d.reaction, + Reaction::classify( + cf.role.is_load_bearing(), + tf.unwrap_or((0, 0)), + tc.unwrap_or((0, 0)) + ) + ); + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct Flips { + cuts: usize, + /// The chain's own verdict differs from A's. + verdict: usize, + load_bearing: usize, + /// The Reaction variant differs from A's. + reaction_class: usize, + /// Of those, cuts where A says TruthOnly with equal frequencies (so the + /// truth moved only in confidence) and the law says Inert. + confidence_only: usize, +} + +fn flips(law: &Law<'_>, chains: &[Chain], compose: ComposeTables<'_>) -> Flips { + let a = law_a(); + let mut out = Flips::default(); + for ch in chains { + for i in 0..ch.steps.len() { + let (da, db) = (decide(&a, ch, i, compose), decide(law, ch, i, compose)); + out.cuts += 1; + out.verdict += usize::from(da.factual != db.factual); + out.load_bearing += usize::from(da.load_bearing != db.load_bearing); + if class(da.reaction) != class(db.reaction) { + out.reaction_class += 1; + if class(da.reaction) == Class::TruthOnly + && da.freq_equal + && class(db.reaction) == Class::Inert + { + out.confidence_only += 1; + } + } + } + } + out +} + +#[test] +fn axis3_report() { + let c = compose_tables(); + let compose = compose_of(&c); + let ts = tables(); + for kind in [Corpus::Uniform, Corpus::W0] { + for len in [2usize, 8] { + let chains = corpus(kind, 256, len); + for (n, t) in LEVELS.iter().zip(&ts) { + println!( + "{kind:?} len={len} B{n:<2} {:?}", + flips(&law_table(t), &chains, compose) + ); + } + } + } + // The repository's own counterfactual fixtures (chain_counterfactual tests). + let lb: Vec = vec![ + (0x91, edge(250, 250, InferenceType::Deduction)), + (0x92, edge(40, 30, InferenceType::Deduction)), + ]; + let red: Vec = (0..4) + .map(|i| (0x90 + i as u8, edge(250, 250, InferenceType::Deduction))) + .collect(); + let seed = edge(200, 200, InferenceType::Deduction); + let a = law_a(); + for (name, steps, cut) in [("load-bearing", &lb, 0usize), ("redundant", &red, 2)] { + let ch = Chain { + seed, + steps: steps.clone(), + }; + println!("{name}: A {:?}", decide(&a, &ch, cut, compose)); + for (n, t) in LEVELS.iter().zip(&ts) { + println!( + "{name}: B{n} {:?}", + decide(&law_table(t), &ch, cut, compose) + ); + } + } +} + +// ─── Pins ─────────────────────────────────────────────────────────────── + +/// Axis 1, per level: (revision tables, bytes, cells differing, broad cells +/// differing, broad max dF, broad max dC, distinct output c). +#[rustfmt::skip] +const AXIS1: [(usize, usize, u64, u64, u8, u8, usize); 5] = [ + ( 1, 262_144, 1_636_550, 1_598_385, 128, 168, 1), + ( 4, 655_360, 1_634_496, 1_596_341, 127, 100, 3), + ( 16, 2_228_224, 1_629_527, 1_591_392, 125, 55, 10), + ( 64, 8_519_680, 1_616_783, 1_578_704, 121, 28, 32), + (256, 33_685_504, 1_585_273, 1_547_346, 113, 14, 92), +]; + +#[test] +fn axis1_is_pinned() { + let ts = tables(); + let got: Vec<_> = ts + .iter() + .zip(all_surfaces()) + .map(|(t, s)| { + ( + t.revision.len(), + t.byte_size(), + s.differ, + s.differ_broad, + s.max_df_broad, + s.max_dc_broad, + s.distinct_c, + ) + }) + .collect(); + assert_eq!(got, AXIS1.to_vec()); +} + +/// Error falls with resolution on the mean, but no level reaches A: even 16 +/// buckets differ on 96.8% of cells and by over 100 frequency codes in the +/// broad domain. That is what "full precision" must not be read to mean. +#[test] +fn axis1_no_resolution_reaches_a() { + for s in all_surfaces() { + assert!(s.differ * 100 > CELLS * 96, "{s:?}"); + assert!(s.max_df_broad > 100, "{s:?}"); + } +} + +/// One AXIS2 row. +type Axis2Row = (&'static str, usize, usize, usize, usize, usize, u64, u64); + +/// Axis 2, per corpus and level: (chains diverging at step 1, chains never +/// diverging, median first divergence, non-F/C field diffs, mean |dF| and +/// mean |dC| at step 64 in hundredths). 256 chains x 64 steps. +#[rustfmt::skip] +const AXIS2: [Axis2Row; 10] = [ + ("Uniform", 1, 256, 0, 1, 0, 4386, 8325), + ("Uniform", 2, 256, 0, 1, 0, 4275, 4557), + ("Uniform", 4, 254, 0, 1, 0, 3953, 2414), + ("Uniform", 8, 254, 0, 1, 0, 3377, 1252), + ("Uniform", 16, 245, 0, 1, 0, 2805, 620), + ("W0", 1, 256, 0, 1, 0, 1634, 8100), + ("W0", 2, 255, 0, 1, 0, 1633, 3200), + ("W0", 4, 252, 0, 1, 0, 1186, 1891), + ("W0", 8, 251, 0, 1, 0, 656, 974), + ("W0", 16, 248, 0, 1, 0, 363, 469), +]; + +#[test] +fn axis2_is_pinned() { + let c = compose_tables(); + let compose = compose_of(&c); + let ts = tables(); + let mut got = Vec::new(); + for (name, kind) in [("Uniform", Corpus::Uniform), ("W0", Corpus::W0)] { + let chains = corpus(kind, 256, 64); + for (n, t) in LEVELS.iter().zip(&ts) { + let d = drift(&law_table(t), &chains, compose); + let last = d.at[CHECKPOINTS.len() - 1]; + got.push(( + name, + *n, + d.first_at_step1, + d.never, + d.median_first, + d.other_field_diffs, + (last.1 * 100.0).round() as u64, + (last.2 * 100.0).round() as u64, + )); + } + } + assert_eq!(got, AXIS2.to_vec()); +} + +/// The law touches only F/C: palettes, mask, direction, plasticity and the +/// epistemic bits agree with A at every step of every chain. So Epi5 and the +/// Pearl operator selection (bits 40..42) cannot be moved by the table. +#[test] +fn axis2_only_truth_bytes_differ() { + let c = compose_tables(); + let compose = compose_of(&c); + let ts = tables(); + let chains = corpus(Corpus::Uniform, 64, 64); + for t in &ts { + assert_eq!(drift(&law_table(t), &chains, compose).other_field_diffs, 0); + } +} + +/// A itself saturates on the uniform corpus: 43 of 256 chains sit at c = 255 +/// by step 64 (frequency frozen from then on) and 13 steps hit the G1 cell. +/// The W0 corpus never reaches it. +#[test] +fn axis2_a_saturation_is_pinned() { + let c = compose_tables(); + let compose = compose_of(&c); + assert_eq!( + a_saturation(&corpus(Corpus::Uniform, 256, 64), compose), + (vec![1, 2, 6, 7, 12, 24, 43], 13) + ); + assert_eq!( + a_saturation(&corpus(Corpus::W0, 256, 64), compose), + (vec![0; 7], 0) + ); +} + +/// Axis 3, per corpus, chain length and level: decision flips against A over +/// every cut of 256 chains. +#[rustfmt::skip] +const AXIS3: [(&str, usize, usize, Flips); 20] = [ + ("Uniform", 2, 1, Flips { cuts: 512, verdict: 104, load_bearing: 125, reaction_class: 156, confidence_only: 1 }), + ("Uniform", 2, 2, Flips { cuts: 512, verdict: 66, load_bearing: 68, reaction_class: 101, confidence_only: 3 }), + ("Uniform", 2, 4, Flips { cuts: 512, verdict: 32, load_bearing: 43, reaction_class: 84, confidence_only: 1 }), + ("Uniform", 2, 8, Flips { cuts: 512, verdict: 16, load_bearing: 21, reaction_class: 58, confidence_only: 2 }), + ("Uniform", 2, 16, Flips { cuts: 512, verdict: 6, load_bearing: 9, reaction_class: 35, confidence_only: 2 }), + ("Uniform", 8, 1, Flips { cuts: 2048, verdict: 776, load_bearing: 244, reaction_class: 905, confidence_only: 16 }), + ("Uniform", 8, 2, Flips { cuts: 2048, verdict: 552, load_bearing: 205, reaction_class: 751, confidence_only: 13 }), + ("Uniform", 8, 4, Flips { cuts: 2048, verdict: 368, load_bearing: 183, reaction_class: 646, confidence_only: 13 }), + ("Uniform", 8, 8, Flips { cuts: 2048, verdict: 168, load_bearing: 105, reaction_class: 502, confidence_only: 13 }), + ("Uniform", 8, 16, Flips { cuts: 2048, verdict: 96, load_bearing: 77, reaction_class: 426, confidence_only: 15 }), + ("W0", 2, 1, Flips { cuts: 512, verdict: 0, load_bearing: 0, reaction_class: 8, confidence_only: 1 }), + ("W0", 2, 2, Flips { cuts: 512, verdict: 0, load_bearing: 0, reaction_class: 7, confidence_only: 1 }), + ("W0", 2, 4, Flips { cuts: 512, verdict: 0, load_bearing: 0, reaction_class: 6, confidence_only: 1 }), + ("W0", 2, 8, Flips { cuts: 512, verdict: 0, load_bearing: 0, reaction_class: 4, confidence_only: 1 }), + ("W0", 2, 16, Flips { cuts: 512, verdict: 0, load_bearing: 0, reaction_class: 1, confidence_only: 0 }), + ("W0", 8, 1, Flips { cuts: 2048, verdict: 0, load_bearing: 0, reaction_class: 632, confidence_only: 54 }), + ("W0", 8, 2, Flips { cuts: 2048, verdict: 0, load_bearing: 0, reaction_class: 628, confidence_only: 54 }), + ("W0", 8, 4, Flips { cuts: 2048, verdict: 0, load_bearing: 0, reaction_class: 338, confidence_only: 43 }), + ("W0", 8, 8, Flips { cuts: 2048, verdict: 0, load_bearing: 0, reaction_class: 244, confidence_only: 36 }), + ("W0", 8, 16, Flips { cuts: 2048, verdict: 0, load_bearing: 0, reaction_class: 238, confidence_only: 45 }), +]; + +#[test] +fn axis3_is_pinned() { + let c = compose_tables(); + let compose = compose_of(&c); + let ts = tables(); + let mut got = Vec::new(); + for (name, kind) in [("Uniform", Corpus::Uniform), ("W0", Corpus::W0)] { + for len in [2usize, 8] { + let chains = corpus(kind, 256, len); + for (n, t) in LEVELS.iter().zip(&ts) { + got.push((name, len, *n, flips(&law_table(t), &chains, compose))); + } + } + } + assert_eq!(got, AXIS3.to_vec()); +} + +/// The W0 corpus cannot flip a verdict at all: every input frequency is +/// at least 128, so every weighted mean is too, and every chain clears the bar of +/// 128 under every law. Its zero verdict flips are structural, not evidence of +/// stability. +#[test] +fn w0_corpus_cannot_flip_the_frequency_bar() { + let c = compose_tables(); + let compose = compose_of(&c); + let a = law_a(); + for ch in corpus(Corpus::W0, 256, 8) { + let t = trajectory(&a, &ch, compose); + assert!(t.iter().all(|e| e.frequency_u8() >= DEFAULT_FREQUENCY_BAR)); + } +} + +/// The repository's load-bearing fixture (`chain_counterfactual`'s +/// `cutting_a_load_bearing_edge_flips_the_verdict`: seed 200/200, steps +/// 250/250 then 40/30, cut 0) is load-bearing under B1 ONLY. Under A and +/// under every other resolution both arms clear the bar. +#[test] +fn the_load_bearing_fixture_is_load_bearing_only_under_b1() { + let c = compose_tables(); + let compose = compose_of(&c); + let ch = Chain { + seed: edge(200, 200, InferenceType::Deduction), + steps: vec![ + (0x91, edge(250, 250, InferenceType::Deduction)), + (0x92, edge(40, 30, InferenceType::Deduction)), + ], + }; + let a = decide(&law_a(), &ch, 0, compose); + assert_eq!( + a.reaction, + Reaction::TruthOnly { + factual: 246, + counterfactual: 194 + } + ); + let lb: Vec = tables() + .iter() + .map(|t| decide(&law_table(t), &ch, 0, compose).load_bearing) + .collect(); + assert_eq!(lb, vec![true, false, false, false, false]); +} + +/// Anti-vacuity for the flip counter: a counter that compared only +/// frequencies would miss TruthOnly decisions that come from confidence +/// alone. This fixture has A moving confidence with frequency held, so a +/// confidence-blind classifier calls it Inert while `Reaction::classify` +/// does not. +#[test] +fn the_flip_counter_sees_confidence_only_changes() { + let c = compose_tables(); + let compose = compose_of(&c); + // Cutting one of two identical steps leaves the frequency where it was + // and lowers only the accumulated confidence. + let w = edge(200, 120, InferenceType::Revision); + let ch = Chain { + seed: edge(200, 120, InferenceType::Revision), + steps: vec![(0x90, w), (0x91, w)], + }; + let d = decide(&law_a(), &ch, 1, compose); + assert!(d.freq_equal, "{d:?}"); + assert_eq!(class(d.reaction), Class::TruthOnly, "{d:?}"); + let blind = if d.freq_equal && !d.load_bearing { + Class::Inert + } else { + class(d.reaction) + }; + assert_ne!(blind, class(d.reaction)); + // And B1 is exactly that blind classifier here. + let t1 = NarsTables::build(1); + assert_eq!( + class(decide(&law_table(&t1), &ch, 1, compose).reaction), + Class::Inert + ); +}