diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 2324d1f6c..09a734f1b 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,23 @@ +## 2026-08-06 — E-D-IGN-B-CORPUS-PRODUCED-NOTHING-TO-READ-1 — arming a CI gate that ran zero tests exposed an empty-vs-empty digest collision; the corpus was the defect, the untagged digest was correct + +**Status:** FINDING (reproduced at `f9206fc`, fixed, both mutation directions verified). **Confidence:** High for the mechanism (the empty-hash constant was computed and matched; the pre-fix corpus was traced token-by-token through the clause machine and emits nothing); High for the fix (all four lens arms now measured non-empty, 4/4 distinct digests on one owner). Test-fixture only — `stance.rs` is untouched. + +**The failure.** `cargo test -p lance-graph-supervisor --features cycle-driver --test d_ign_b_lenses` panicked at L1's can-fire half: two *different* lenses (z=3 Kant, z=4 Wittgenstein) over byte-identical rows produced the *same* digest `15130871412783076140`. That number is `DefaultHasher::new().finish()` — the hash of **zero bytes**. Both readouts were EMPTY, and `LensReadout::digest` folds contents only, so two empties of any two variants collide. + +**Root cause: the corpus, not the lenses.** `load_or_synthesize_corpus` reads `$BLW_KJV_TSV` or `/tmp/kjv_verses.tsv`; neither exists in CI, so it fell back to `synthetic_corpus`, which emitted `"d-ign-b synthetic verse {i} token{salt}"`. That text carries **no copula, no auxiliary, no modal, no typed relational verb and no `-ed` morphology**, so `stance::stream` never sets `armed` and emits **zero** statements. Empty arena + empty `ReadOut` ⇒ all four `stance_panel` arms empty ⇒ every lens digests identically. The test's own risk-check line had been printing `Hegel empty = true, Nietzsche empty = true` all along; nothing was reading it. + +**Pre-existing, and newly VISIBLE rather than newly broken — the transferable half.** Reproduced identically at `f9206fc`. Without `--features cycle-driver` the whole test module is `#[cfg]`-ed out and the binary runs **0 tests** — reporting `ok` and passing CI **vacuously**. `LATEST_STATE.md` recorded that step as a standing gate; it was *unarmed*. **A test binary that runs zero tests is indistinguishable from a passing one in CI summary output, and a feature-gated integration test is the standard way to arrive there by accident.** Arming the gate did not cause a regression — it ended one. + +**Why the "obvious" fix was the wrong one, explicitly.** Folding a variant-discriminant tag into `digest()` turns the collision green instantly — and makes L1's cross-lens `!=` pass **by construction of the tag alone**, for any input, forever. That is precisely `E-VACUOUS-ASSERTION-IS-THE-HOUSE-STYLE-1`'s shape, and the digest's own doc comment already forbade it in advance (deviation 3 in the module doc records an earlier draft being talked out of exactly this). **A red test whose green fix would delete its own falsifier is telling you the defect is upstream of the assertion.** The untagged digest is what made an empty corpus *observable at all*; tagging it would have hidden the empty corpus permanently while displaying green. + +**The fix.** `synthetic_window(w)` builds each 48-verse owner slice from templates the shipped clause machine genuinely extracts from, one per arm: affirm-then-negate and negate-then-affirm pairs on the same term (`revise_at` records `|f₁−f₂| = 0.85` ⇒ Hegel's `> 0.05` floor, with both flip directions legible from the provenance endpoints ⇒ Nietzsche's `Transvaluation`/`Devaluation`), `they knew that they were X` (perception verb + complementizer within the 3-token window ⇒ Kant's `ReadOut::lifts`), and ` because ` (⇒ Wittgenstein's `impl-cause`/`impl-effect` games on top of the `Inh` games every emission already feeds). Predicates are nonsense stems shaped `{stem}{window:02}{n:02}` — chosen to collide with **no** catalogue the machine consults, so a template can only give them the role it means to. + +**The non-obvious constraint the fix had to satisfy — worth its own line.** Three of the four arms fold interned **`u16` ids**, never the strings behind them, and the `Interner` assigns ids by order of first appearance. So two owner slices with identical STRUCTURE digest **identically no matter how their tokens are spelled** — renaming the vocabulary buys nothing. L4's "≥2 distinct digests across the in-scope owners" half would have stayed unfalsifiable. The window *shape* is therefore driven by `w % 5|3|7`, deliberately **coprime with the stride-4 arming cycle** (`(id - SPREAD_LO) % 4 + 1`) so each lens's own owner set spans several shapes instead of landing on one residue class. **Generalizable: when a digest folds identity rather than content, varying the content is a no-op; you must vary the structure — and the modulus that varies it must be coprime with whatever modulus selected the sample.** + +**The diagnostic the failure lacked, added.** L1 now asserts each readout is non-empty **before** comparing digests, naming the corpus provenance in the message. An empty corpus now reports *"the corpus produced no extractable content … this is a CORPUS defect, not a lens collision"* instead of accusing the lenses. The original message was not merely unhelpful, it was **actively misleading** — it named a conclusion (distinct lenses collide) that the evidence did not support and that sent the first read of this failure looking at `stance.rs`. + +**Falsifiability, verified in both directions rather than asserted.** (a) Make `run_lens(4, …)` return the Kant arm: L1 fails on `1790839806321388433` — a **non-empty** digest, so the assertion still discriminates content and did not merely become unreachable. (b) Restore the pre-fix filler corpus: the new precondition fires with the corpus-defect message, not the collision message. Measured after the fix: all four arms non-empty on the twin base, 4/4 distinct digests over one owner, and 4/7/6/6 distinct digests across each lens's owner set. + ## 2026-08-06 — E-A-REPEATABLE-TRANSFER-IS-NOT-IDEMPOTENCE-OVER-A-MULTI-FILE-DIRECTORY-1 — the PR #901 review round: four corrections, one of them load-bearing on a safety claim **Status:** FINDING (review round, PR #901) — corrections to the two entries below, which stay as written so the first draft's claims remain visible. **Confidence:** High for the manifest correction (probe re-run, recorded in the knowledge doc's evidence table) and for the atomicity gap (it follows from Lance datasets being multi-file directories, which is not in dispute); the eviction plan remains a PROPOSAL and nothing in it is measured. Documentation-only. diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index 7ba9370c4..83a72c4f0 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,5 +1,46 @@ # Issues Log — Open + Resolved (double-entry, append-only) +## ISS-D-IGN-B-REAL-CORPUS-PATH-IS-UNVERIFIED (2026-08-06) — OPEN, SURFACED BY THE CORPUS FIX + +`crates/lance-graph-supervisor/tests/d_ign_b_lenses.rs` takes one of two corpus +paths: `$BLW_KJV_TSV` (or `/tmp/kjv_verses.tsv`) if present, else the deterministic +synthetic fallback. **Only the synthetic path has ever been executed here.** The +TSV is absent from this environment and from CI, so the real-corpus branch of +`load_or_synthesize_corpus` is, today, dead code that has never run. + +**Why that is not merely a coverage note.** The fix in +`E-D-IGN-B-CORPUS-PRODUCED-NOTHING-TO-READ-1` reshaped the synthetic corpus so +every 48-verse owner slice yields all four lens arms non-empty — which is what +**L3** demands (`empty < total` for each of z=1..4). The file's own module doc +records the opposite measurement for the real corpus: Hegel *"is measured (not +assumed) constant-false on this corpus shape (§12.3a″)"*, and Nietzsche is computed +from Hegel (`stance.rs:483-496`) so it degrades with it. If that measurement still +holds slice-for-slice, **setting `BLW_KJV_TSV` could make L3 fail for z=1 and z=2** +— not because anything is broken, but because the two corpora exercise the arms +differently and only one of them has been checked against the L-suite. + +**So the two corpora are NOT interchangeable, and that is stated rather than +papered over.** Kant and Wittgenstein: both corpora feed them (any emission feeds +Wittgenstein; the KJV's `saw that` / `knew that` constructions feed Kant, and the +synthetic corpus plants `they knew that they were X` deliberately). Hegel and +Nietzsche: the synthetic corpus plants a reversal in **every** window by +construction; in the KJV reversals are **rare and localized** (the probe fixture's +whole point is that Genesis 2:17 / 3:4 / 3:6 carry them and the surrounding text +does not), so most 48-verse windows would carry none. + +**Deliberately NOT done in this PR.** Obtaining, vendoring or generating a real +corpus is out of scope for a CI-failure fix, and guessing at its slice-by-slice +behaviour would be exactly the paraphrase-instead-of-measurement move the +falsifiability rule rejects. **The honest status is: the real-corpus path is +unverified, and this entry exists so the next session that sets `BLW_KJV_TSV` reads +this before concluding it broke something.** Closing it means running the L-suite +against a real corpus and recording the per-arm measurements — at which point the +module doc's §12.3a″ risk note can be re-confirmed or corrected with evidence. + +**Not a defect in the fix.** The synthetic path is the one CI runs, it is green, +and its assertions were verified falsifiable in both directions. This is a gap in +what has been *measured*, recorded as such. + ## ISS-IDENTITY-CODEBOOK-ORDINAL-STABILITY (2026-08-06) — OPEN, PARTIALLY MITIGATED, RAISED BY REVIEW `IdentityCodebook` derives each ordinal from a **sorted position**, so the ordinal diff --git a/crates/lance-graph-supervisor/tests/d_ign_b_lenses.rs b/crates/lance-graph-supervisor/tests/d_ign_b_lenses.rs index c975e3c7c..b297a844a 100644 --- a/crates/lance-graph-supervisor/tests/d_ign_b_lenses.rs +++ b/crates/lance-graph-supervisor/tests/d_ign_b_lenses.rs @@ -81,6 +81,22 @@ //! signature cited was read from source in the same pass that wrote this //! file (see the build tag-file, `.claude/board/exec-runs/d-ign-b-build.md`, //! for what could and could not be verified). +//! +//! > **⊘ SUPERSEDED 2026-08-06 — this file IS compiled and run now.** CI +//! > runs `cargo test -p lance-graph-supervisor --features cycle-driver +//! > --test d_ign_b_lenses`. The section above records the authoring pass's +//! > honest state, not the file's current one; it is regraded in place, not +//! > deleted. Arming that CI step exposed a real defect the unarmed gate had +//! > been hiding (without the feature this binary runs 0 tests, so it was +//! > green vacuously): the deterministic synthetic fallback corpus emitted +//! > text `stance::stream` extracts NOTHING from, so all four lens readouts +//! > came back empty and L1's untagged digests collided empty-vs-empty. The +//! > corpus was replaced with `synthetic_window` (see its doc comment for +//! > what each arm needs) and L1 gained an explicit non-emptiness +//! > precondition so an empty corpus reports itself as a corpus defect +//! > instead of a lens collision. `stance.rs` was NOT touched — the lenses +//! > were never at fault. See `.claude/board/EPIPHANIES.md` +//! > `E-D-IGN-B-CORPUS-PRODUCED-NOTHING-TO-READ-1`. #[cfg(feature = "cycle-driver")] mod d_ign_b_lenses { @@ -243,13 +259,131 @@ mod d_ign_b_lenses { (verses.len() == limit).then_some(verses) } + /// Nonsense syllables the synthetic corpus builds its clause predicates + /// from. They must collide with NO catalogue the clause machine consults + /// — `STOP` / `AUX` (`stance.rs:33-45`), `is_negation` / `is_copula` / + /// `is_modal_aux` / `is_causal_cue` / `pronoun_case` / `epistemic_reading` + /// (`clause_cues.rs`, `verb_lexicon.rs`), and `read_verb`'s + /// `FAMILY_LEXICON` — so the only role a template can give them is the + /// one it means to: a clause predicate. The `{stem}{window:02}{n:02}` + /// shape guarantees that (7 chars, alphanumeric, no `-ed`/`-s`/`-ing` + /// suffix for `classify_verb`'s morphology to strip, absent from every + /// exact catalogue). + const SYNTH_STEMS: [&str; 8] = ["vor", "lan", "tik", "mez", "qor", "sil", "dun", "fex"]; + + fn synth_term(window: usize, n: usize) -> String { + format!( + "{}{:02}{:02}", + SYNTH_STEMS[n % SYNTH_STEMS.len()], + window, + n + ) + } + + /// One owner-slice's worth (`POPULATED_ROWS` verses) of the deterministic + /// synthetic corpus. + /// + /// ## Why this is not filler (the 2026-08-06 corpus fix) + /// + /// The previous fallback emitted `"d-ign-b synthetic verse {i} token{salt}"`. + /// That text carries no copula, no auxiliary, no typed relational verb and + /// no `-ed` morphology, so `stance::stream` never arms a predicate and + /// emits ZERO statements: the arena stays empty, `ReadOut` stays empty, + /// and all four `stance_panel` arms come back empty. Two empty readouts + /// then fold zero bytes each and digest IDENTICALLY (`DefaultHasher::new() + /// .finish()` == 15130871412783076140) — which is what made L1's can-fire + /// half fail the moment CI armed `--features cycle-driver`. The digest is + /// deliberately untagged (see `LensReadout::digest`); the defect was the + /// corpus, not the digest. + /// + /// ## What each arm needs, and where this window supplies it + /// + /// * **Hegel** (`contradiction_ranking`, `stance.rs:418-427`) — a belief + /// whose `contradiction` exceeds `0.05`. `revise_at` sets that to + /// `|f₁ − f₂|` on a disjoint-stamp revision (`belief.rs:191-205`), so it + /// needs the SAME `(they, Inh, term)` statement observed once affirmed + /// (f=0.9) and once negated (f=0.05) → depth 0.85. Supplied by the + /// `dev`/`trans` pairs below. + /// * **Nietzsche** (`stance.rs:483-496`) — iterates Hegel's output and + /// partitions by the FIRST vs LAST provenance entry's `negated` flag, so + /// it needs both flip directions to be legible from the endpoints: + /// affirm→negate (`Devaluation`) and negate→affirm (`Transvaluation`). + /// * **Kant** (`stance.rs:500-510`) — maps `ReadOut::lifts`, so it needs + /// rung-1 lifts: a perception verb with a subject, a complementizer + /// within the 3-content-token window, and an emission + /// (`stance.rs:237-243`, `:302-364`) — i.e. `they knew that they were X`. + /// * **Wittgenstein** (`stance.rs:512-532`) — counts distinct language + /// games per concept over observed `Inh` beliefs, lift knowers/objects + /// and `Impl` cause/effects, so any emission feeds it; the `because` + /// verses add the `impl-cause`/`impl-effect` games on top. + /// + /// ## Why the SHAPE varies with the window index + /// + /// Three of the four arms fold interned `u16` ids (`CStmt`'s `s`/`p`, + /// Wittgenstein's concept), never the strings behind them — and the + /// `Interner` assigns ids by order of first appearance, per `run_lens` + /// call. Two windows with identical structure therefore digest + /// IDENTICALLY no matter how their tokens are spelled, which would make + /// L4's ">=2 distinct digests across the in-scope owners" half + /// unfalsifiable. The counts below are driven by `window % 5|3|7`, + /// deliberately coprime with the stride-4 arming cycle + /// (`(id - SPREAD_LO) % 4 + 1`), so each lens's OWN owner set spans + /// several shapes rather than landing on one residue class. + fn synthetic_window(window: usize) -> Vec { + let dev_pairs = 3 + window % 5; // affirm then negate → Devaluation + let trans_pairs = 2 + window % 3; // negate then affirm → Transvaluation + let lifts = 4 + window % 7; // knows-that → Kant + let causal = 2 + window % 3; // " because " → Impl games + + let term = |n: usize| synth_term(window, n); + let mut out: Vec = Vec::with_capacity(POPULATED_ROWS); + + // Disjoint `n` namespaces keep the five roles' terms distinct within + // one window (0.., 10.., 20.., 40../50.., 60..). + for j in 0..dev_pairs { + out.push(format!("they were {}.", term(j))); + } + for j in 0..trans_pairs { + out.push(format!("they were not {}.", term(10 + j))); + } + for j in 0..lifts { + out.push(format!("they knew that they were {}.", term(20 + j))); + } + for j in 0..causal { + out.push(format!( + "they were {} because they were {}.", + term(40 + j), + term(50 + j) + )); + } + for j in 0..dev_pairs { + out.push(format!("they were not {}.", term(j))); + } + for j in 0..trans_pairs { + out.push(format!("they were {}.", term(10 + j))); + } + + // Pad to the fixed slice length with plain affirmations. The counts + // above are bounded (max 7+4+10+4+7+4 = 36 < POPULATED_ROWS), so the + // pad is never negative; assert rather than assume. + assert!( + out.len() <= POPULATED_ROWS, + "synthetic window {window} over-filled: {} > {POPULATED_ROWS}", + out.len() + ); + for j in out.len()..POPULATED_ROWS { + out.push(format!("they were {}.", term(60 + j))); + } + out + } + fn synthetic_corpus(n: usize) -> Vec { - (0..n) - .map(|i| { - let salt = (i as u64).wrapping_mul(2_654_435_761) % 104_729; - format!("d-ign-b synthetic verse {i} token{salt}") - }) - .collect() + assert_eq!( + n % POPULATED_ROWS, + 0, + "the synthetic corpus is built per owner slice; {n} must be a multiple of {POPULATED_ROWS}" + ); + (0..n / POPULATED_ROWS).flat_map(synthetic_window).collect() } fn load_or_synthesize_corpus() -> (Vec, &'static str) { @@ -768,13 +902,18 @@ mod d_ign_b_lenses { { let twin_verses = labelled_verses(&corpus, TWIN_LO); - // Pre-registered per design §4's risk note + §7 Q4: Hegel is - // measured (not assumed) constant-false on this corpus shape - // (§12.3a″); Nietzsche derives from Hegel (stance.rs:483-496) - // so it degrades with it. The can-fire witness pair is - // therefore pinned to z=3 (Kant) vs z=4 (Wittgenstein) BEFORE - // any run, per the design's own pre-registered fallback — - // never chosen after seeing output. + // Pre-registered per design §4's risk note + §7 Q4: Hegel was + // measured (not assumed) constant-false on the corpus shape of + // the time (§12.3a″); Nietzsche derives from Hegel + // (stance.rs:483-496) so it degrades with it. The can-fire + // witness pair is therefore pinned to z=3 (Kant) vs z=4 + // (Wittgenstein) BEFORE any run, per the design's own + // pre-registered fallback — never chosen after seeing output. + // The 2026-08-06 corpus fix makes Hegel/Nietzsche fire on the + // synthetic fallback too (the risk-check line below prints the + // measurement rather than trusting it); the witness pair is NOT + // re-picked on the strength of that — re-picking after seeing + // output is exactly what the pre-registration forbids. let r_kant = run_lens(3, &twin_verses); let r_witt = run_lens(4, &twin_verses); eprintln!( @@ -782,6 +921,29 @@ mod d_ign_b_lenses { run_lens(1, &twin_verses).is_empty(), run_lens(2, &twin_verses).is_empty() ); + + // Precondition, checked BEFORE the digest comparison: an empty + // readout folds zero bytes, so two empties digest identically + // (`digest` is deliberately untagged — see its doc comment). + // Without this guard a corpus that yields nothing extractable + // reports as "distinct lenses collide", which is a false and + // deeply misleading diagnosis of a corpus defect. That is the + // exact failure this file shipped with: the pre-fix synthetic + // fallback armed no predicate at all, both readouts came back + // empty, and the assertion below blamed the lenses. + for (label, readout) in [("z=3 Kant", &r_kant), ("z=4 Wittgenstein", &r_witt)] { + assert!( + !readout.is_empty(), + "L1 precondition: the {label} readout is EMPTY over the twin base slice — \ + the corpus produced no extractable content, so the digest comparison below \ + would be empty-vs-empty and could only collide. This is a CORPUS defect, \ + not a lens collision. Corpus source: {provenance}. \ + (`stance::stream` emits nothing without a copula / auxiliary / typed \ + relational verb / `-ed` form to arm a predicate, and Kant additionally \ + needs a perception verb + `that` complementizer.)" + ); + } + assert_ne!( r_kant.digest(), r_witt.digest(),