From b274820f24d1aa9fc3feff10659d3536ddc3cf48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:22:35 +0000 Subject: [PATCH 1/4] plan: D-STAUNEN-5 resident recursive plasticity First closed learning loop on the XLOG-on-cube foundation: a novel stimulus licenses one activation of a declared candidate edge under the contract of Atlas's update_mask, the delta stays in the cube as mask words, and two competing policies (parsimony, novelty x eligibility) are scored on held-out pairs and learned from later evidence. Also records the xlog-cube coverage order and that 140/230 measures admission, not checked answers. Board: INTEGRATION_PLANS, STATUS_BOARD (D-XLOG-NATIVE-8 shipped in #1913). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- .claude/board/INTEGRATION_PLANS.md | 5 + .claude/board/STATUS_BOARD.md | 3 +- ...unen-5-resident-recursive-plasticity-v1.md | 220 ++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 .claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 0f3eaa1a3..1b639bcdc 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,8 @@ +## 2026-11-01 — D-STAUNEN-5: resident recursive plasticity → `.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md` + +**Status:** PROPOSED, plan only. **Board:** [STATUS_BOARD](STATUS_BOARD.md) D-STAUNEN-5. First closed learning loop on the XLOG-on-cube foundation: one binary relation, its transitive closure as the prediction, a declared set of dormant candidate edges. A novel stimulus (an observed pair the closure misses) licenses one activation, under the contract of Atlas's `update_mask` (fixed mask shape, 0/1, atomic between sequences, regrowth clears history, needs committed state) plus a generation guard; Atlas is the contract source, not the executor. The delta stays in the cube as `ndarray::simd` mask words. Two competing policies (H1 parsimony, H2 novelty × eligibility) are scored on held-out pairs from a hidden graph; later evidence consolidates or reverts each update, and a selector learns which policy to use. Also records the xlog-cube coverage order (projection composition, mask filters, resident execution, computed columns, F100, N-ary gated) and that 140/230 measures admission, not checked answers. +**Confidence:** plan; Atlas contract read from `brian2-atlas` `dev` `9ec52f9`; no code yet. + ## 2026-10-31 — D-DT64-7: behaviour addressing and registration → `.claude/plans/d-dt64-7-behaviour-addressing-registration-v1.md` **Status:** PROPOSED, docs only. **Board:** [STATUS_BOARD](STATUS_BOARD.md) D-DT64-7. Behaviour for fixed relalg is Frozen (compile time, at `0xFFFF :: 0xF###`, registered by DT64 as delegate for `lance-graph-ogar` without asking `ogar-vocab`; the range names the concern: relalg, XLOG, ONNX, TFLite, SQL, Gremlin), Learned (registered in the `BindStore` at `Domain::AppId :: 0xF###`, withdrawn only by its registrant, never deleted) or Explore (not registered). ONNX, SQL and Gremlin are Frozen; the R2IL codegraph harvest is Learned / Explore; XLOG is Learned in the lab and Frozen once minted as `lance-graph-xlog`. Findings: the Frozen polyfill is runtime-declared and named through `ogar-r2il`'s vocabulary; only routes can be withdrawn; registration moves no version; no activation is consulted; `classid_canon_compat` misreads a legacy left-empty classid (it reads `0xFFFF` correctly); the global → local selection is not yet a versioned table. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 2bf1d9528..69d47bbe6 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -72,7 +72,8 @@ Plan: `.claude/plans/d-soc-fence-v1.md`. Plan only; nothing runs yet. | D-XLOG-NATIVE-6 | probe executes on `ndarray::simd` (`src/native.rs`): support via `eq_u32_to_mask` / `mask_scatter_or_u32` / `mask_and`, bag count via `masked_group_count_u32` ×2 + `checked_binary_i64`; DR `LocalMap` up to 256; register path kept as differential (plan Amendment B) | Shipped (#1884) | lab probe; layering fix is D-DYNBAG-0 | | D-XLOG-NATIVE-5 | bag count bound through the production bind store (`lance-graph-dt64` + `dt64::dr`), polyfill words by position; Epi5 reading of XLOG results pinned (plan Amendment A) | Shipped (#1884) | lab execution only | | D-XLOG-NATIVE-7 | resident hypercube context (`lance_graph_xlog::cube`): one intake for every relation, `Copy` view handles, terminals only run programs; XLOG plans executed on it in the XLOG fork (`xlog-cube`) | In PR (#1911; xlog#2): parity + 300-case differential; 12/230 integration rules admitted | recursion, derived inputs | -| D-XLOG-NATIVE-8 | XLOG behaviour lens `0xFFFF :: 0xF100` (`lance_graph_xlog::lens`): `functor`/`arg`/`=..` by position with helper-relation aliases; `declare` binds column counts as `arity` (side effect documented) | In PR | consumer half in xlog#2 | +| D-XLOG-NATIVE-8 | XLOG behaviour lens `0xFFFF :: 0xF100` (`lance_graph_xlog::lens`): `functor`/`arg`/`=..` by position with helper-relation aliases; `declare` binds column counts as `arity` (side effect documented) | Shipped (#1913) | consumer half in xlog#2 | +| D-STAUNEN-5 | resident recursive plasticity: novelty-gated activation of one declared candidate edge (Atlas `update_mask` contract), in-cube mask delta, H1 parsimony vs H2 eligibility, consolidate/revert, policy selection (plan P0–P7) | Queued | plan only | ## D-LITHO — the photolithography contract (2026-10-30) diff --git a/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md new file mode 100644 index 000000000..eec41cc7f --- /dev/null +++ b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md @@ -0,0 +1,220 @@ +# D-STAUNEN-5 | Resident recursive plasticity | v1 + +**Status:** PROPOSAL · **Date:** 2026-11-01 · **Prereq:** D-XLOG-NATIVE-7 (cube, #1911/#1912), D-XLOG-NATIVE-8 (#1913), xlog#2–#4 (`xlog-cube` on xlog `main`) · **Reads:** D-STAUNEN-2 (A4/A5), D-STAUNEN-3, D-STAUNEN-4 (B0/B10/B11, M1/M2/M5) + +## Overview + +D-STAUNEN-3 proposed an XLOG/structure/learning integration. Its first +foundation is now implemented: XLOG plans run on the resident hypercube, +recursion included. D-STAUNEN-5 is the first experiment that closes a loop +on that foundation. + +**Question.** Can a novel stimulus trigger a local structural adaptation +that changes a recursively computed prediction, and can the system then +learn when that adaptation was useful? + +**Scope fences (session direction).** +- No new runtime. The experiment runs on the existing cube and + `ndarray::simd` mask words. +- No widened relation representation. One binary relation over one tile + (at most 64 ids in v1, so a row is one `u64`). +- No Prolog meta-predicate support (`functor`/`arg`/`=..` stay refused). +- Not 64 thoughts. Two competing hypotheses, each independently verifiable. + +## The setup + +- **Base relation** `edge(X, Y)`: binary, over one `LocalMap`. +- **Recursive prediction** `reach = edge⁺` (transitive closure, the program + `xlog-cube` already runs). A prediction is "is `(a, b)` in `reach`". +- **Candidate edges** `C`: a set declared before the run. Each is dormant + (absent from `edge`) or active. This is the only structural freedom. +- **Hidden world** `G*`: a ground-truth graph over the same ids. The + system never reads `G*`. It only sees stimuli sampled from `reach(G*)`. +- **Stimulus** `obs(a, b)`: one observed reachable pair. + **Novel** means `(a, b)` is not in the current `reach`. + +## The update, under Atlas's contract + +Atlas (`AdaWorldAPI/brian2-atlas`, cloned this session, branch `dev` +`9ec52f9`) has one bounded structural update: +`NativeLIFTrainer.update_mask(masks, growth_weight)` +(`brian2-rust/python/brian2_rust/training.py`). Its contract, read from the +source: + +1. The mask shape is the plan's. "topology migration is unsupported", so + only declared connections can change. +2. Entries are 0 or 1. +3. Applied atomically between sequences, without advancing clocks. +4. A pruned cell is zeroed. A regrown cell gets its declared or learned + initial value, and its event history and timestamps are cleared. +5. Refused before a committed runtime state exists. + +The cube's update obeys the same five rules. It does not run Atlas. + +| Atlas rule | Cube rule | Refusal | +|---|---|---| +| plan-shaped mask | the update toggles one member of the declared `C` | `NotACandidate` | +| entries 0/1 | an edge is active or dormant; no weights in v1 | (by type) | +| atomic, between sequences | applied between stimuli, never inside a fixpoint round | `InsideRound` | +| regrowth clears history | an activated edge carries no prior evidence; its record starts empty | — | +| needs committed state | refused until the base closure is sealed under a frame | `NotSealed` | + +Plus two rules of our own: +- **One update per novel stimulus.** A non-novel stimulus is never a + licence to adapt. +- **Generation guard.** Every update bumps a generation; a delta, a + prediction or a pending verdict from an older generation is refused + (`Stale`). This is D-STAUNEN-4 M1 in its smallest form. + +An activation is speculative: it is held as a THOUGHT delta over the +sealed base, never written into the base until it is consolidated (below). + +## The in-cube delta + +Today `xlog-cube` computes each recursive round's new rows on the host (a +set difference) and re-adds the whole relation every round. Both shortcuts +are removed for this relation: + +- The relation is held as row masks over local positions: + `row[x]` has bit `y` set when `(x, y)` holds. 64 ids, so one `u64` a row. +- Semi-naive closure: `delta' = (delta ∘ edge) andnot full`; + `full |= delta'`. Composition is `or` over the rows of `edge` selected by + `delta[x]`; difference is `mask_andnot`; union is `mask_or`. +- **Incremental update for one activated edge `(u, v)`.** The new pairs are + `{x : x = u or reach(x, u)} × {y : y = v or reach(v, y)}`, minus `reach`. + This is one column read, one row read, and an outer `or`, then + `mask_andnot`. No fixpoint re-run. +- Deactivation (revert) is not the inverse. The closure is recomputed from + the base plus the remaining active candidates. The plan says so rather + than inventing a decremental algorithm. + +Every mask op is an `ndarray::simd` word (`mask_and`, `mask_or`, +`mask_andnot`, `mask_any`). No tuples are built except at the one named +terminal. + +## Two competing hypotheses + +Each is a policy: given a novel `obs(a, b)`, which candidate to activate. +Both must make `(a, b)` predicted. They differ in what else they predict. + +- **H1, parsimony.** Activate the candidate that makes `(a, b)` reachable + with the fewest new `reach` pairs. Minimal structural surprise. +- **H2, eligibility.** Each id carries an eligibility count: how often it + appeared in recent stimuli. Activate the closing candidate whose endpoints + have the highest eligibility. Novelty is the third factor; eligibility is + the trace. This is the D-STAUNEN-2 A4 rule, `Δ = M · e`, with a binary + structural target instead of a weight. + +They are independently verifiable: each can be scored against `G*` on +pairs it was not shown, and the two disagree on fixtures built for it. + +## Learning when an adaptation was useful + +An activated candidate is pending. Later stimuli are its evidence: +- a later stimulus that `reach` predicts **only because of** that candidate + is a confirmation; +- a predicted pair that a held-out query from `G*` refutes is a refutation. + +After `k` subsequent stimuli the pending update is **consolidated** (merged +into the sealed base, generation bump) or **reverted** (deactivated). Each +outcome is recorded against the policy that proposed the update, as +frequency/confidence counts. + +**Meta-step.** A selector picks H1 or H2 for the next novel stimulus from +those records. That is the "learn when it was useful" claim at policy +level. + +**Evidence rules (D-STAUNEN-4 M2/M3).** +- A stimulus counts once: the same observation repeated is one root. +- A pair derived through the closure is not independent evidence for the + edge that produced it, so a candidate cannot confirm itself. +- Only stimuli or held-out queries arriving after the activation count. + +## Phases (tests first, each gate a falsifier) + +- [ ] **P0 — fixtures and oracle.** A seeded generator for `G*`, `C` + (containing the true missing edges plus distractors) and stimulus streams. + A scalar oracle closure. Pre-register: task split, episode count, `k`, + minimum effect, budget. +- [ ] **P1 — in-cube semi-naive closure.** Mask closure equals the oracle + and equals `xlog-cube`'s closure on random graphs. Disable runs: drop the + `andnot` (non-termination, caught by a round bound); use `full` in place of + `delta` (equal answer, more rounds; asserted on the round count). +- [ ] **P2 — incremental activation.** After activating `(u, v)`, the + incremental closure equals a full recompute, over every `(u, v)` in a + random `C`. Disable run: drop the `x = u` term. +- [ ] **P3 — the gate.** Each refusal (`NotACandidate`, `InsideRound`, + `NotSealed`, `Stale`) fires on its input, and each stays silent on a + legal update. A non-novel stimulus never adapts. +- [ ] **P4 — one closed episode.** Novel stimulus → one activation → a + prediction that was false is now true → later stimuli → consolidate or + revert. Asserted end to end on a fixture whose answer is known. +- [ ] **P5 — H1 vs H2.** Score both on held-out pairs from `G*` over seeded + episodes. Fixtures where they must disagree (a short distractor beside a + recently active long path), so the comparison is not vacuous. +- [ ] **P6 — controls and meta-selection.** B0 no adaptation; B10 best + fixed policy; B11 random candidate; B7 shuffled novelty (adapt on + non-novel stimuli); B9 duplicated root (one observation repeated). The + meta-selector must beat B11 and match or beat B10. Report the losing + runs as well. +- [ ] **P7 — Atlas differential (optional, lab).** Mirror one fixture into + an Atlas plan whose masks are `C`, apply the same activation sequence with + `update_mask`, and check that Atlas accepts and refuses the same updates. + Needs Atlas built in the session; if it does not build, record that and + stop rather than copying its executor. + +## What a pass would and would not show + +A pass shows that, on one binary relation and a declared candidate set, a +novelty-gated structural update changes a recursive prediction, and that a +policy record learned from later evidence picks updates that survive +better than chance. It shows nothing about spiking dynamics, weights, +biological structural plasticity, or general learning. Atlas is the +contract source, not the executor, until P7 runs. + +## Where the code goes + +`crates/lance-graph-xlog` (the cube's crate), as a `plastic` lab module plus +`tests/plastic.rs`. No new crate. `xlog-cube` is not changed until P1 can +replace its host-side delta, and that is a separate PR. + +## Open + +- `k` and the consolidate/revert thresholds are policy pins until P5 + measures them. +- Whether H2's eligibility should decay. v1 counts over a fixed window. +- Weights (Atlas `growth_weight`) are out of v1: edges are binary. + +## Relation to the xlog-cube coverage work (session review, 2026-11-01) + +A review of `xlog-cube`, XLOG's RIR and the cube concluded that most of the +90 refused integration rules need new ways to compose algebra the substrate +already has, not 90 new operators. The proposed order, recorded here +because step 3 is this plan's P1: + +1. Projection composition: `P_A(P_B(R)) = P_{B∘A}(R)` at preparation time + (5 `Project(Project(..))` rules); join projections only where the whole + output schema is kept, values borrowed by field reference. +2. Filter algebra: comparisons and `And`/`Or`/`Not` as native masks, with + explicit width, signedness, float and UNKNOWN semantics. Column-to-column + equality first, because it also gates `q(X) :- r(X,X)`. +3. Resident execution: no full-relation re-insert per round, set difference + and adjacency in the cube (**= P1 here**). +4. Computed columns (`Add`/`Sub` first, checked) and grouped reductions. +5. F100 behaviour: ground-term `arg/3` etc. under the lens; unsupported + binding modes refused. +6. N-ary relations: a separate research gate, kept blocked. More logical + columns are more SoA lanes under one relation identity and one row + selection, never a dense N-dimensional cube. + +Two corrections to the coverage figures reported with xlog#3: +- **Admission is not correctness.** `examples/coverage.rs` gives every + binary base relation as empty, so 140/230 means 140 shapes were accepted, + not 140 checked answers. Each newly admitted shape needs its own + differential test before it counts. +- **First reason only.** A rule is counted under its first refusal; fixing + one category can expose another. The nine counts are not additive. + +Consequence for P1: the `r(V,V)` diagonal in `generic_join` (implemented, +untested) is a prerequisite, because P1 compares the mask closure with +`xlog-cube`'s. From 2db3367e6f3a6902ff343e62c542086806716539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:31:33 +0000 Subject: [PATCH 2/4] lance-graph-xlog: resident semi-naive closure (D-STAUNEN-5 P0, P1) Cube::closure computes edge+ of one binary relation from its resident lanes: row masks over local positions, delta' = (delta o edge) andnot full, full |= delta', until the delta is empty. The difference is mask_andnot in the cube; nothing is added to the cube and no tuple is built. Refuses maps wider than 64 and a fixpoint that does not empty within n+1 rounds. ClosureStats counts rounds and rows composed (equal to |edge+| in semi-naive evaluation); CubeStats gains relations_added. tests/staunen5.rs: a BFS oracle sharing no code with the cube; the fixed 12-pair cycle; 400 random cyclic and acyclic graphs up to position 63, bit for bit; the longest chain and ring; the P0 episode generator (G* in World only) with oracle-classified one-edge-closable and impossible stimuli. Plan: the reviewer's four corrections, the P1 acceptance gate, the paired P6 criterion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- ...unen-5-resident-recursive-plasticity-v1.md | 55 ++- crates/lance-graph-xlog/src/cube.rs | 99 ++++- crates/lance-graph-xlog/tests/staunen5.rs | 344 ++++++++++++++++++ 3 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 crates/lance-graph-xlog/tests/staunen5.rs diff --git a/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md index eec41cc7f..6dc4609da 100644 --- a/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md +++ b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md @@ -55,10 +55,16 @@ The cube's update obeys the same five rules. It does not run Atlas. |---|---|---| | plan-shaped mask | the update toggles one member of the declared `C` | `NotACandidate` | | entries 0/1 | an edge is active or dormant; no weights in v1 | (by type) | -| atomic, between sequences | applied between stimuli, never inside a fixpoint round | `InsideRound` | +| atomic, between sequences | applied between stimuli, never inside a fixpoint round | `InsideRound` (cube rule) | | regrowth clears history | an activated edge carries no prior evidence; its record starts empty | — | | needs committed state | refused until the base closure is sealed under a frame | `NotSealed` | +`update_mask` checks rules 1, 2 and 5 (plus an ownership layout for +dynamic plans). The refusal names in the table are the cube's; Atlas has +no `NotACandidate`, `InsideRound` or `Stale`, and no novelty gate. A novel +stimulus that no single candidate closes is refused with +`NoClosingCandidate`, changing neither generation nor masks. + Plus two rules of our own: - **One update per novel stimulus.** A non-novel stimulus is never a licence to adapt. @@ -134,12 +140,34 @@ level. - [ ] **P0 — fixtures and oracle.** A seeded generator for `G*`, `C` (containing the true missing edges plus distractors) and stimulus streams. - A scalar oracle closure. Pre-register: task split, episode count, `k`, - minimum effect, budget. -- [ ] **P1 — in-cube semi-naive closure.** Mask closure equals the oracle - and equals `xlog-cube`'s closure on random graphs. Disable runs: drop the - `andnot` (non-termination, caught by a round bound); use `full` in place of - `delta` (equal answer, more rounds; asserted on the round count). + A scalar oracle closure. Each novel stimulus is classified by the oracle + as one-edge-closable (the candidates that close it, listed) or not. The + primary experiment uses closable episodes; every stream also carries at + least one novel stimulus no single candidate closes, which P3 must refuse + with `NoClosingCandidate` without changing the generation or the masks. + `G*` lives only in the generator and the scorer: the episode type that + H1, H2 and the selector receive has no field for it. Pre-register: task + split, episode count, `k`, minimum effect, budget. +- [ ] **P1 — in-cube semi-naive closure.** Three independent checks: a + scalar reference closure, the resident mask closure, and `xlog-cube`'s + closure. The last is a comparator only after its own nonempty `r(V,V)` + diagonal fixture passes; a failure there does not by itself indict the + resident closure. Fixed fixture: `edge = {(0,1),(1,2),(2,0),(2,3)}` has + exactly 12 closure pairs, including `(0,0)`, `(1,1)`, `(2,2)`, excluding + `(3,3)`. Disable runs: drop the `andnot` (a cyclic fixpoint never + empties its delta and hits the round bound); compose from `full` instead + of `delta`. The second gives the same closure in possibly the same number + of rounds, so it is caught on work, not rounds: in semi-naive evaluation + each closure pair enters the delta once, so rows composed equals + `|edge⁺|` exactly. + + **Acceptance.** For nonempty cyclic and acyclic graphs, including local + position 63, resident `edge⁺` equals the scalar oracle bit for bit and + agrees with a separately validated `xlog-cube` baseline. The instrumented + run shows zero host-side delta differences and zero relations added to the + cube. **Falsifier:** one mismatched pair, an unbounded cyclic fixpoint, or + one host-side delta calculation. **Refusal boundary:** a P1 pass admits no + structural adaptation, policy selection, consolidation or learning claim. - [ ] **P2 — incremental activation.** After activating `(u, v)`, the incremental closure equals a full recompute, over every `(u, v)` in a random `C`. Disable run: drop the `x = u` term. @@ -155,11 +183,18 @@ level. - [ ] **P6 — controls and meta-selection.** B0 no adaptation; B10 best fixed policy; B11 random candidate; B7 shuffled novelty (adapt on non-novel stimuli); B9 duplicated root (one observation repeated). The - meta-selector must beat B11 and match or beat B10. Report the losing - runs as well. + meta-selector must beat B11 and match B10 under a pre-registered paired + criterion over seeds (not a win on every seed). Report the losing runs + as well. - [ ] **P7 — Atlas differential (optional, lab).** Mirror one fixture into an Atlas plan whose masks are `C`, apply the same activation sequence with - `update_mask`, and check that Atlas accepts and refuses the same updates. + `update_mask`, and compare only the contract both sides share: mask + shape, 0/1 entries, committed state, and (for dynamic plans) the + ownership layout. `NotACandidate`, `InsideRound`, `Stale`, + `NoClosingCandidate` and novelty gating are the cube's own rules, tested + in P3, never demanded from Atlas. Weight restoration differs between + Atlas's static branch (`growth_weight`) and dynamic migration, and v1 has + no weights, so neither is compared. Needs Atlas built in the session; if it does not build, record that and stop rather than copying its executor. diff --git a/crates/lance-graph-xlog/src/cube.rs b/crates/lance-graph-xlog/src/cube.rs index babd0a3f4..55d4f4417 100644 --- a/crates/lance-graph-xlog/src/cube.rs +++ b/crates/lance-graph-xlog/src/cube.rs @@ -27,7 +27,12 @@ //! //! Scope: one tile (at most 256 ids across all relations), binary relations, //! a join of two sides, at most one bound per side and only on the side's -//! free column. Recursion, union and difference are not here. +//! free column. Union and difference are not here as views. +//! +//! [`Cube::closure`] is the one recursive terminal: `edge⁺` of one binary +//! relation, computed semi-naively over row masks built from the relation's +//! resident lanes (D-STAUNEN-5 P1). The delta difference is `mask_andnot` +//! inside the cube; nothing is added to the cube and no tuple is built. use std::cell::Cell; use std::sync::atomic::{AtomicU64, Ordering}; @@ -35,7 +40,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use lance_graph_dt64::dr::{Frame, LocalMap, MapRead, MapRefusal, MapTable, TileId}; use lance_graph_mask_risc::{LaneRef, Out}; use lance_graph_quack::{Agg, Cmp, Col, Filter, GroupAddr, GroupAgg, Mask, Query}; -use ndarray::simd::{checked_binary_i64, mask_and, I64Op}; +use ndarray::simd::{checked_binary_i64, mask_and, mask_andnot, mask_any, mask_or_assign, I64Op}; use crate::{all_live, run_lanes, NativeRefusal}; @@ -89,6 +94,12 @@ pub enum CubeRefusal { Map(MapRead), /// The computation refused. Native(NativeRefusal), + /// [`Cube::closure`] holds one `u64` per row, so the map may have at most + /// 64 positions; carries the map's size. + ClosureWidth(usize), + /// The fixpoint did not empty its delta within one round per position, + /// which a correct semi-naive closure cannot do. + FixpointBound, } /// What the cube has done since it was built. @@ -98,6 +109,40 @@ pub struct CubeStats { pub programs: u64, /// Tuples built by [`Cube::materialize_rows`]. pub rows_materialized: u64, + /// Relations added by [`Cube::add_relation`]. + pub relations_added: u64, +} + +/// `edge⁺` as one row mask per local position: bit `y` of row `x` is set +/// when `y` is reachable from `x` in one or more steps. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Closure { + rows: Vec, +} + +impl Closure { + /// One word per local position of the cube's map. + pub fn rows(&self) -> &[u64] { + &self.rows + } + + /// Whether local position `y` is reachable from `x`. + pub fn contains(&self, x: u8, y: u8) -> bool { + self.rows + .get(usize::from(x)) + .is_some_and(|r| y < 64 && r >> y & 1 == 1) + } +} + +/// The work one [`Cube::closure`] did. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ClosureStats { + /// Composition rounds, including the last, which found nothing new. + pub rounds: u32, + /// Rows of `edge` or'ed into a new frontier: one per bit of every delta + /// composed. In semi-naive evaluation each closure pair enters a delta + /// exactly once, so this equals `|edge⁺|`. + pub rows_composed: u64, } /// One relation's lanes: both columns as local positions in the shared map. @@ -168,6 +213,9 @@ impl Cube { let map = self.map(table)?; let r = rel(tuples, map)?; self.rels.push(r); + let mut s = self.stats.get(); + s.relations_added += 1; + self.stats.set(s); Ok((self.rels.len() - 1) as RelId) } @@ -352,6 +400,53 @@ impl Cube { Ok(out) } + /// `edge⁺` of relation `rel`, semi-naively: `delta' = (delta ∘ edge) andnot + /// full`, `full |= delta'`, until a delta is empty. Rows come from the + /// relation's lanes, which already hold local positions, so nothing is + /// looked up; the difference is a mask word, not a host-side set. + pub fn closure( + &self, + table: &MapTable, + rel: RelId, + ) -> Result<(Closure, ClosureStats), CubeRefusal> { + let r = self + .rels + .get(rel as usize) + .ok_or(CubeRefusal::UnknownRel(rel))?; + self.map(table)?; + if self.n > 64 { + return Err(CubeRefusal::ClosureWidth(self.n)); + } + let mut edge = vec![0u64; self.n]; + for (&a, &b) in r.cols[0].iter().zip(&r.cols[1]) { + edge[a as usize] |= 1 << b; + } + let mut full = edge.clone(); + let mut delta = edge.clone(); + let mut next = vec![0u64; self.n]; + let mut stats = ClosureStats::default(); + // A path visits each position once, so the delta is empty by round n+1. + while mask_any(&delta) { + if stats.rounds as usize > self.n { + return Err(CubeRefusal::FixpointBound); + } + stats.rounds += 1; + for (x, &d) in delta.iter().enumerate() { + let mut acc = 0u64; + let mut bits = d; + while bits != 0 { + acc |= edge[bits.trailing_zeros() as usize]; + bits &= bits - 1; + stats.rows_composed += 1; + } + next[x] = acc; + } + mask_andnot(&next, &full, &mut delta); + mask_or_assign(&mut full, &delta); + } + Ok((Closure { rows: full }, stats)) + } + /// The joined tuples `(left free, join value, right free)` as global ids: /// the one place this module builds rows, for a caller that downloads the /// relation. Restricted to the join's support first, so rows with no diff --git a/crates/lance-graph-xlog/tests/staunen5.rs b/crates/lance-graph-xlog/tests/staunen5.rs new file mode 100644 index 000000000..a4b436d6a --- /dev/null +++ b/crates/lance-graph-xlog/tests/staunen5.rs @@ -0,0 +1,344 @@ +//! D-STAUNEN-5 P0 and P1 (`.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md`). +//! +//! P0: a seeded episode generator and a scalar reference closure. The hidden +//! graph `G*` lives in [`fixtures::World`] only; [`fixtures::Episode`], which +//! later phases hand to the policies, has no field that can reach it. +//! +//! P1: [`Cube::closure`] against the scalar oracle, bit for bit, on cyclic +//! and acyclic graphs up to local position 63, with its work counted. + +use lance_graph_dt64::dr::{LocalMap, MapTable, Width}; +use lance_graph_xlog::cube::{Cube, CubeRefusal}; + +const TILE: u32 = 3; + +/// The scalar reference: `edge⁺` as one row mask per local position, by a +/// breadth-first search from every source. Shares no code with the cube. +mod oracle { + pub fn closure(n: usize, edges: &[(u8, u8)]) -> Vec { + let mut succ = vec![Vec::new(); n]; + for &(a, b) in edges { + succ[a as usize].push(b as usize); + } + (0..n) + .map(|s| { + let mut seen = 0u64; + let mut stack: Vec = succ[s].clone(); + while let Some(v) = stack.pop() { + if seen >> v & 1 == 0 { + seen |= 1 << v; + stack.extend(&succ[v]); + } + } + seen + }) + .collect() + } + + pub fn pairs(rows: &[u64]) -> u64 { + rows.iter().map(|r| u64::from(r.count_ones())).sum() + } +} + +/// A frame over the ids `0..n`, so global id = local position. +fn table(n: u32) -> MapTable { + let ids: Vec = (0..u64::from(n)).collect(); + let mut t = MapTable::new(1, 1); + t.advance( + 1, + 1, + vec![(TILE, Some(LocalMap::prepare(Width::W64, &ids).unwrap()))], + ); + t +} + +fn resident(n: u32, edges: &[(u8, u8)]) -> (Vec, lance_graph_xlog::cube::ClosureStats, Cube) { + let t = table(n); + let tuples: Vec<(u32, u32)> = edges + .iter() + .map(|&(a, b)| (u32::from(a), u32::from(b))) + .collect(); + let rels: [&[(u32, u32)]; 1] = [&tuples]; + let cube = Cube::new(&t, &t.seal(), TILE, &rels).unwrap(); + let (closure, stats) = cube.closure(&t, 0).unwrap(); + (closure.rows().to_vec(), stats, cube) +} + +struct Rng(u64); +impl Rng { + fn next(&mut self, m: u32) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % u64::from(m)) as u32 + } +} + +// ---------------------------------------------------------------- P1 + +/// The fixed fixture: a 3-cycle with an exit. `0,1,2` reach `0..=3` +/// (themselves included), `3` reaches nothing. +#[test] +fn the_fixed_cycle_has_exactly_twelve_pairs() { + let edges = [(0, 1), (1, 2), (2, 0), (2, 3)]; + let (rows, stats, _) = resident(4, &edges); + assert_eq!(rows, oracle::closure(4, &edges)); + assert_eq!(oracle::pairs(&rows), 12); + let has = |x: usize, y: usize| rows[x] >> y & 1 == 1; + assert!(has(0, 0) && has(1, 1) && has(2, 2)); + assert!(!has(3, 3)); + assert_eq!(stats.rows_composed, 12); +} + +/// Random cyclic and acyclic graphs, every size up to 64 ids, so local +/// position 63 is exercised. Bit for bit against the oracle; the work +/// invariant holds on every one. +#[test] +fn resident_closure_matches_the_oracle_bit_for_bit() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let (mut cyclic, mut acyclic, mut top) = (0, 0, 0); + for case in 0..400u32 { + let n = if case % 4 == 0 { 64 } else { rng.next(64) + 1 }; + let dag = case % 2 == 0; + let m = rng.next(3 * n) + 1; + let edges: Vec<(u8, u8)> = (0..m) + .filter_map(|_| { + let (a, b) = (rng.next(n) as u8, rng.next(n) as u8); + match dag { + true if a < b => Some((a, b)), + true => None, + false => Some((a, b)), + } + }) + .collect(); + if edges.is_empty() { + continue; + } + let want = oracle::closure(n as usize, &edges); + let (rows, stats, cube) = resident(n, &edges); + assert_eq!(rows, want, "case {case}, n {n}, edges {edges:?}"); + assert_eq!(stats.rows_composed, oracle::pairs(&want), "case {case}"); + assert_eq!(cube.stats().relations_added, 0, "case {case}"); + let has_cycle = (0..n as usize).any(|x| want[x] >> x & 1 == 1); + cyclic += usize::from(has_cycle); + acyclic += usize::from(!has_cycle); + top += usize::from(want.iter().any(|r| r >> 63 & 1 == 1)); + } + // Anti-vacuity: both shapes occur, and position 63 is reached. + assert!( + cyclic > 50 && acyclic > 50, + "{cyclic} cyclic, {acyclic} acyclic" + ); + assert!(top > 20, "position 63 reached in only {top} cases"); +} + +/// A long chain needs one round per step; a cycle of 64 needs 64 rounds and +/// must still terminate. +#[test] +fn the_longest_paths_terminate() { + let chain: Vec<(u8, u8)> = (0..63).map(|i| (i, i + 1)).collect(); + let (rows, stats, _) = resident(64, &chain); + assert_eq!(rows, oracle::closure(64, &chain)); + assert_eq!(oracle::pairs(&rows), 63 * 64 / 2); + + let mut ring = chain.clone(); + ring.push((63, 0)); + let (rows, stats_ring, _) = resident(64, &ring); + assert!(rows.iter().all(|&r| r == u64::MAX)); + assert!(stats_ring.rounds <= 64 && stats.rounds <= 64); +} + +#[test] +fn an_empty_relation_has_an_empty_closure() { + let (rows, stats, _) = resident(5, &[]); + assert_eq!(rows, vec![0; 5]); + assert_eq!(stats.rows_composed, 0); +} + +/// More than one word per row is not admitted: the closure refuses instead +/// of reading a 65th position. +#[test] +fn a_map_wider_than_64_is_refused() { + let ids: Vec = (0..65).collect(); + let mut t = MapTable::new(1, 1); + t.advance( + 1, + 1, + vec![(TILE, Some(LocalMap::prepare(Width::W256, &ids).unwrap()))], + ); + let tuples = [(0u32, 64u32)]; + let rels: [&[(u32, u32)]; 1] = [&tuples]; + let cube = Cube::new(&t, &t.seal(), TILE, &rels).unwrap(); + assert!(matches!( + cube.closure(&t, 0), + Err(CubeRefusal::ClosureWidth(65)) + )); + assert!(matches!( + cube.closure(&t, 1), + Err(CubeRefusal::UnknownRel(1)) + )); +} + +// ---------------------------------------------------------------- P0 + +/// The episode generator. `World` holds the hidden graph; `Episode` is what +/// a policy may read. +mod fixtures { + use super::{oracle, Rng}; + + /// Read only by the generator and by external scoring. + pub struct World { + pub n: usize, + pub g_star: Vec<(u8, u8)>, + } + + pub struct Stimulus { + pub pair: (u8, u8), + /// Not in the closure of `base`. + pub novel: bool, + /// Indices into `candidates` whose activation alone closes `pair`. + pub closing: Vec, + } + + /// No field reaches `G*`. + pub struct Episode { + pub n: usize, + pub base: Vec<(u8, u8)>, + pub candidates: Vec<(u8, u8)>, + pub stimuli: Vec, + } + + fn reaches(n: usize, edges: &[(u8, u8)], (a, b): (u8, u8)) -> bool { + oracle::closure(n, edges)[a as usize] >> b & 1 == 1 + } + + /// `G*` is a random graph over `n` ids. Two planted edges in series, + /// `(p,q),(q,r)`, plus `missing` random ones are withheld from `base`; + /// the candidates are every withheld edge plus `distractors` edges absent + /// from `G*`. Stimuli are pairs reachable in `G*`, some novel. + pub fn generate(seed: u64, n: usize, distractors: usize) -> (World, Episode) { + let mut rng = Rng(seed | 1); + let nn = n as u32; + let (p, q, r) = (0u8, 1u8, 2u8); + let mut g_star = vec![(p, q), (q, r)]; + while g_star.len() < 2 * n { + let e = (rng.next(nn) as u8, rng.next(nn) as u8); + // Keep p→q and q→r as the only routes into q and r, so the + // planted pair (p,r) needs both withheld edges. + if e.1 == q || e.1 == r || g_star.contains(&e) { + continue; + } + g_star.push(e); + } + let mut withheld = vec![(p, q), (q, r)]; + while withheld.len() < 5 { + let e = g_star[2 + rng.next(nn * 2 - 2) as usize]; + if !withheld.contains(&e) { + withheld.push(e); + } + } + let base: Vec<_> = g_star + .iter() + .filter(|e| !withheld.contains(e)) + .copied() + .collect(); + let mut candidates = withheld.clone(); + while candidates.len() < withheld.len() + distractors { + let e = (rng.next(nn) as u8, rng.next(nn) as u8); + // A distractor into q or r could close the planted pair alone. + if e.1 != q && e.1 != r && !g_star.contains(&e) && !candidates.contains(&e) { + candidates.push(e); + } + } + let truth = oracle::closure(n, &g_star); + let mut stimuli = Vec::new(); + for a in 0..n as u8 { + for b in 0..n as u8 { + if truth[a as usize] >> b & 1 == 0 { + continue; + } + let novel = !reaches(n, &base, (a, b)); + let closing = (0..candidates.len()) + .filter(|&i| { + novel && { + let mut e = base.clone(); + e.push(candidates[i]); + reaches(n, &e, (a, b)) + } + }) + .collect(); + stimuli.push(Stimulus { + pair: (a, b), + novel, + closing, + }); + } + } + ( + World { n, g_star }, + Episode { + n, + base, + candidates, + stimuli, + }, + ) + } +} + +#[test] +fn episodes_are_deterministic_per_seed() { + let (w1, e1) = fixtures::generate(7, 16, 4); + let (w2, e2) = fixtures::generate(7, 16, 4); + assert_eq!(w1.g_star, w2.g_star); + assert_eq!(e1.base, e2.base); + assert_eq!(e1.candidates, e2.candidates); + let (w3, _) = fixtures::generate(8, 16, 4); + assert_ne!(w1.g_star, w3.g_star); +} + +/// Every stream has a one-edge-closable novel stimulus and a novel stimulus +/// no single candidate closes; every `closing` list is exactly right. +#[test] +fn novel_stimuli_are_classified_by_the_oracle() { + for seed in 1..40u64 { + let (w, e) = fixtures::generate(seed, 16, 4); + let novel: Vec<_> = e.stimuli.iter().filter(|s| s.novel).collect(); + assert!(novel.iter().any(|s| !s.closing.is_empty()), "seed {seed}"); + assert!( + novel.iter().any(|s| s.closing.is_empty()), + "seed {seed}: no novel stimulus needs two edges" + ); + // The planted pair needs both withheld edges. + let planted = e.stimuli.iter().find(|s| s.pair == (0, 2)).unwrap(); + assert!(planted.novel && planted.closing.is_empty(), "seed {seed}"); + for s in &e.stimuli { + assert!(s.novel || s.closing.is_empty()); + for (i, &c) in e.candidates.iter().enumerate() { + let mut edges = e.base.clone(); + edges.push(c); + let closes = oracle::closure(e.n, &edges)[s.pair.0 as usize] >> s.pair.1 & 1 == 1; + assert_eq!(s.novel && closes, s.closing.contains(&i), "seed {seed}"); + } + } + // Withheld edges are in G*; distractors are not. + assert!(e.candidates[..5].iter().all(|c| w.g_star.contains(c))); + assert!(e.candidates[5..].iter().all(|c| !w.g_star.contains(c))); + assert_eq!(w.n, e.n); + } +} + +/// The `relations_added == 0` gate above can fire: adding a relation counts. +#[test] +fn adding_a_relation_is_counted() { + let t = table(4); + let tuples = [(0u32, 1u32)]; + let rels: [&[(u32, u32)]; 1] = [&tuples]; + let mut cube = Cube::new(&t, &t.seal(), TILE, &rels).unwrap(); + assert_eq!(cube.stats().relations_added, 0); + cube.add_relation(&t, &[(1, 2)]).unwrap(); + assert_eq!(cube.stats().relations_added, 1); + let (c, _) = cube.closure(&t, 1).unwrap(); + assert!(c.contains(1, 2) && !c.contains(0, 1)); + assert_eq!(cube.stats().relations_added, 1); +} From 8d52c730c2301eb899a8362e0412a052d7c6405f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:32:18 +0000 Subject: [PATCH 3/4] board: D-STAUNEN-5 P0/P1 status Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- .claude/board/STATUS_BOARD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 69d47bbe6..1a87fc91d 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -73,7 +73,7 @@ Plan: `.claude/plans/d-soc-fence-v1.md`. Plan only; nothing runs yet. | D-XLOG-NATIVE-5 | bag count bound through the production bind store (`lance-graph-dt64` + `dt64::dr`), polyfill words by position; Epi5 reading of XLOG results pinned (plan Amendment A) | Shipped (#1884) | lab execution only | | D-XLOG-NATIVE-7 | resident hypercube context (`lance_graph_xlog::cube`): one intake for every relation, `Copy` view handles, terminals only run programs; XLOG plans executed on it in the XLOG fork (`xlog-cube`) | In PR (#1911; xlog#2): parity + 300-case differential; 12/230 integration rules admitted | recursion, derived inputs | | D-XLOG-NATIVE-8 | XLOG behaviour lens `0xFFFF :: 0xF100` (`lance_graph_xlog::lens`): `functor`/`arg`/`=..` by position with helper-relation aliases; `declare` binds column counts as `arity` (side effect documented) | Shipped (#1913) | consumer half in xlog#2 | -| D-STAUNEN-5 | resident recursive plasticity: novelty-gated activation of one declared candidate edge (Atlas `update_mask` contract), in-cube mask delta, H1 parsimony vs H2 eligibility, consolidate/revert, policy selection (plan P0–P7) | Queued | plan only | +| D-STAUNEN-5 | resident recursive plasticity: novelty-gated activation of one declared candidate edge (Atlas `update_mask` shared contract), in-cube mask delta, H1 parsimony vs H2 eligibility, consolidate/revert, policy selection (plan P0–P7) | In progress (#1914): P0 fixtures + oracle done; P1 resident closure matches the oracle bit for bit (400 random graphs to position 63), 4 disable runs red; xlog-cube comparison pending its `r(V,V)` fixture | P2–P7 gated | ## D-LITHO — the photolithography contract (2026-10-30) From 729e8025e7895e4e9d153fdd8813e09c4bdc9bfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:40:15 +0000 Subject: [PATCH 4/4] D-STAUNEN-5: inherit the novelty kill condition; noise stimuli in P0 EXO-50 board references, read after the first draft: D-CE64-STAUNEN-0 measured that raw surprise is captured by noise (0.649 vs round-robin 0.964), and the co-research map parks novelty gating until it inherits that condition. P0's generator now carries observation noise (pairs G* does not reach, labelled only in World), including distractor traps; the plan adds raw vs habituated H2 and a round-robin arm, and pools outcomes as NARS revision over root-disjoint proofs via DerivationLedger, not Fisher-Z addition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMNZqKXSaeb9g2ypywF --- ...unen-5-resident-recursive-plasticity-v1.md | 48 +++++++++++++++-- crates/lance-graph-xlog/tests/staunen5.rs | 53 ++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md index 6dc4609da..04d5e740f 100644 --- a/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md +++ b/.claude/plans/d-staunen-5-resident-recursive-plasticity-v1.md @@ -114,6 +114,23 @@ Both must make `(a, b)` predicted. They differ in what else they predict. They are independently verifiable: each can be scored against `G*` on pairs it was not shown, and the two disagree on fixtures built for it. +**Inherited kill condition (D-CE64-STAUNEN-0, #1395; corrected by +D-CE64-CYCLE-0).** Raw surprise is captured by noise: the naive surprise +scheduler resolved 0.649 against round-robin's 0.964, and pooled over +seeds the full scheduler had no wake advantage over round-robin. The +D-STAUNEN co-research map (2026-10-31, row 11) parks novelty gating until it +inherits that condition. So: +- the stimulus stream carries **observation noise**: pairs `G*` does not + reach, presented exactly like real stimuli, some closable by a distractor + alone (P0 generates them; only `World` knows which are noise); +- H2 must be run in two forms, **raw** (any novel stimulus licenses an + update) and **habituated** (only novelty above the relation's own + habitual novelty rate licenses one), and raw H2 is expected to lose; +- a **round-robin** arm (activate candidates in a fixed rotation on novel + stimuli, no scoring) joins B11 as a baseline. A policy that does not beat + round-robin on held-out pairs under the paired criterion has failed, + whatever else it wins on. + ## Learning when an adaptation was useful An activated candidate is pending. Later stimuli are its evidence: @@ -130,8 +147,15 @@ frequency/confidence counts. those records. That is the "learn when it was useful" claim at policy level. -**Evidence rules (D-STAUNEN-4 M2/M3).** +**Evidence rules (D-STAUNEN-4 M2/M3).** Outcomes are pooled as NARS +revision over root-disjoint proofs, reusing `lance-graph-planner` +`nars::support::DerivationLedger` (its `Eval::select` never revises two +proofs that share a root). Fisher-Z addition is a different operation +(naive-Bayes accumulation of independent likelihood ratios) and is not +substituted for it (EXO-50, SPOFC, 2026-10-22). - A stimulus counts once: the same observation repeated is one root. + **Open, operator:** is a source root the observer or one report? Until + ruled, P0 treats one stimulus as one root. - A pair derived through the closure is not independent evidence for the edge that produced it, so a candidate cannot confirm itself. - Only stimuli or held-out queries arriving after the activation count. @@ -146,7 +170,9 @@ level. least one novel stimulus no single candidate closes, which P3 must refuse with `NoClosingCandidate` without changing the generation or the masks. `G*` lives only in the generator and the scorer: the episode type that - H1, H2 and the selector receive has no field for it. Pre-register: task + H1, H2 and the selector receive has no field for it. The stream also + carries observation noise (pairs outside `reach(G*)`, labelled only in + `World`), including distractor traps. Pre-register: task split, episode count, `k`, minimum effect, budget. - [ ] **P1 — in-cube semi-naive closure.** Three independent checks: a scalar reference closure, the resident mask closure, and `xlog-cube`'s @@ -181,7 +207,8 @@ level. episodes. Fixtures where they must disagree (a short distractor beside a recently active long path), so the comparison is not vacuous. - [ ] **P6 — controls and meta-selection.** B0 no adaptation; B10 best - fixed policy; B11 random candidate; B7 shuffled novelty (adapt on + fixed policy; B11 random candidate; B12 round-robin; raw vs habituated + H2; B7 shuffled novelty (adapt on non-novel stimuli); B9 duplicated root (one observation repeated). The meta-selector must beat B11 and match B10 under a pre-registered paired criterion over seeds (not a win on every seed). Report the losing runs @@ -253,3 +280,18 @@ Two corrections to the coverage figures reported with xlog#3: Consequence for P1: the `r(V,V)` diagonal in `generic_join` (implemented, untested) is a prerequisite, because P1 compares the mask closure with `xlog-cube`'s. + +## Board references read (EXO-50, 2026-11-01) + +Read after the first draft, all 351 comments (2026-10-20..31). Five touch +Staunen, plasticity or novelty; two changed this plan: +- `CC:session_01XAqKMNZqKXSaeb9g2ypywF/REASONING-CODEGRAPH-STAUNEN-20261031-01`: + novelty gating measured in D-CE64-STAUNEN-0 (raw surprise lost to + round-robin); root-disjoint support exists in `DerivationLedger`; + cross-thought dedup is D-HL-3. Adopted above. +- `session_01CG3aK3RJ7zQT4p1qZnRwoM/001`: Z-add is not NARS revision; + damped Hebb `ΔZ = η·s·p·(1−tanh|Z|)` is a candidate CE64 plasticity rule + needing an inertness test on `η`. Not used in v1 (edges are binary), and + CE64 bits 50..52 are not written (D-CE64-STAUNEN-0: `learn` gates on them). +- The other three (SHARE witness = lane dimension; D-EPI-COT C3 warrant) + bear on P4/P6 evidence reading, not on P0/P1. diff --git a/crates/lance-graph-xlog/tests/staunen5.rs b/crates/lance-graph-xlog/tests/staunen5.rs index a4b436d6a..e9a2f4d3e 100644 --- a/crates/lance-graph-xlog/tests/staunen5.rs +++ b/crates/lance-graph-xlog/tests/staunen5.rs @@ -190,6 +190,9 @@ mod fixtures { pub struct World { pub n: usize, pub g_star: Vec<(u8, u8)>, + /// Indices into `Episode::stimuli` that are observation noise: pairs + /// `G*` does not reach. Only the scorer may read this. + pub noise: Vec, } pub struct Stimulus { @@ -274,8 +277,31 @@ mod fixtures { }); } } + // Observation noise (D-CE64-STAUNEN-0: raw surprise is captured by + // noise). Each distractor's own endpoints, when `G*` does not reach + // them, are reported as a stimulus: novel, and closable by that + // distractor alone, which is the trap a raw-novelty policy falls into. + let mut noise = Vec::new(); + for &(u, v) in &candidates[withheld.len()..] { + if truth[u as usize] >> v & 1 == 1 || reaches(n, &base, (u, v)) { + continue; + } + let closing = (0..candidates.len()) + .filter(|&i| { + let mut e = base.clone(); + e.push(candidates[i]); + reaches(n, &e, (u, v)) + }) + .collect(); + noise.push(stimuli.len()); + stimuli.push(Stimulus { + pair: (u, v), + novel: true, + closing, + }); + } ( - World { n, g_star }, + World { n, g_star, noise }, Episode { n, base, @@ -342,3 +368,28 @@ fn adding_a_relation_is_counted() { assert!(c.contains(1, 2) && !c.contains(0, 1)); assert_eq!(cube.stats().relations_added, 1); } + +/// Noise stimuli are indistinguishable to a policy (same type, same fields), +/// labelled only in `World`, never reachable in `G*`; every stream carries at +/// least one that a distractor alone would close. +#[test] +fn noise_stimuli_are_labelled_only_for_the_scorer() { + let mut traps = 0; + for seed in 1..40u64 { + let (w, e) = fixtures::generate(seed, 16, 4); + let truth = oracle::closure(w.n, &w.g_star); + let in_truth = |(a, b): (u8, u8)| truth[a as usize] >> b & 1 == 1; + for (i, s) in e.stimuli.iter().enumerate() { + assert_eq!(w.noise.contains(&i), !in_truth(s.pair), "seed {seed}"); + } + let trap = w.noise.iter().any(|&i| { + let s = &e.stimuli[i]; + s.novel && s.closing.iter().any(|&c| c >= 5) + }); + traps += usize::from(trap); + } + assert!( + traps >= 35, + "only {traps}/39 streams carry a distractor trap" + ); +}