diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 5fe4daec3..ce7fc7123 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -32,9 +32,9 @@ Plan: `.claude/plans/2026-10-06-global-sudoku-replayable-orchestration-v1.md`. O | **D-GSO-3** | P3: global ontology agreement/disagreement probe (mammal fixture) | Shipped (#1350) | `cognitive-shader-driver/examples/ontology_agreement_probe.rs`: `NiblePath` hierarchy + `ontology_warrant::Quorum`/`SourceVerdict` folded per observation into local (self + siblings), up (ancestors), down (descendants); binary-entropy tension; all four cells + unknown reached; 6 tests, 5 disable runs red | | **D-GSO-4** | P4: entropy × topology routing probe | Shipped (#1351) | `cognitive-shader-driver/examples/entropy_topology_probe.rs` = F-ECG-1 and F-ECG-2 at probe level (F-ECG-3 open: no real corpus carries topology bits yet): bits 59..60 read through `BandDeclarations::project_truth` (topology lens, asserted provenance), known-share census as competence, `SettlementSignals::cell`, §4b walker route; equal measured entropy (0.8167 bits) → Crystal vs Glass; 6 tests, 4 disable runs red | | **D-GSO-5** | P5: first recipe quartet over existing primitives | Shipped (#1355) | `cognitive-shader-driver/examples/recipe_quartet_probe.rs`: four `match`-arm recipes over `Quorum::observe`, `Quad8::fold_product`, `Morton8x8` + `PalettePerturbation::hop`, `GadamerRevision::revise` (verdict left `NotRun`: removing the new roots is tautological, so the counterfactual moves to P7); a thread-local counting allocator shows zero allocations per recipe (no instruction vector); `ProbeRecipe` is its own type, not a shipped `recipes` ID; ordinal meanings not canonized; no Pearl projection (P7) and no selector (P6); 7 tests, 6 disable runs red | -| **D-GSO-6** | P6: deterministic, versioned recipe selector | Shipped (#1358); `new_encounter` wired to revision output: In PR | `cognitive-shader-driver/examples/recipe_selector_probe.rs`: `SelectorPolicy::select(state)` is a pure `const fn` over a declared 4-fact `EpistemicState`; V1 = §11 order (fold, bound, local, revise), rests only on the settled state; V2 swaps two steps (differs on 2 of 16 states); a recorded `Selection` (policy + state) replays under its own policy; every cycle rests in one step per open condition and replays identically; the version covers selection only, not recipe implementations (§13 digest open); 5 tests, 5 disable runs red. Follow-up: `new_encounter` is derived from the world (encounter roots \ horizon roots) and the `Revision` recipe writes `GadamerRevision`'s `delta.resulting`; dropping that write never rests; `unresolved_tension` cannot drive `local_disagreement` (never cleared); the other three facts are still stand-ins; 12 tests, 5 disable runs red | -| **D-GSO-7** | P7: reasoning-band earning and downgrade | In PR | `cognitive-shader-driver/examples/reasoning_band_probe.rs`: `ReasoningBand` (bits 61..63) read only via `band_reading::project_band`; earned one rung per executed passing proof under the shipped Pearl `CausalMask` (SO majority → Association, PO intervention trial passed → Causal, SPO removal attack passed → Counterfactual), outcomes executed from trial data and never supplied, no skipping, `NotRun` never raises; failed test, contradicting `Quorum` (minority caps at Association, majority drops to Surface) and `simpsons_paradox_risk` lower it; replay gives the same edge bits; unreadable provenance / undeclared / band-absent refuse; ladder and thresholds are policy pins; 7 tests, 10 disable runs red | -| **D-GSO-8** | P8: seal boundary | Queued | replay from the prior seal reproduces the next seal | +| **D-GSO-6** | P6: deterministic, versioned recipe selector | Shipped (#1358); `new_encounter` wired to revision output: Shipped (#1362) | `cognitive-shader-driver/examples/recipe_selector_probe.rs`: `SelectorPolicy::select(state)` is a pure `const fn` over a declared 4-fact `EpistemicState`; V1 = §11 order (fold, bound, local, revise), rests only on the settled state; V2 swaps two steps (differs on 2 of 16 states); a recorded `Selection` (policy + state) replays under its own policy; every cycle rests in one step per open condition and replays identically; the version covers selection only, not recipe implementations (§13 digest open); 5 tests, 5 disable runs red. Follow-up: `new_encounter` is derived from the world (encounter roots \ horizon roots) and the `Revision` recipe writes `GadamerRevision`'s `delta.resulting`; dropping that write never rests; `unresolved_tension` cannot drive `local_disagreement` (never cleared); the other three facts are still stand-ins; 12 tests, 5 disable runs red | +| **D-GSO-7** | P7: reasoning-band earning and downgrade | Shipped (#1360) | `cognitive-shader-driver/examples/reasoning_band_probe.rs`: `ReasoningBand` (bits 61..63) read only via `band_reading::project_band`; earned one rung per executed passing proof under the shipped Pearl `CausalMask` (SO majority → Association, PO intervention trial passed → Causal, SPO removal attack passed → Counterfactual), outcomes executed from trial data and never supplied, no skipping, `NotRun` never raises; failed test, contradicting `Quorum` (minority caps at Association, majority drops to Surface) and `simpsons_paradox_risk` lower it; replay gives the same edge bits; unreadable provenance / undeclared / band-absent refuse; ladder and thresholds are policy pins; 7 tests, 10 disable runs red | +| **D-GSO-8** | P8: seal boundary | In PR | `lance-graph-planner/examples/seal_boundary_probe.rs`: internal events change only working rows; a declared `Boundary` event freezes the changed rows with the shipped `DetachedCycleBatch::freeze` into a local ledger (no `WalSink`); an unchanged boundary writes nothing; replaying the persisted events from each prior seal reproduces the next seal exactly (frame, landings, image, hash); wrong base, dropped event and swapped order each change the seal; tie limit handled by plan option 1: the key is the event's own durable unique `seq`, a tied batch is refused, and a test shows on the real `freeze` that tied keys make the seal arrival-dependent; 6 tests, 5 disable runs red | ## D-PLX — Population-law cross-check (2026-10-03) diff --git a/crates/lance-graph-planner/Cargo.toml b/crates/lance-graph-planner/Cargo.toml index bafa3d309..ee684e9b6 100644 --- a/crates/lance-graph-planner/Cargo.toml +++ b/crates/lance-graph-planner/Cargo.toml @@ -43,6 +43,11 @@ lance-graph-contract = { path = "../lance-graph-contract" } axum = { version = "0.8", optional = true } +# D-GSO-8 seal boundary probe; tests run under `cargo test`. +[[example]] +name = "seal_boundary_probe" +test = true + [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/lance-graph-planner/examples/seal_boundary_probe.rs b/crates/lance-graph-planner/examples/seal_boundary_probe.rs new file mode 100644 index 000000000..d7195451a --- /dev/null +++ b/crates/lance-graph-planner/examples/seal_boundary_probe.rs @@ -0,0 +1,431 @@ +//! D-GSO-8 (P8): seal boundary. +//! +//! Plan: `.claude/plans/2026-10-06-global-sudoku-replayable-orchestration-v1.md` +//! §15 (Rubikon / seal as materialization governor) and §18 P8. +//! +//! Claim under test: many internal operations run without any durable write; +//! a seal happens only at one declared semantic boundary; and replaying the +//! persisted events from the prior seal reproduces the next seal exactly. +//! +//! # Reused, unchanged +//! +//! The seal is the shipped `persist_sink::DetachedCycleBatch::freeze`: it +//! stable-orders the casts by `stream_position`, coalesces same-row updates +//! (later position wins) and hashes the canonical content together with the +//! frame (`cycle`, `base_version`). The probe compares `batch_hash` and the +//! coalesced `image`. +//! +//! # The tie limit, and the choice made here +//! +//! `freeze` sorts stably, so casts with equal `stream_position` keep their +//! arrival order, and arrival order is not durable +//! (`.claude/knowledge/seal-vs-temporal-ordering-information.md` §2). The plan +//! offers three ways out; this probe takes the first: **the key is the +//! event's own sequence number, globally unique and persisted with the +//! event.** `seal` refuses a batch with a tied key instead of sealing it, and +//! `tied_keys_make_the_seal_depend_on_arrival` shows on the real `freeze` +//! why that refusal is needed. +//! +//! # What this probe does not decide +//! +//! - **The ledger is local.** It keeps `(cycle, base_version, batch_hash)` +//! and the frozen batch; no `WalSink` is driven, so publication, fencing and +//! recovery are not exercised here. +//! - **One boundary kind.** The boundary is a declared event +//! (`Event::Boundary`); which semantic events earn it (§15 lists several) +//! is not decided. +//! - **The internal operation is a stand-in**: each event folds one byte into +//! one row with a fixed rule. +//! +//! Run: `cargo run -p lance-graph-planner --example seal_boundary_probe` +//! Tests: `cargo test -p lance-graph-planner --example seal_boundary_probe` + +use lance_graph_contract::scheduler::DatasetVersion; +use lance_graph_planner::persist_sink::{CycleFrame, CycleId, DetachedCycleBatch, SweepSlot}; + +/// Rows in the probe's state. +const ROWS: usize = 16; +/// The owner every landing is on behalf of. +const OWNER: u32 = 7; + +/// One persisted event. `seq` is its durable, globally unique sequence number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Event { + /// An internal operation: fold `value` into `row`. + Internal { seq: u64, row: u8, value: u8 }, + /// The declared semantic boundary: seal what changed since the last seal. + Boundary { seq: u64 }, +} + +/// The stand-in internal operation: a fixed, non-commutative fold. +const fn fold(current: u8, value: u8) -> u8 { + current.rotate_left(3) ^ value +} + +/// One sealed cycle as the ledger keeps it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Seal { + cycle: CycleId, + base_version: DatasetVersion, + batch: DetachedCycleBatch, +} + +/// Why a boundary did not seal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SealError { + /// Two casts share a `stream_position`; their order would come from + /// arrival, which is not durable. + TiedKey(u64), +} + +/// The in-memory working state plus the durable ledger. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Engine { + /// Working rows; changed freely, never durable by themselves. + rows: [u8; ROWS], + /// The rows as of the last seal. + sealed_rows: [u8; ROWS], + /// For each row changed since the last seal, the `seq` of its last change. + last_change: [Option; ROWS], + /// Durable record. The only thing a boundary writes to. + ledger: Vec, + /// Internal operations applied since start (for the report). + internal_ops: usize, +} + +impl Engine { + fn new() -> Self { + Self { + rows: [0; ROWS], + sealed_rows: [0; ROWS], + last_change: [None; ROWS], + ledger: Vec::new(), + internal_ops: 0, + } + } + + /// The version a new seal builds on: the previous seal's successor, or 0. + fn head(&self) -> DatasetVersion { + DatasetVersion(self.ledger.len() as u64) + } + + /// Apply one event. Internal events touch only working state; a boundary + /// seals. + fn apply(&mut self, event: Event) -> Result<(), SealError> { + match event { + Event::Internal { seq, row, value } => { + let r = usize::from(row) % ROWS; + self.rows[r] = fold(self.rows[r], value); + self.last_change[r] = Some(seq); + self.internal_ops += 1; + Ok(()) + } + Event::Boundary { .. } => self.seal(), + } + } + + /// The casts a boundary would seal: one per row whose value differs from + /// the last seal, keyed by the `seq` of its last change. + fn casts(&self) -> Vec { + let cycle = CycleId(self.ledger.len() as u64); + (0..ROWS) + .filter(|&r| self.rows[r] != self.sealed_rows[r]) + .map(|r| SweepSlot { + cycle, + stream_position: self.last_change[r].expect("a changed row has a last change"), + owner: OWNER, + row: r as u64, + paired_move: None, + payload: vec![self.rows[r]], + }) + .collect() + } + + /// Seal at the boundary. Nothing changed: no write. A tied key: refuse. + fn seal(&mut self) -> Result<(), SealError> { + self.seal_casts(self.casts()) + } + + fn seal_casts(&mut self, casts: Vec) -> Result<(), SealError> { + if casts.is_empty() { + return Ok(()); + } + if let Some(k) = first_tie(&casts) { + return Err(SealError::TiedKey(k)); + } + let cycle = CycleId(self.ledger.len() as u64); + let base_version = self.head(); + let batch = DetachedCycleBatch::freeze(CycleFrame::new(cycle, base_version), casts); + self.sealed_rows = self.rows; + self.last_change = [None; ROWS]; + self.ledger.push(Seal { + cycle, + base_version, + batch, + }); + Ok(()) + } + + /// Rebuild the state a seal left behind: the previous sealed rows with the + /// seal's coalesced image written over them. + fn resume_after(prior: &[Seal]) -> Self { + let mut e = Self::new(); + for s in prior { + for (&row, payload) in &s.batch.image { + e.sealed_rows[row as usize] = payload[0]; + } + e.ledger.push(s.clone()); + } + e.rows = e.sealed_rows; + e + } +} + +/// The first `stream_position` shared by two casts, if any. +fn first_tie(casts: &[SweepSlot]) -> Option { + let mut keys: Vec = casts.iter().map(|c| c.stream_position).collect(); + keys.sort_unstable(); + keys.windows(2).find(|w| w[0] == w[1]).map(|w| w[0]) +} + +/// A deterministic event stream: `n` internal events over a few rows, with a +/// boundary after every `every` internal events. Sequence numbers are unique. +fn stream(n: u64, every: u64) -> Vec { + let mut v = Vec::new(); + let mut seq = 0; + for i in 0..n { + seq += 1; + let row = ((i * 7 + 3) % 5) as u8; + let value = (i.wrapping_mul(2_654_435_761) >> 13) as u8; + v.push(Event::Internal { seq, row, value }); + if (i + 1) % every == 0 { + seq += 1; + v.push(Event::Boundary { seq }); + } + } + v +} + +/// Split a stream into the event runs that end at each boundary. +fn runs(events: &[Event]) -> Vec<&[Event]> { + let mut out = Vec::new(); + let mut start = 0; + for (i, e) in events.iter().enumerate() { + if matches!(e, Event::Boundary { .. }) { + out.push(&events[start..=i]); + start = i + 1; + } + } + out +} + +fn main() { + let events = stream(1_000, 250); + let mut engine = Engine::new(); + for &e in &events { + engine.apply(e).expect("unique keys"); + } + println!( + "{} internal operations, {} seals", + engine.internal_ops, + engine.ledger.len() + ); + for s in &engine.ledger { + println!( + " cycle {} on base v{}: {} landings, hash {:016x}", + s.cycle.0, + s.base_version.0, + s.batch.landings.len(), + s.batch.batch_hash + ); + } + // Replay the last run from the seal before it. + let last = engine.ledger.len() - 1; + let mut replayed = Engine::resume_after(&engine.ledger[..last]); + for &e in *runs(&events).last().expect("one run") { + replayed.apply(e).expect("unique keys"); + } + assert_eq!(replayed.ledger[last], engine.ledger[last]); + println!("replay from the prior seal reproduced the last seal"); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FAILS IF: an internal operation writes to the ledger, or a boundary + /// writes more than one seal. + #[test] + fn only_a_boundary_writes() { + let mut e = Engine::new(); + for ev in stream(999, 1_000) { + e.apply(ev).unwrap(); + } + assert_eq!(e.internal_ops, 999); + assert!(e.ledger.is_empty(), "no boundary yet, so nothing durable"); + + e.apply(Event::Boundary { seq: 10_000 }).unwrap(); + assert_eq!(e.ledger.len(), 1); + // Rows 0..5 were touched; the seal holds each once, coalesced. + assert_eq!(e.ledger[0].batch.image.len(), 5); + } + + /// FAILS IF: a boundary with nothing changed since the last seal writes a + /// seal anyway. + #[test] + fn an_unchanged_boundary_writes_nothing() { + let mut e = Engine::new(); + e.apply(Event::Boundary { seq: 1 }).unwrap(); + assert!(e.ledger.is_empty()); + + e.apply(Event::Internal { + seq: 2, + row: 1, + value: 9, + }) + .unwrap(); + e.apply(Event::Boundary { seq: 3 }).unwrap(); + e.apply(Event::Boundary { seq: 4 }).unwrap(); + assert_eq!(e.ledger.len(), 1, "the second boundary had nothing to seal"); + } + + /// FAILS IF: replaying the persisted events from the prior seal does not + /// reproduce each next seal exactly (frame, landings, image, hash). + #[test] + fn replay_from_the_prior_seal_reproduces_the_next() { + let events = stream(1_000, 125); + let mut live = Engine::new(); + for &ev in &events { + live.apply(ev).unwrap(); + } + let runs = runs(&events); + assert_eq!(live.ledger.len(), 8); + assert_eq!(runs.len(), 8); + + for (k, run) in runs.iter().enumerate() { + let mut replay = Engine::resume_after(&live.ledger[..k]); + for &ev in *run { + replay.apply(ev).unwrap(); + } + assert_eq!(replay.ledger.len(), k + 1); + assert_eq!(replay.ledger[k], live.ledger[k], "seal {k}"); + assert_eq!( + replay.rows, + live_rows_after(&events, k), + "rows after seal {k}" + ); + } + } + + /// The working rows right after the `k`-th seal, by running the stream. + fn live_rows_after(events: &[Event], k: usize) -> [u8; ROWS] { + let mut e = Engine::new(); + for &ev in events { + e.apply(ev).unwrap(); + if e.ledger.len() == k + 1 { + return e.rows; + } + } + unreachable!("stream has at least k + 1 seals") + } + + /// FAILS IF: the seal does not bind its base, its content or its order: + /// replaying from the wrong prior seal, dropping an event, or swapping two + /// events on one row must each change the next seal. + #[test] + fn the_seal_binds_base_content_and_order() { + let events = stream(400, 100); + let mut live = Engine::new(); + for &ev in &events { + live.apply(ev).unwrap(); + } + let runs = runs(&events); + let reference = &live.ledger[2]; + + // Wrong base: replay run 2 on top of only the first seal. + let mut wrong_base = Engine::resume_after(&live.ledger[..1]); + for &ev in runs[2] { + wrong_base.apply(ev).unwrap(); + } + assert_ne!( + wrong_base.ledger.last().unwrap().batch.batch_hash, + reference.batch.batch_hash + ); + + // Dropped event. + let mut dropped = Engine::resume_after(&live.ledger[..2]); + for &ev in &runs[2][1..] { + dropped.apply(ev).unwrap(); + } + assert_ne!( + dropped.ledger[2].batch.batch_hash, + reference.batch.batch_hash + ); + + // Two events on the same row, swapped (the fold does not commute). + let mut swapped: Vec = runs[2].to_vec(); + let row_of = |e: &Event| match e { + Event::Internal { row, .. } => Some(*row), + Event::Boundary { .. } => None, + }; + let i = 0; + let j = (1..swapped.len()) + .find(|&j| row_of(&swapped[j]) == row_of(&swapped[i])) + .unwrap(); + swapped.swap(i, j); + let mut reordered = Engine::resume_after(&live.ledger[..2]); + for ev in swapped { + reordered.apply(ev).unwrap(); + } + assert_ne!(reordered.ledger[2].batch.image, reference.batch.image); + } + + /// FAILS IF: the seal depends on the order casts arrive in when their keys + /// are unique. The freeze canonicalizes, so arrival order is irrelevant. + #[test] + fn unique_keys_make_the_seal_arrival_independent() { + let mut e = Engine::new(); + for ev in stream(60, 1_000) { + e.apply(ev).unwrap(); + } + let casts = e.casts(); + let mut reversed = casts.clone(); + reversed.reverse(); + let frame = CycleFrame::new(CycleId(0), DatasetVersion(0)); + assert_eq!( + DetachedCycleBatch::freeze(frame, casts).batch_hash, + DetachedCycleBatch::freeze(frame, reversed).batch_hash + ); + } + + /// The known limit, measured on the shipped `freeze`. FAILS IF: tied keys + /// stop making the seal depend on arrival order (then the refusal would be + /// unnecessary), or the probe seals a tied batch instead of refusing it. + #[test] + fn tied_keys_make_the_seal_depend_on_arrival() { + let slot = |row: u64, byte: u8| SweepSlot { + cycle: CycleId(0), + stream_position: 42, + owner: OWNER, + row, + paired_move: None, + payload: vec![byte], + }; + let frame = CycleFrame::new(CycleId(0), DatasetVersion(0)); + // Same row, same key, two arrival orders: the coalesced value differs. + let a = DetachedCycleBatch::freeze(frame, vec![slot(3, 1), slot(3, 2)]); + let b = DetachedCycleBatch::freeze(frame, vec![slot(3, 2), slot(3, 1)]); + assert_ne!(a.image, b.image); + // Different rows, same key: the landing order and hash differ. + let c = DetachedCycleBatch::freeze(frame, vec![slot(1, 1), slot(2, 2)]); + let d = DetachedCycleBatch::freeze(frame, vec![slot(2, 2), slot(1, 1)]); + assert_ne!(c.batch_hash, d.batch_hash); + + let mut e = Engine::new(); + assert_eq!( + e.seal_casts(vec![slot(1, 1), slot(2, 2)]), + Err(SealError::TiedKey(42)) + ); + assert!(e.ledger.is_empty(), "a tied batch is refused, not sealed"); + } +}