From 5bf7ec9f36c5285ca1fc9f5a8b1fd62d109b72a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:54:39 +0000 Subject: [PATCH 1/7] cognitive-shader-driver: measure what one dispatch materializes A counting global allocator measures one dispatch (after warm-up) at 16..256 rows. Baseline: 23 KB / 44 allocs at 16 rows, 5.3 MB / 530 allocs at 256 rows, for an answer that is always <= 8 hits. Bytes grow with the square of the population (the content pre-pass pushes two hits per resonant pair into a Vec that is sorted and cut to 8); the allocation count grows linearly (p64-bridge cascade returns a Vec per row). The module doc claims no allocations beyond top-k + edges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- .../tests/dispatch_trace.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 crates/cognitive-shader-driver/tests/dispatch_trace.rs diff --git a/crates/cognitive-shader-driver/tests/dispatch_trace.rs b/crates/cognitive-shader-driver/tests/dispatch_trace.rs new file mode 100644 index 000000000..606ce98da --- /dev/null +++ b/crates/cognitive-shader-driver/tests/dispatch_trace.rs @@ -0,0 +1,108 @@ +//! What does one `ShaderDriver::dispatch` materialize, and does it grow with +//! the population it looks at? +//! +//! The driver's module doc claims "no allocations beyond top-k + edges". A +//! counting global allocator measures one dispatch (after a warm-up) at +//! several population sizes. Everything the cycle materializes shows up here; +//! nothing is inferred from reading the code. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use bgz17::base17::Base17; +use bgz17::palette::Palette; +use bgz17::palette_semiring::PaletteSemiring; + +use cognitive_shader_driver::bindspace::BindSpace; +use cognitive_shader_driver::driver::CognitiveShaderBuilder; +use cognitive_shader_driver::engine_bridge::ingest_codebook_indices; +use cognitive_shader_driver::{ + CognitiveShaderDriver, ColumnWindow, MetaFilter, ShaderDispatch, StyleSelector, +}; + +struct Counting; + +static BYTES: AtomicUsize = AtomicUsize::new(0); +static COUNT: AtomicUsize = AtomicUsize::new(0); + +// SAFETY: a pure pass-through to `System`; the counters are the only addition. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + BYTES.fetch_add(layout.size(), Ordering::Relaxed); + COUNT.fetch_add(1, Ordering::Relaxed); + // SAFETY: same layout, same contract as the caller's. + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: `ptr` came from `alloc` above with this `layout`. + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + BYTES.fetch_add(new_size, Ordering::Relaxed); + COUNT.fetch_add(1, Ordering::Relaxed); + // SAFETY: forwarded unchanged. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static A: Counting = Counting; + +fn palette_256() -> PaletteSemiring { + let entries: Vec = (0..256) + .map(|i| { + let mut dims = [0i16; 17]; + dims[0] = (i * 100 % 3400) as i16; + dims[1] = ((i * 37) % 200) as i16; + Base17 { dims } + }) + .collect(); + PaletteSemiring::build(&Palette { entries }) +} + +fn planes_chain() -> [[u64; 64]; 8] { + let mut planes = [[0u64; 64]; 8]; + for (i, row) in planes[0].iter_mut().enumerate().take(63) { + *row |= 1u64 << (i + 1); + } + for (i, row) in planes[2].iter_mut().enumerate() { + *row |= 1u64 << i; + } + planes +} + +/// Bytes and allocation count of one dispatch over `n` rows. +fn measure(n: u32) -> (usize, usize, u16) { + let mut bs = BindSpace::zeros(n as usize); + let indices: Vec = (0..n as u16).collect(); + ingest_codebook_indices(&mut bs, &indices, 1, 1000, 0); + let driver = CognitiveShaderBuilder::new() + .bindspace(Arc::new(bs)) + .semiring(Arc::new(palette_256())) + .planes(planes_chain()) + .build(); + let req = ShaderDispatch { + rows: ColumnWindow::new(0, n), + meta_prefilter: MetaFilter::ALL, + layer_mask: 0xFF, + radius: u16::MAX, + style: StyleSelector::Auto, + max_cycles: u16::MAX / 4, + ..Default::default() + }; + let _warm = driver.dispatch(&req); + let (b0, c0) = (BYTES.load(Ordering::Relaxed), COUNT.load(Ordering::Relaxed)); + let crystal = driver.dispatch(&req); + let (b1, c1) = (BYTES.load(Ordering::Relaxed), COUNT.load(Ordering::Relaxed)); + (b1 - b0, c1 - c0, crystal.bus.resonance.hit_count) +} + +#[test] +fn trace_dispatch_materialization() { + println!("{:>6} {:>12} {:>12} {:>10}", "rows", "bytes", "allocs", "hit_count"); + for n in [16u32, 32, 64, 128, 256] { + let (bytes, allocs, hits) = measure(n); + println!("{n:>6} {bytes:>12} {allocs:>12} {hits:>10}"); + } +} From 0dca936ac96a181f2abad1570b2422e40677875d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:04:00 +0000 Subject: [PATCH 2/7] cognitive-shader-driver: a dispatch no longer materializes what it discards Two collections were built only to be cut down: - The content pre-pass pushed two hits per resonant pair into a Vec (O(rows^2)) that was then stable-sorted and truncated to 8. TopHits keeps the best 8 as they arrive; a new hit goes after every kept hit whose resonance is not lower, so ties keep arrival order exactly as the stable sort did. - p64-bridge's cascade returned every candidate (up to 256 per row) in a fresh Vec, sorted, of which the driver read 4. cascade_nearest:: keeps the K nearest as they arrive, allocation-free. cascade and cascade_nearest now share one private candidate walk, so the candidate logic exists once. Measured by tests/dispatch_trace.rs (one dispatch after warm-up): rows before after 16 23 KB, 44 allocs 1.2 KB, 5 allocs 256 5.3 MB, 530 allocs 2.2 KB, 5 allocs The remaining per-row growth is the prefilter's row list (4 bytes/row). The test now pins a constant allocation count across 16..256 rows and at most 4 bytes of growth per row. Output is unchanged: a digest over every field of the crystal (60 configurations x 3 dispatches, including all 12 styles' paths through Ordinal 0/5/11 and Auto, radii 50..MAX, 16..300 rows) is identical before and after (355d5fccbc424763). TopHits and cascade_nearest each have an equivalence test against the collect-sort-truncate they replace, on tie-heavy inputs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- crates/cognitive-shader-driver/src/driver.rs | 126 +++++++++++++++--- .../tests/dispatch_trace.rs | 38 +++++- crates/p64-bridge/src/lib.rs | 118 +++++++++++++++- 3 files changed, 255 insertions(+), 27 deletions(-) diff --git a/crates/cognitive-shader-driver/src/driver.rs b/crates/cognitive-shader-driver/src/driver.rs index 1590b2578..4370ad122 100644 --- a/crates/cognitive-shader-driver/src/driver.rs +++ b/crates/cognitive-shader-driver/src/driver.rs @@ -266,7 +266,10 @@ impl ShaderDriver { let planes_snapshot: [[u64; 64]; 8] = **self.planes.read().expect("planes RwLock poisoned"); let shader = CognitiveShader::new(planes_snapshot, &self.semiring); let max_dist = (self.semiring.k as f32) * (self.semiring.k as f32); - let mut hits = Vec::::with_capacity(passed_rows.len().min(64)); + // Only the best 8 hits are ever read, so they are kept as they arrive + // instead of collecting every candidate (the content pre-pass alone + // produces two per resonant pair — O(rows²)) and cutting afterwards. + let mut top = TopHits::new(); // TD-INT-10: optional NARS truth-table lookups per hit. let nars_tables = self.nars_tables.as_deref(); @@ -293,7 +296,7 @@ impl ShaderDriver { ndarray::hpc::bitwise::hamming_distance_raw(fp_i_bytes, fp_j_bytes) as u32; let resonance = 1.0 - (hamming as f32 / FP_BITS); if resonance >= min_resonance { - hits.push(ShaderHit { + top.offer(ShaderHit { row: row_i, distance: hamming.min(u16::MAX as u32) as u16, predicates: CONTENT_MATCH_PREDICATE, @@ -301,7 +304,7 @@ impl ShaderDriver { resonance, cycle_index: i as u32, }); - hits.push(ShaderHit { + top.offer(ShaderHit { row: row_j, distance: hamming.min(u16::MAX as u32) as u16, predicates: CONTENT_MATCH_PREDICATE, @@ -322,8 +325,11 @@ impl ShaderDriver { // Rows with edge=0 default to palette 0 (identity probe). let edge = backing.edge(row as usize); let query = edge.s_idx(); - let raw = shader.cascade(query, req.radius, effective_layer_mask); - for hit in raw.into_iter().take(4) { + // The 4 nearest, kept as they arrive: the full candidate list + // (up to 256 per row) was sorted and then cut to 4. + let (nearest, n_nearest) = + shader.cascade_nearest::<4>(query, req.radius, effective_layer_mask); + for hit in &nearest[..n_nearest] { let resonance = 1.0 / (1.0 + (hit.distance as f32 / max_dist)); // TD-INT-10: NARS truth lookup against precomputed tables. @@ -340,7 +346,7 @@ impl ShaderDriver { let _revised_truth = (unpack_f(packed), unpack_c(packed)); } - hits.push(ShaderHit { + top.offer(ShaderHit { row, distance: hit.distance, predicates: hit.predicates, @@ -351,13 +357,8 @@ impl ShaderDriver { } } - // Sort by resonance descending, keep top-8. - hits.sort_by(|a, b| { - b.resonance - .partial_cmp(&a.resonance) - .unwrap_or(std::cmp::Ordering::Equal) - }); - hits.truncate(8); + // Top-8 by resonance, descending; ties keep arrival order. + let hits: &[ShaderHit] = top.as_slice(); // [4] Build the cycle_fingerprint with positional Markov braiding. // Each row is rotated by its cycle_index before XOR — preserves @@ -365,7 +366,7 @@ impl ShaderDriver { // Per I-SUBSTRATE-MARKOV: this activates the Markov ±5 property // even in binary space; full f32 VSA bundle is the next step. let mut cycle_fp = [0u64; WORDS_PER_FP]; - for h in &hits { + for h in hits { let row_words = backing.content_row(h.row as usize); let pos = (h.cycle_index as usize) % WORDS_PER_FP; for (i, w) in row_words.iter().enumerate() { @@ -374,7 +375,7 @@ impl ShaderDriver { } // [5] Entropy + std-dev of top-k resonances. - let (entropy, std_dev) = entropy_std(&hits); + let (entropy, std_dev) = entropy_std(hits); // [6] FreeEnergy gate (principled F from resonance + KL surrogate). let top_resonance = hits.first().map(|h| h.resonance).unwrap_or(0.0); @@ -537,7 +538,7 @@ impl ShaderDriver { .alpha_saturation_override .unwrap_or(ALPHA_SATURATION_THRESHOLD); Some(alpha_front_to_back_composite( - &hits, + hits, |row| { hit_qualia_f32 .iter() @@ -919,6 +920,99 @@ impl Default for CognitiveShaderBuilder { // Helpers // ═══════════════════════════════════════════════════════════════════════════ +/// The best 8 hits by resonance, kept as they arrive. +/// +/// Equivalent to collecting every hit, stable-sorting by resonance descending +/// and truncating to 8: a new hit goes after every kept hit whose resonance is +/// not lower, so equal resonances keep arrival order, and a hit that would land +/// ninth is dropped. Holds exactly for finite resonances. +struct TopHits { + buf: [ShaderHit; 8], + len: usize, +} + +impl TopHits { + fn new() -> Self { + Self { + buf: [ShaderHit::default(); 8], + len: 0, + } + } + + #[inline] + fn offer(&mut self, hit: ShaderHit) { + let pos = self.buf[..self.len] + .iter() + .position(|kept| kept.resonance < hit.resonance) + .unwrap_or(self.len); + if pos >= self.buf.len() { + return; + } + let last = self.len.min(self.buf.len() - 1); + self.buf.copy_within(pos..last, pos + 1); + self.buf[pos] = hit; + self.len = (self.len + 1).min(self.buf.len()); + } + + fn as_slice(&self) -> &[ShaderHit] { + &self.buf[..self.len] + } +} + +#[cfg(test)] +mod top_hits_tests { + use super::*; + + /// The replaced implementation: collect everything, stable-sort by + /// resonance descending, cut to 8. + fn collect_sort_truncate(stream: &[ShaderHit]) -> Vec { + let mut v = stream.to_vec(); + v.sort_by(|a, b| { + b.resonance + .partial_cmp(&a.resonance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + v.truncate(8); + v + } + + fn key(h: &ShaderHit) -> (u32, u32, u32) { + (h.row, h.resonance.to_bits(), h.cycle_index) + } + + #[test] + fn top_hits_matches_collect_sort_truncate_including_ties() { + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for len in [0usize, 1, 7, 8, 9, 20, 300] { + for _ in 0..200 { + // Few distinct resonance values, so ties are common and the + // arrival-order rule is actually exercised. + let stream: Vec = (0..len) + .map(|i| ShaderHit { + row: i as u32, + resonance: (next() % 6) as f32 / 5.0, + cycle_index: (next() % 1000) as u32, + ..Default::default() + }) + .collect(); + let mut top = TopHits::new(); + for h in &stream { + top.offer(*h); + } + let want: Vec<_> = collect_sort_truncate(&stream).iter().map(key).collect(); + let got: Vec<_> = top.as_slice().iter().map(key).collect(); + assert_eq!(got, want, "len {len}"); + } + } + } +} + fn entropy_std(hits: &[ShaderHit]) -> (f32, f32) { if hits.is_empty() { return (0.0, 0.0); diff --git a/crates/cognitive-shader-driver/tests/dispatch_trace.rs b/crates/cognitive-shader-driver/tests/dispatch_trace.rs index 606ce98da..f7cfa27fb 100644 --- a/crates/cognitive-shader-driver/tests/dispatch_trace.rs +++ b/crates/cognitive-shader-driver/tests/dispatch_trace.rs @@ -5,6 +5,10 @@ //! counting global allocator measures one dispatch (after a warm-up) at //! several population sizes. Everything the cycle materializes shows up here; //! nothing is inferred from reading the code. +//! +//! Before the fix: 23 KB / 44 allocations at 16 rows, 5.3 MB / 530 at 256, +//! for an answer of at most 8 hits. Now: a constant 5 allocations, and bytes +//! grow only by the prefilter's 4-byte-per-row list. use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -100,9 +104,35 @@ fn measure(n: u32) -> (usize, usize, u16) { #[test] fn trace_dispatch_materialization() { - println!("{:>6} {:>12} {:>12} {:>10}", "rows", "bytes", "allocs", "hit_count"); - for n in [16u32, 32, 64, 128, 256] { - let (bytes, allocs, hits) = measure(n); - println!("{n:>6} {bytes:>12} {allocs:>12} {hits:>10}"); + println!( + "{:>6} {:>12} {:>12} {:>10}", + "rows", "bytes", "allocs", "hit_count" + ); + let sizes = [16u32, 32, 64, 128, 256]; + let rows: Vec<(u32, usize, usize, u16)> = sizes + .iter() + .map(|&n| { + let (bytes, allocs, hits) = measure(n); + println!("{n:>6} {bytes:>12} {allocs:>12} {hits:>10}"); + (n, bytes, allocs, hits) + }) + .collect(); + let (n0, b0, a0, _) = rows[0]; + for &(n, bytes, allocs, hits) in &rows { + assert!(hits > 0, "fixture must produce hits at {n} rows"); + // The cycle keeps at most 8 hits and 4 cascade results per row, so the + // number of allocations must not depend on how many rows it looks at. + assert_eq!( + allocs, a0, + "{n} rows: {allocs} allocations vs {a0} at {n0} rows" + ); + // The only per-row growth left is the prefilter's row list: one u32 + // per surviving row. + let per_row_limit = 4 * (n - n0) as usize; + assert!( + bytes - b0 <= per_row_limit, + "{n} rows: {bytes} bytes, {} more than at {n0} rows (limit {per_row_limit})", + bytes - b0 + ); } } diff --git a/crates/p64-bridge/src/lib.rs b/crates/p64-bridge/src/lib.rs index cbd75979c..40d0d6940 100644 --- a/crates/p64-bridge/src/lib.rs +++ b/crates/p64-bridge/src/lib.rs @@ -350,7 +350,7 @@ pub mod cognitive_shader { } /// Result of a cascade query. - #[derive(Debug, Clone)] + #[derive(Debug, Clone, Copy)] pub struct CascadeHit { /// Target archetype index (0..255). pub target: u8, @@ -381,9 +381,59 @@ pub mod cognitive_shader { /// /// Returns hits sorted by distance ascending. pub fn cascade(&self, query: u8, radius: u16, layer_mask: u8) -> Vec { + let mut hits = Vec::new(); + self.for_each_candidate(query, radius, layer_mask, |h| hits.push(h)); + hits.sort_by_key(|h| h.distance); + hits + } + + /// The `K` nearest hits of [`Self::cascade`], without allocating. + /// + /// Returns the same hits, in the same order, as + /// `cascade(..).into_iter().take(K)`: nearest first, and equal + /// distances keep candidate order (the order a stable sort keeps). + /// The second value is how many of the `K` slots are filled. + pub fn cascade_nearest( + &self, + query: u8, + radius: u16, + layer_mask: u8, + ) -> ([CascadeHit; K], usize) { + let empty = CascadeHit { + target: 0, + distance: 0, + predicates: 0, + }; + let mut buf = [empty; K]; + let mut len = 0usize; + self.for_each_candidate(query, radius, layer_mask, |h| { + let pos = buf[..len] + .iter() + .position(|kept| kept.distance > h.distance) + .unwrap_or(len); + if pos >= K { + return; + } + let last = len.min(K - 1); + buf.copy_within(pos..last, pos + 1); + buf[pos] = h; + len = (len + 1).min(K); + }); + (buf, len) + } + + /// Visit every candidate within `radius`, in candidate order (block + /// column ascending, then the 4 archetypes of each block). + fn for_each_candidate( + &self, + query: u8, + radius: u16, + layer_mask: u8, + mut visit: impl FnMut(CascadeHit), + ) { let block_row = query as usize / 4; if block_row >= 64 { - return Vec::new(); + return; } // Collect which block-columns are active across selected layers @@ -405,7 +455,6 @@ pub mod cognitive_shader { } // Expand active block-columns to archetype indices, lookup distance - let mut hits = Vec::new(); let mut bits = active_cols; while bits != 0 { let block_col = bits.trailing_zeros() as usize; @@ -419,7 +468,7 @@ pub mod cognitive_shader { } let dist = self.semiring.distance(query, target); if dist <= radius { - hits.push(CascadeHit { + visit(CascadeHit { target, distance: dist, predicates: per_col_predicates[block_col], @@ -427,9 +476,6 @@ pub mod cognitive_shader { } } } - - hits.sort_by_key(|h| h.distance); - hits } /// Transitive deduction: A→B→C via compose. @@ -651,6 +697,64 @@ mod tests { assert_eq!(ctr, contra::INVERT); } + #[test] + fn cascade_nearest_equals_cascade_take_k() { + use super::cognitive_shader::CognitiveShader; + use bgz17::base17::Base17; + use bgz17::palette::Palette; + use bgz17::palette_semiring::PaletteSemiring; + + // Coarse palette values so equal distances are common: the tie rule + // (candidate order, as the stable sort keeps it) must be exercised. + let entries: Vec = (0..256) + .map(|i| { + let mut dims = [0i16; 17]; + dims[0] = ((i % 7) * 100) as i16; + dims[1] = ((i % 3) * 50) as i16; + Base17 { dims } + }) + .collect(); + let semiring = PaletteSemiring::build(&Palette { entries }); + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut planes = [[0u64; 64]; 8]; + for plane in planes.iter_mut() { + for row in plane.iter_mut() { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *row = state; + } + } + let shader = CognitiveShader::new(planes, &semiring); + + fn key(h: &super::cognitive_shader::CascadeHit) -> (u8, u16, u8) { + (h.target, h.distance, h.predicates) + } + let mut compared = 0usize; + for query in 0..=255u8 { + for radius in [0u16, 40, 400, u16::MAX] { + for mask in [0x01u8, 0x0F, 0xFF] { + let all = shader.cascade(query, radius, mask); + let (b4, n4) = shader.cascade_nearest::<4>(query, radius, mask); + let want: Vec<_> = all.iter().take(4).map(key).collect(); + let got: Vec<_> = b4[..n4].iter().map(key).collect(); + assert_eq!(got, want, "query {query} radius {radius} mask {mask:#x}"); + let (b1, n1) = shader.cascade_nearest::<1>(query, radius, mask); + assert_eq!( + b1[..n1].iter().map(key).collect::>(), + all.iter().take(1).map(key).collect::>() + ); + compared += usize::from(all.len() > 4); + } + } + } + // The case that matters: more candidates than kept slots. + assert!( + compared > 100, + "fixture too sparse: only {compared} overflowing queries" + ); + } + #[test] fn cognitive_shader_cascade() { use super::cognitive_shader::CognitiveShader; From d1ae23e015ecbeab2fe323b93226a472802627ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:05:29 +0000 Subject: [PATCH 3/7] board: entry for the dispatch materialization trace Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- ...26-09-25-dispatch-materialization-trace.md | 26 +++++++++++++++++++ .claude/board/entries/README.md | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-09-25-dispatch-materialization-trace.md diff --git a/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md b/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md new file mode 100644 index 000000000..12d67b2a5 --- /dev/null +++ b/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md @@ -0,0 +1,26 @@ +# 2026-09-25 — One shader dispatch materialized O(rows²) to return at most 8 hits + +**Status:** MEASURED · DONE (two collections removed, output digest unchanged) · OPEN (the prefilter row list; the stage-by-stage trace of the rest of the chain) + +## The measurement (`crates/cognitive-shader-driver/tests/dispatch_trace.rs`) +A counting global allocator measures one `ShaderDriver::dispatch`, after a warm-up, at 16..256 rows. The driver's module doc claims "no allocations beyond top-k + edges". + +| rows | before | after | +|---|---|---| +| 16 | 23 KB, 44 allocs | 1.2 KB, 5 allocs | +| 256 | 5.3 MB, 530 allocs | 2.2 KB, 5 allocs | + +Before, bytes grew with the square of the population and allocations grew linearly. The answer is at most 8 hits either way (`hit_count` is taken after the cut to 8). + +## What was materialized and discarded +- **Content pre-pass.** It pushed two hits per resonant pair into a `Vec` (O(rows²)), then stable-sorted the Vec and cut it to 8. `TopHits` now keeps the best 8 as they arrive. A new hit goes after every kept hit whose resonance is not lower, so ties keep arrival order exactly as the stable sort did. +- **`p64-bridge` `cascade`.** It returned every candidate (up to 256 per row) in a fresh `Vec`, sorted, of which the driver read 4. `cascade_nearest::` keeps the K nearest, allocation-free. Both methods share one private candidate walk. + +## Evidence it is the same computation +- **Digest.** A hash over every field of the crystal (60 configurations × 3 dispatches: rows 16..300, radii 50..MAX, `Auto` + `Ordinal` 0/5/11) is identical before and after (`355d5fccbc424763`). +- **Equivalence tests.** `TopHits` and `cascade_nearest` each have a test against the collect-sort-truncate they replace, on tie-heavy inputs. +- **Disable runs.** Restoring either collection breaks the pinned constant allocation count (37 → 69, 12 → 14). + +## Open +- **The row list.** `passed_rows` (4 bytes per surviving row) is the prefilter's population materialized as a list of row ids. Replacing it with a mask is the natural next step, but it changes `BackingStore::prefilter`'s shape. +- **The rest of the trace.** Stage [4] rotates each hit's row by its `cycle_index` before the XOR, so each hit contributes in its own basis. That is the relative-coordinate step of the end-to-end chain, and it has not yet been traced as such. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 461e54459..d842a4af9 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -173 entries, 2026-08-06 .. 2026-09-25. +174 entries, 2026-08-06 .. 2026-09-25. | date | entry id | finding | file | |---|---|---|---| @@ -38,6 +38,7 @@ exactly one of them, never both. | 2026-09-25 | `llvm-whole-stack-fold-ceiling` | | [2026-09-25-llvm-whole-stack-fold-ceiling.md](2026-09-25-llvm-whole-stack-fold-ceiling.md) | | 2026-09-25 | `lance12-lancedb039-sweep` | | [2026-09-25-lance12-lancedb039-sweep.md](2026-09-25-lance12-lancedb039-sweep.md) | | 2026-09-25 | `keep-fold` | | [2026-09-25-keep-fold.md](2026-09-25-keep-fold.md) | +| 2026-09-25 | `dispatch-materialization-trace` | | [2026-09-25-dispatch-materialization-trace.md](2026-09-25-dispatch-materialization-trace.md) | | 2026-09-25 | `argon2-in-register-compress-per-tier` | | [2026-09-25-argon2-in-register-compress-per-tier.md](2026-09-25-argon2-in-register-compress-per-tier.md) | | 2026-09-25 | `aperture-prefix-lowers-to-range` | | [2026-09-25-aperture-prefix-lowers-to-range.md](2026-09-25-aperture-prefix-lowers-to-range.md) | | 2026-09-25 | `aperture-masks-and-kernel-gap` | | [2026-09-25-aperture-masks-and-kernel-gap.md](2026-09-25-aperture-masks-and-kernel-gap.md) | From 377c1761f04fa5c3632a92dfa372c9c3d5088966 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:06:18 +0000 Subject: [PATCH 4/7] board: regenerate supersession index (dispatch_trace.rs references BindSpace) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- .claude/board/SUPERSESSION-INDEX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/board/SUPERSESSION-INDEX.md b/.claude/board/SUPERSESSION-INDEX.md index e4199db6b..8aa87d1f9 100644 --- a/.claude/board/SUPERSESSION-INDEX.md +++ b/.claude/board/SUPERSESSION-INDEX.md @@ -14,7 +14,7 @@ ## What this table says **`BindSpace` is the shape of the problem.** Marked RETIRE, and simultaneously the -most-referenced symbol here: **68 crate files, 48 plans, 42 of them blind.** +most-referenced symbol here: **69 crate files, 48 plans, 42 of them blind.** That is a programme, not a cleanup. **`GateState` is the sharpest case: 1 plan names it and *every one* is blind.** @@ -60,7 +60,7 @@ coverage instead. | `GateState` | REPURPOSE | — | 14 | 1 | 1 | | `MergeMode` | REPURPOSE | — | 8 | 14 | 13 | | `ResonanceDto` | REPURPOSE | `PerturbationDto` | 2 | 12 | 7 | -| `BindSpace` | RETIRE | — | 68 | 48 | 42 | +| `BindSpace` | RETIRE | — | 69 | 48 | 42 | | `CollapseGateEmission` | RETIRE | — | 5 | 14 | 12 | | `ThinkingStyle` | RETIRE-toward-contract | — | 52 | 28 | 24 | From bde8682d38adec055f21209bd523084842bd20f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:47:44 +0000 Subject: [PATCH 5/7] cognitive-shader-driver: one SPOFC candidate per row, support kept as evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage [3] offered every supporting relationship (content pre-pass pair, each P64 cascade hit) as its own candidate slot. A row supported N ways took N top-k slots with one cycle_index, and the XOR braid cancelled repeated (row, basis) pairs — on empty predicate planes the strongest row could vanish from cycle_fp entirely (36/60 dispatches carried a repeated row in top_k). Now each surviving row is one candidate. Its supporting relationships aggregate into a SPOFC record: predicate union, best-resonance partner (Row or P64 Palette target) as object, support count as evidence, and TruthU8 { f = best resonance, c = m*255/(m+k) } via the arm-discovery convention (evidence_confidence_u8, extracted from arm_to_truth_u8). Ranking uses best resonance; cycle_index stays the rotation basis. Measured over 60 dispatches (old vs new): non-empty planes: cycle_fp 12/60, edges 60/60, top_k 60/60 changed empty planes: cycle_fp 48/60, edges 48/60, top_k 48/60 changed Disabling only the predicate union drops the non-empty edge change to 12/60, so the other 48 are the CausalMask bits of the union. CE64 emission is unchanged in shape: the target stays in the SPOFC record; writing it into CE64's 24-bit S/P/O is the follow-up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- crates/cognitive-shader-driver/Cargo.toml | 3 + crates/cognitive-shader-driver/src/driver.rs | 446 +++++++++++++++--- .../tests/dispatch_trace.rs | 12 +- .../tests/p64_target_identity_probe.rs | 31 +- .../src/translator.rs | 35 +- 5 files changed, 443 insertions(+), 84 deletions(-) diff --git a/crates/cognitive-shader-driver/Cargo.toml b/crates/cognitive-shader-driver/Cargo.toml index fb8de61f9..a6d17c7b6 100644 --- a/crates/cognitive-shader-driver/Cargo.toml +++ b/crates/cognitive-shader-driver/Cargo.toml @@ -41,6 +41,9 @@ lance-graph-ontology = { path = "../lance-graph-ontology", default-features = fa p64-bridge = { path = "../p64-bridge" } bgz17 = { path = "../bgz17" } causal-edge = { path = "../causal-edge" } +# SPOFC truth (`TruthU8`, the evidence-confidence convention). Zero-dependency +# with default features. +lance-graph-arm-discovery = { path = "../lance-graph-arm-discovery" } ndarray = { path = "../../../ndarray", default-features = false, features = ["std", "hpc-extras"] } # Optional: real thinking-engine wiring behind a feature gate, matching diff --git a/crates/cognitive-shader-driver/src/driver.rs b/crates/cognitive-shader-driver/src/driver.rs index 4370ad122..e5c399796 100644 --- a/crates/cognitive-shader-driver/src/driver.rs +++ b/crates/cognitive-shader-driver/src/driver.rs @@ -30,6 +30,7 @@ use causal_edge::edge::{CausalEdge64, InferenceType}; use causal_edge::pearl::CausalMask; use causal_edge::plasticity::PlasticityState; use causal_edge::tables::{unpack_c, unpack_f, NarsTables}; +use lance_graph_arm_discovery::translator::{evidence_confidence_u8, TruthU8, NARS_PERSONALITY_K}; use lance_graph_contract::cognitive_shader::{ AlphaComposite, CognitiveShaderDriver, EmitMode, MaterializeProvenance, MetaSummary, NullSink, RungElevator, RungLevel, ShaderBus, ShaderCrystal, ShaderDispatch, ShaderHit, ShaderResonance, @@ -222,54 +223,29 @@ impl ShaderDriver { } } - /// Run one dispatch, feeding a sink. This is the single hot path. - fn run(&self, req: &ShaderDispatch, sink: &mut S) -> ShaderCrystal { - // W3 read-shim: select the substrate (singleton BindSpace by default; - // the designated MailboxSoA under `mailbox-thoughtspace`). The body - // below is written ONCE against `backing` — no `#[cfg]` branches here. - let backing = self.backing(); - - // ── Rung ascent loop (D-TRI-6) ────────────────────────────────────── - // The persistent per-driver RungElevator's CURRENT level — advanced by - // the PREVIOUS cycle's gate via `on_gate` at the end of this fn — selects - // THIS cycle's cascade plane breadth. A dispatch whose requested base - // rung differs from the elevator's tracked base resets the elevator - // first, so streaks never leak across unrelated dispatch bases. At base - // the mask is unchanged (no regression); above base it widens by union - // (see `rung_widened_layer_mask` + its HAZARD(a) note). - let effective_layer_mask = { - let mut elevator = self - .rung_elevator - .write() - .expect("rung_elevator RwLock poisoned"); - if elevator.base != req.rung { - *elevator = RungElevator::new(req.rung); - } - rung_widened_layer_mask(elevator.base, elevator.level, req.layer_mask) - }; - - // [1] Cheap meta prefilter (u32 column sweep). - let passed_rows = backing.prefilter(req.rows, &req.meta_prefilter); - - // [2] Resolve style — Auto reads the qualia of the FIRST surviving row. - // D-CSV-5b: qualia is QualiaI4_16D; the shim converts to f32 at the read. - let qualia_f32_arr: [f32; 17] = if let Some(&row) = passed_rows.first() { - backing.qualia_17d(row as usize) - } else { - [0.0f32; 17] - }; - let style_ord = auto_style::resolve(req.style, &qualia_f32_arr[..]); - + /// Stage [3]: every supporting relationship for every prefilter row — the + /// content pre-pass partners and the cascade targets — aggregated per row + /// into one SPOFC candidate (`CandidateTable`). + fn collect_candidates( + &self, + backing: &BackingStore<'_>, + req: &ShaderDispatch, + passed_rows: &[u32], + style_ord: u8, + effective_layer_mask: u8, + ) -> CandidateTable { // [3] Shader cascade — bgz17 O(1) per probed block. // Snapshot the planes under the read lock so the cascade sees a // consistent topology even if `update_planes` fires mid-dispatch. let planes_snapshot: [[u64; 64]; 8] = **self.planes.read().expect("planes RwLock poisoned"); let shader = CognitiveShader::new(planes_snapshot, &self.semiring); let max_dist = (self.semiring.k as f32) * (self.semiring.k as f32); - // Only the best 8 hits are ever read, so they are kept as they arrive - // instead of collecting every candidate (the content pre-pass alone - // produces two per resonant pair — O(rows²)) and cutting afterwards. - let mut top = TopHits::new(); + // Every supporting relationship found for a row — a pre-pass partner + // or a cascade target — is recorded against that row's position, so a + // row with many partners is ONE candidate carrying all of its support + // (SPOFC), not many candidates. Duplicate candidates would share one + // rotation basis and cancel each other in the XOR braid below. + let mut candidates = CandidateTable::new(passed_rows.len()); // TD-INT-10: optional NARS truth-table lookups per hit. let nars_tables = self.nars_tables.as_deref(); @@ -296,22 +272,21 @@ impl ShaderDriver { ndarray::hpc::bitwise::hamming_distance_raw(fp_i_bytes, fp_j_bytes) as u32; let resonance = 1.0 - (hamming as f32 / FP_BITS); if resonance >= min_resonance { - top.offer(ShaderHit { - row: row_i, - distance: hamming.min(u16::MAX as u32) as u16, - predicates: CONTENT_MATCH_PREDICATE, - _pad: 0, + let distance = hamming.min(u16::MAX as u32) as u16; + candidates.support( + i, resonance, - cycle_index: i as u32, - }); - top.offer(ShaderHit { - row: row_j, - distance: hamming.min(u16::MAX as u32) as u16, - predicates: CONTENT_MATCH_PREDICATE, - _pad: 0, + distance, + CONTENT_MATCH_PREDICATE, + SupportPartner::Row(row_j), + ); + candidates.support( + j_off, resonance, - cycle_index: j_off as u32, - }); + distance, + CONTENT_MATCH_PREDICATE, + SupportPartner::Row(row_i), + ); } } } @@ -346,19 +321,78 @@ impl ShaderDriver { let _revised_truth = (unpack_f(packed), unpack_c(packed)); } - top.offer(ShaderHit { - row, - distance: hit.distance, - predicates: hit.predicates, - _pad: 0, + candidates.support( + cycle_idx, resonance, - cycle_index: cycle_idx as u32, - }); + hit.distance, + hit.predicates, + SupportPartner::Palette(hit.target), + ); } } - // Top-8 by resonance, descending; ties keep arrival order. + candidates + } + + /// Run one dispatch, feeding a sink. This is the single hot path. + fn run(&self, req: &ShaderDispatch, sink: &mut S) -> ShaderCrystal { + // W3 read-shim: select the substrate (singleton BindSpace by default; + // the designated MailboxSoA under `mailbox-thoughtspace`). The body + // below is written ONCE against `backing` — no `#[cfg]` branches here. + let backing = self.backing(); + + // ── Rung ascent loop (D-TRI-6) ────────────────────────────────────── + // The persistent per-driver RungElevator's CURRENT level — advanced by + // the PREVIOUS cycle's gate via `on_gate` at the end of this fn — selects + // THIS cycle's cascade plane breadth. A dispatch whose requested base + // rung differs from the elevator's tracked base resets the elevator + // first, so streaks never leak across unrelated dispatch bases. At base + // the mask is unchanged (no regression); above base it widens by union + // (see `rung_widened_layer_mask` + its HAZARD(a) note). + let effective_layer_mask = { + let mut elevator = self + .rung_elevator + .write() + .expect("rung_elevator RwLock poisoned"); + if elevator.base != req.rung { + *elevator = RungElevator::new(req.rung); + } + rung_widened_layer_mask(elevator.base, elevator.level, req.layer_mask) + }; + + // [1] Cheap meta prefilter (u32 column sweep). + let passed_rows = backing.prefilter(req.rows, &req.meta_prefilter); + + // [2] Resolve style — Auto reads the qualia of the FIRST surviving row. + // D-CSV-5b: qualia is QualiaI4_16D; the shim converts to f32 at the read. + let qualia_f32_arr: [f32; 17] = if let Some(&row) = passed_rows.first() { + backing.qualia_17d(row as usize) + } else { + [0.0f32; 17] + }; + let style_ord = auto_style::resolve(req.style, &qualia_f32_arr[..]); + + // [3] Collect every supporting relationship per candidate row. + let candidates = + self.collect_candidates(&backing, req, &passed_rows, style_ord, effective_layer_mask); + + // Each candidate row once, ranked by its best partner resonance; its + // position in the prefilter list stays its rotation basis. Ties keep + // row-position order. + let mut top = TopHits::new(); + for (pos, cand) in candidates.iter() { + top.offer(cand.hit(passed_rows[pos], pos)); + } let hits: &[ShaderHit] = top.as_slice(); + // A selected candidate always carries its evidence: at least one + // supporting relationship, hence a non-zero evidence confidence. The + // support is retained here (SPOFC); writing it into the emitted + // CausalEdge64 is the follow-up, so this change leaves edges as-is. + debug_assert!(hits.iter().all(|h| { + candidates + .spofc(h.cycle_index as usize, h.row) + .is_some_and(|s| s.truth.confidence > 0) + })); // [4] Build the cycle_fingerprint with positional Markov braiding. // Each row is rotated by its cycle_index before XOR — preserves @@ -920,6 +954,125 @@ impl Default for CognitiveShaderBuilder { // Helpers // ═══════════════════════════════════════════════════════════════════════════ +/// The other end of one supporting relationship: a content pre-pass partner +/// (another row) or a cascade target (a palette archetype). Two different +/// id spaces, so they stay two variants rather than one number. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SupportPartner { + Row(u32), + Palette(u8), +} + +/// One candidate row's support, in the SPOFC shape used by +/// `lance-graph-arm-discovery` (`{s, p, o, f, c}` with a [`TruthU8`]): +/// subject = the row, predicates = the union of its relationships' kinds, +/// object = its best supporting relationship, frequency = that +/// relationship's resonance, confidence = the +/// NARS evidence confidence of how many supporting relationships were found +/// (`m / (m + k)`, `evidence_confidence_u8`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Spofc { + pub subject: u32, + pub predicates: u8, + pub object: SupportPartner, + pub truth: TruthU8, + /// `m`: supporting relationships found for the subject. + pub support: u16, +} + +#[derive(Clone, Copy)] +struct Candidate { + resonance: f32, + distance: u16, + predicates: u8, + partner: SupportPartner, + support: u16, +} + +impl Candidate { + fn hit(&self, row: u32, pos: usize) -> ShaderHit { + ShaderHit { + row, + distance: self.distance, + predicates: self.predicates, + _pad: 0, + resonance: self.resonance, + cycle_index: pos as u32, + } + } +} + +/// Per prefilter position: the best supporting relationship found for that +/// row and how many were found. One record per surviving row, not per pair. +pub(crate) struct CandidateTable { + slots: Vec>, +} + +impl CandidateTable { + pub(crate) fn new(rows: usize) -> Self { + Self { + slots: vec![None; rows], + } + } + + /// Record one supporting relationship for the row at `pos`. The best + /// relationship (highest resonance; on a tie the first) sets the ranking, + /// distance and object; the predicate bits are the union over all of them. + fn support( + &mut self, + pos: usize, + resonance: f32, + distance: u16, + predicates: u8, + partner: SupportPartner, + ) { + let slot = &mut self.slots[pos]; + match slot { + Some(c) => { + c.support = c.support.saturating_add(1); + // Every relationship's kind stays on the candidate, whichever + // one ranks it. + c.predicates |= predicates; + if resonance > c.resonance { + c.resonance = resonance; + c.distance = distance; + c.partner = partner; + } + } + None => { + *slot = Some(Candidate { + resonance, + distance, + predicates, + partner, + support: 1, + }); + } + } + } + + fn iter(&self) -> impl Iterator { + self.slots + .iter() + .enumerate() + .filter_map(|(pos, c)| c.as_ref().map(|c| (pos, c))) + } + + /// The SPOFC record of the row at `pos`, if it has any support. + pub(crate) fn spofc(&self, pos: usize, row: u32) -> Option { + self.slots.get(pos).copied().flatten().map(|c| Spofc { + subject: row, + predicates: c.predicates, + object: c.partner, + truth: TruthU8 { + frequency: (c.resonance.clamp(0.0, 1.0) * 255.0) as u8, + confidence: evidence_confidence_u8(u32::from(c.support), NARS_PERSONALITY_K), + }, + support: c.support, + }) + } +} + /// The best 8 hits by resonance, kept as they arrive. /// /// Equivalent to collecting every hit, stable-sorting by resonance descending @@ -1176,6 +1329,171 @@ mod tests { ColumnWindow, MetaFilter, ShaderDispatch, StyleSelector, }; + /// `n` rows, each with one distinct content bit, so every pair resonates + /// equally in the content pre-pass; empty predicate planes, so the cascade + /// returns nothing and the pre-pass alone fills the top 8. + fn empty_plane_driver(n: u32) -> ShaderDriver { + let mut bs = BindSpace::zeros(n as usize); + let indices: Vec = (0..n as u16).collect(); + crate::engine_bridge::ingest_codebook_indices(&mut bs, &indices, 1, 1000, 0); + CognitiveShaderBuilder::new() + .bindspace(Arc::new(bs)) + .semiring(Arc::new(demo_semiring())) + .planes([[0u64; 64]; 8]) + .build() + } + + fn all_rows(n: u32) -> ShaderDispatch { + ShaderDispatch { + rows: ColumnWindow::new(0, n), + meta_prefilter: MetaFilter::ALL, + layer_mask: 0xFF, + radius: u16::MAX, + style: StyleSelector::Auto, + max_cycles: u16::MAX / 4, + ..Default::default() + } + } + + /// Regression: with the cascade empty, the pre-pass emitted row i once per + /// resonating partner. Row 0 took four of the eight slots with one basis, + /// and its four rotated contributions XOR-cancelled out of cycle_fp. + #[test] + fn repeated_support_is_one_candidate_and_survives_the_braid() { + let n = 16u32; + let driver = empty_plane_driver(n); + let req = all_rows(n); + let crystal = driver.dispatch(&req); + let k = crystal.bus.resonance.hit_count as usize; + let top = &crystal.bus.resonance.top_k[..k]; + assert_eq!(k, 8, "sixteen rows with support must fill all eight slots"); + + // Distinct candidate rows: none takes two slots. + let mut rows: Vec = top.iter().map(|h| h.row).collect(); + rows.sort_unstable(); + rows.dedup(); + assert_eq!(rows.len(), k, "a row occupied more than one slot: {top:?}"); + + // The basis is the row's prefilter position (here, its row id). + assert!(top.iter().all(|h| h.cycle_index == h.row)); + + // The braid is exactly one rotated contribution per selected row, and + // the strongest candidate is in it. + let bs = driver.bindspace(); + let mut expected = [0u64; WORDS_PER_FP]; + for h in top { + let words = bs.fingerprints.content_row(h.row as usize); + let pos = h.cycle_index as usize % WORDS_PER_FP; + for (i, w) in words.iter().enumerate() { + expected[(i + pos) % WORDS_PER_FP] ^= *w; + } + } + assert_eq!(crystal.bus.cycle_fingerprint, expected); + let strongest = top[0]; + let mut alone = [0u64; WORDS_PER_FP]; + for (i, w) in bs + .fingerprints + .content_row(strongest.row as usize) + .iter() + .enumerate() + { + alone[(i + strongest.cycle_index as usize) % WORDS_PER_FP] ^= *w; + } + assert!( + alone + .iter() + .zip(crystal.bus.cycle_fingerprint.iter()) + .all(|(a, f)| a & f == *a), + "the strongest row's contribution is missing from cycle_fp" + ); + assert_eq!( + crystal + .bus + .cycle_fingerprint + .iter() + .map(|w| w.count_ones()) + .sum::(), + k as u32, + "one bit per selected row: nothing cancelled" + ); + } + + /// F-ARW-TARGET-1's fixture: one source row whose cascade returns four + /// distinct targets at equal distance. They were four candidate slots and + /// four identical CE64s; now they are one candidate carrying all four. + #[test] + fn p64_targets_survive_as_spofc_support() { + let q = lance_graph_contract::qualia::QualiaI4_16D::ZERO; + let bs = BindSpaceBuilder::new(1) + .push( + &[0u64; WORDS_PER_FP], + MetaWord::new(1, 1, 200, 200, 5), + 0, + q, + 0, + 0, + ) + .build(); + let semiring = PaletteSemiring::build(&Palette { + entries: (0..4).map(|_| Base17 { dims: [0i16; 17] }).collect(), + }); + let mut planes = [[0u64; 64]; 8]; + planes[0][0] = 1; + let driver = CognitiveShaderBuilder::new() + .bindspace(Arc::new(bs)) + .semiring(Arc::new(semiring)) + .planes(planes) + .build(); + let req = ShaderDispatch { + rows: ColumnWindow::new(0, 1), + meta_prefilter: MetaFilter::ALL, + layer_mask: 0b0000_0001, + radius: u16::MAX, + style: StyleSelector::Ordinal(1), + ..ShaderDispatch::default() + }; + let backing = driver.backing(); + let passed = backing.prefilter(req.rows, &req.meta_prefilter); + assert_eq!(passed.len(), 1); + let table = driver.collect_candidates(&backing, &req, &passed, 1, req.layer_mask); + let s = table + .spofc(0, passed[0]) + .expect("the row has cascade support"); + assert_eq!(s.support, 4, "all four target relations retained"); + assert!(matches!(s.object, SupportPartner::Palette(t) if t < 4)); + assert_eq!(table.iter().count(), 1, "one candidate, not four"); + } + + /// The repeats are evidence, not noise: each row keeps a count of its + /// supporting relationships and the best one, in SPOFC form. + #[test] + fn repeated_support_is_retained_as_spofc_evidence() { + let n = 16u32; + let driver = empty_plane_driver(n); + let req = all_rows(n); + let backing = driver.backing(); + let passed = backing.prefilter(req.rows, &req.meta_prefilter); + let style = auto_style::resolve(req.style, &backing.qualia_17d(passed[0] as usize)[..]); + let table = driver.collect_candidates(&backing, &req, &passed, style, req.layer_mask); + for (pos, &row) in passed.iter().enumerate() { + let s = table.spofc(pos, row).expect("every row has partners"); + // Every other row resonates with this one: n - 1 relationships. + assert_eq!(s.support, (n - 1) as u16, "row {row}"); + assert_eq!(s.subject, row); + assert_eq!( + s.truth.confidence, + evidence_confidence_u8(n - 1, NARS_PERSONALITY_K) + ); + // The best partner is another row, never the subject itself. + assert!(matches!(s.object, SupportPartner::Row(p) if p != row)); + } + // More evidence, more confidence: fifteen partners beat one. + assert!( + evidence_confidence_u8(n - 1, NARS_PERSONALITY_K) + > evidence_confidence_u8(1, NARS_PERSONALITY_K) + ); + } + fn demo_bindspace() -> BindSpace { use lance_graph_contract::qualia::QualiaI4_16D; let q = QualiaI4_16D::ZERO; diff --git a/crates/cognitive-shader-driver/tests/dispatch_trace.rs b/crates/cognitive-shader-driver/tests/dispatch_trace.rs index f7cfa27fb..976e9ba08 100644 --- a/crates/cognitive-shader-driver/tests/dispatch_trace.rs +++ b/crates/cognitive-shader-driver/tests/dispatch_trace.rs @@ -7,8 +7,9 @@ //! nothing is inferred from reading the code. //! //! Before the fix: 23 KB / 44 allocations at 16 rows, 5.3 MB / 530 at 256, -//! for an answer of at most 8 hits. Now: a constant 5 allocations, and bytes -//! grow only by the prefilter's 4-byte-per-row list. +//! for an answer of at most 8 hits. Now: a constant allocation count, and bytes +//! grow only per surviving row (its prefilter entry and its SPOFC candidate +//! record), never per pair. use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -126,9 +127,10 @@ fn trace_dispatch_materialization() { allocs, a0, "{n} rows: {allocs} allocations vs {a0} at {n0} rows" ); - // The only per-row growth left is the prefilter's row list: one u32 - // per surviving row. - let per_row_limit = 4 * (n - n0) as usize; + // Per surviving row: the prefilter's row list (4 bytes) and one SPOFC + // candidate record carrying that row's aggregated support (24 bytes). + // Nothing grows with the number of PAIRS. + let per_row_limit = 28 * (n - n0) as usize; assert!( bytes - b0 <= per_row_limit, "{n} rows: {bytes} bytes, {} more than at {n0} rows (limit {per_row_limit})", diff --git a/crates/cognitive-shader-driver/tests/p64_target_identity_probe.rs b/crates/cognitive-shader-driver/tests/p64_target_identity_probe.rs index 118647b85..1e265a617 100644 --- a/crates/cognitive-shader-driver/tests/p64_target_identity_probe.rs +++ b/crates/cognitive-shader-driver/tests/p64_target_identity_probe.rs @@ -12,6 +12,18 @@ //! source BindSpace row but not `CascadeHit.target`. CE64 emission then derives //! S/P/O from that source row. If all emitted CE64 words are identical, target //! identity is proven lost at this seam. +//! +//! Re-pinned 2026-09-25 (#1293). The four targets used to become four +//! candidate slots and four byte-identical CE64s — repeated emissions of one +//! relation. Stage [3] now aggregates every supporting relationship per source +//! row (SPOFC): the four targets are ONE candidate with support 4, whose best +//! target is kept as its object (`driver::tests`, +//! `p64_targets_survive_as_spofc_support`). What stands is narrower than +//! "CE64 cannot carry the target": its 24-bit S/P/O can hold three palette256 +//! indices, so a target has a place to go. The driver just never writes it +//! there — it packs S/O from the source row id and P = 0. So exactly one edge +//! now represents the four relations, with the target left in the SPOFC record. +//! Writing the aggregated support and target into CE64 is the follow-up. use std::collections::BTreeSet; use std::sync::Arc; @@ -95,19 +107,16 @@ fn distinct_p64_targets_collapse_to_one_emitted_ce64_identity() { let crystal = driver.dispatch(&req); let n = crystal.bus.emitted_edge_count as usize; - assert!( - n > 1, - "probe is vacuous: the live driver emitted fewer than two CE64s (n={n})" - ); - - let emitted = &crystal.bus.emitted_edges[..n]; - let first = emitted[0]; - assert!( - emitted.iter().all(|&edge| edge == first), - "F-ARW-TARGET-1 falsified: distinct P64 targets remain distinguishable in emitted CE64s: {emitted:?}" + // One source row with four relations is one candidate, so one CE64 — not + // four identical copies of it. + assert_eq!( + n, + 1, + "the four targets of one source row must be one candidate: {:?}", + &crystal.bus.emitted_edges[..n] ); - let decoded = CausalEdge64(first); + let decoded = CausalEdge64(crystal.bus.emitted_edges[0]); assert_eq!(decoded.s_idx(), 0, "source row 0 is projected to S=0"); assert_eq!(decoded.p_idx(), 0, "current emission writes P=0"); assert_eq!(decoded.o_idx(), 0, "source row 0 is projected to O=0"); diff --git a/crates/lance-graph-arm-discovery/src/translator.rs b/crates/lance-graph-arm-discovery/src/translator.rs index 32775fe9e..01e90e18c 100644 --- a/crates/lance-graph-arm-discovery/src/translator.rs +++ b/crates/lance-graph-arm-discovery/src/translator.rs @@ -76,15 +76,38 @@ pub fn arm_to_truth_u8(rule: &CandidateRule, k: u32) -> TruthU8 { } else { ((rule.cooccur as u64 * 255) / rule.antecedent_count as u64).min(255) as u8 }; - // confidence = m / (m + k), m = cooccur (integer evidential mass); k>0 ⇒ denom>0 - let m = rule.cooccur as u64; - let confidence = ((m * 255) / (m + k as u64)) as u8; + let confidence = evidence_confidence_u8(rule.cooccur, k); TruthU8 { frequency, confidence, } } +/// NARS confidence of `m` units of evidence, `c = m / (m + k)`, quantised so +/// that `255` = 1.0. The confidence half of [`arm_to_truth_u8`], for callers +/// whose evidence is a count of supporting relationships rather than a rule. +/// +/// ``` +/// use lance_graph_arm_discovery::translator::{evidence_confidence_u8, NARS_PERSONALITY_K}; +/// assert_eq!(evidence_confidence_u8(0, NARS_PERSONALITY_K), 0); +/// assert_eq!(evidence_confidence_u8(1, NARS_PERSONALITY_K), 127); +/// assert_eq!(evidence_confidence_u8(8, NARS_PERSONALITY_K), 226); +/// ``` +/// +/// # Panics +/// +/// If `k == 0`: any positive evidence would then be dogmatic (confidence 1.0). +#[must_use] +pub fn evidence_confidence_u8(m: u32, k: u32) -> u8 { + assert!( + k > 0, + "NARS personality constant k must be > 0; k=0 makes any evidence dogmatic (confidence=1.0)" + ); + // m / (m + k) on integers; k > 0 so the denominator is positive. + let m = m as u64; + ((m * 255) / (m + k as u64)) as u8 +} + /// An `f32` NARS truth — **edge convenience only** (see module docs). Derived /// from [`TruthU8`]; never read inside the discovery path. #[derive(Debug, Clone, Copy, PartialEq)] @@ -257,7 +280,11 @@ mod tests { #[test] fn triple_projection() { - let t = CandidateTriple::from_rule(&rule(90, 100, 400), &DebugProjector::default(), NARS_PERSONALITY_K); + let t = CandidateTriple::from_rule( + &rule(90, 100, 400), + &DebugProjector::default(), + NARS_PERSONALITY_K, + ); assert_eq!(t.s, "arm:feat0=cat1"); assert_eq!(t.p, "implies"); assert_eq!(t.o, "arm:feat1=cat0"); From 48347259ef369e35d3388f98aefb9a622959aaa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:48:46 +0000 Subject: [PATCH 6/7] cognitive-shader-driver: pin SPOFC predicate union The union had no falsifier: removing it left all 111 lib tests green, because the first relationship's bits were kept instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- crates/cognitive-shader-driver/src/driver.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/cognitive-shader-driver/src/driver.rs b/crates/cognitive-shader-driver/src/driver.rs index e5c399796..be69406b0 100644 --- a/crates/cognitive-shader-driver/src/driver.rs +++ b/crates/cognitive-shader-driver/src/driver.rs @@ -1421,6 +1421,25 @@ mod tests { /// F-ARW-TARGET-1's fixture: one source row whose cascade returns four /// distinct targets at equal distance. They were four candidate slots and /// four identical CE64s; now they are one candidate carrying all four. + /// A row supported by several relationship kinds keeps all of them: the + /// predicate bits are the union, while ranking and object follow the best + /// resonance. Taking only the best relationship's bits would silently drop + /// the weaker kinds from the emitted `CausalMask`. + #[test] + fn spofc_predicates_are_the_union_of_supporting_relationships() { + let mut t = CandidateTable::new(1); + t.support(0, 0.5, 10, 0b001, SupportPartner::Palette(1)); + t.support(0, 0.9, 5, 0b010, SupportPartner::Palette(2)); + t.support(0, 0.3, 20, 0b100, SupportPartner::Row(7)); + let s = t.spofc(0, 0).expect("row 0 was supported"); + assert_eq!(s.predicates, 0b111, "every relationship kind kept"); + assert_eq!(s.support, 3); + // The best (0.9) relationship ranks it and names the object; the + // union does not come from simply keeping the last or first bits. + assert!(matches!(s.object, SupportPartner::Palette(2))); + assert_eq!(s.truth.frequency, (0.9f32 * 255.0) as u8); + } + #[test] fn p64_targets_survive_as_spofc_support() { let q = lance_graph_contract::qualia::QualiaI4_16D::ZERO; From ee4e14512779868c6d6f877ee15b151f1bdbc997 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:49:38 +0000 Subject: [PATCH 7/7] board: SPOFC candidate aggregation + cycle_index cancellation finding Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019HnekoM1EidTwQLS3oFVFm --- .../2026-09-25-dispatch-materialization-trace.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md b/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md index 12d67b2a5..227c5158e 100644 --- a/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md +++ b/.claude/board/entries/2026-09-25-dispatch-materialization-trace.md @@ -1,6 +1,6 @@ # 2026-09-25 — One shader dispatch materialized O(rows²) to return at most 8 hits -**Status:** MEASURED · DONE (two collections removed, output digest unchanged) · OPEN (the prefilter row list; the stage-by-stage trace of the rest of the chain) +**Status:** MEASURED · DONE (two collections removed, output digest unchanged; duplicate candidate slots replaced by one SPOFC candidate per row, #1293) · OPEN (the prefilter row list; writing SPOFC into CE64) ## The measurement (`crates/cognitive-shader-driver/tests/dispatch_trace.rs`) A counting global allocator measures one `ShaderDriver::dispatch`, after a warm-up, at 16..256 rows. The driver's module doc claims "no allocations beyond top-k + edges". @@ -24,3 +24,16 @@ Before, bytes grew with the square of the population and allocations grew linear ## Open - **The row list.** `passed_rows` (4 bytes per surviving row) is the prefilter's population materialized as a list of row ids. Replacing it with a mask is the natural next step, but it changes `BackingStore::prefilter`'s shape. - **The rest of the trace.** Stage [4] rotates each hit's row by its `cycle_index` before the XOR, so each hit contributes in its own basis. That is the relative-coordinate step of the end-to-end chain, and it has not yet been traced as such. + +## The `cycle_index` trace found a cancellation (#1293) +Stage [4] builds `cycle_fp = XOR over hits of R^{cycle_index} · row`. Stage [3] offered every supporting relationship (each content pair, each P64 cascade hit) as its own slot, and a row's `cycle_index` is its position, so one row supported N ways gave N identical terms. XOR cancels pairs: on empty predicate planes, 36 of 60 dispatches had a repeated row in top-k, and in some the strongest row vanished from `cycle_fp` entirely. + +**Fix.** One candidate per surviving row. Its relationships aggregate into a SPOFC record (driver-private; the only code precedent is arm-discovery's `CandidateTriple`/`TruthU8`): predicate union, best-resonance partner as object (`Row` or P64 `Palette` target), support count `m`, and `TruthU8 { f = best resonance, c = m·255/(m+k) }` via `evidence_confidence_u8`, extracted from `arm_to_truth_u8` so both share one convention. + +**Effect, 60 dispatches each, old vs new.** Non-empty planes: `cycle_fp` changed in 12, edges and top-k in 60. Empty planes: 48, 48, 48; repeated rows 36 → 0. Disabling only the predicate union drops the non-empty edge change to 12, so 48 of those edge changes are the union's `CausalMask` bits. The digest above is therefore intentionally not preserved by this change. + +**Allocation.** One more allocation (the candidate table, 6 total), still constant in rows; bytes grow 28 per surviving row (4 row id + 24 SPOFC). `dispatch_trace` pins both. + +**Falsifiers, each disable-verified:** re-offering each relationship → the braid test fails; not counting support → both SPOFC evidence tests fail; dropping or overwriting the union → the union test fails (it had no falsifier before; all 111 lib tests stayed green without it). + +**Not done here.** The SPOFC record ends at a `debug_assert`: the emitted CE64 still packs `s = row%256, p = 0, o = (row/4)%256`, `f = c = resonance`. CE64's 24-bit S/P/O could hold the target (palette256³); the driver does not write it. That, and carrying the evidence across cycles without recounting it, is the follow-up.