diff --git a/.claude/board/entries/2026-10-28-d-epi-cot-c3b-scent.md b/.claude/board/entries/2026-10-28-d-epi-cot-c3b-scent.md new file mode 100644 index 000000000..81d3ed230 --- /dev/null +++ b/.claude/board/entries/2026-10-28-d-epi-cot-c3b-scent.md @@ -0,0 +1,20 @@ +# D-EPI-COT-C3B: scent ranks candidate mediators by testability, not raw uncertainty + +**Status:** TEST-PINNED (fixture world). Code: `crates/lance-graph-planner/src/nars/scent.rs`. Doc: `docs/research/epistemic-cot/c3b-scenting.md`. + +## Decision + +- `scent` ranks candidate mediators of an `A → Y` edge. Testable leads come first: one link is bound in the sealed chain and grounded in the ledger, and the other link is missing. Within a status, leads are ordered by uncertainty (binary entropy of the NARS expectation). +- It reads only. It writes no edge, truth or certification, and it stores no state. +- A settled lead reopens as contested on independent contradiction. The edge keeps its certification. + +## Evidence + +- 6 tests; 4 disables, each red. +- On the fixture, scent hydrates the true mediator in 1 investigation. Uncertainty-only ordering takes 4, because all four candidates have uncertainty 1.0. + +## Open + +- One world, one seed; extend STAUNEN-0 with this ranking. +- No budget, habituation or learning-progress terms. +- Maturation bits 50..52 are unwritten (order for new readings: `7 = Staunen … 0 = Wisdom`). diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 6f379afec..ee43d7b03 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,13 +25,14 @@ 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. -497 entries, 2026-08-06 .. 2026-10-28. +498 entries, 2026-08-06 .. 2026-10-28. | date | entry id | finding | file | |---|---|---|---| | 2026-10-28 | `thought-layers-nan-skip` | | [2026-10-28-thought-layers-nan-skip.md](2026-10-28-thought-layers-nan-skip.md) | | 2026-10-28 | `lane-layout-from-classview` | | [2026-10-28-lane-layout-from-classview.md](2026-10-28-lane-layout-from-classview.md) | | 2026-10-28 | `D-SAG-6-1` | | [2026-10-28-d-sag-6-1-audit-reference-ledger.md](2026-10-28-d-sag-6-1-audit-reference-ledger.md) | +| 2026-10-28 | `D-EPI-COT-C3B` | | [2026-10-28-d-epi-cot-c3b-scent.md](2026-10-28-d-epi-cot-c3b-scent.md) | | 2026-10-28 | `D-EPI-COT` | | [2026-10-28-d-epi-cot-c0-c2.md](2026-10-28-d-epi-cot-c0-c2.md) | | 2026-10-28 | `D-DT64-6` | | [2026-10-28-d-dt64-6-routing-bindstore-behavior.md](2026-10-28-d-dt64-6-routing-bindstore-behavior.md) | | 2026-10-28 | `D-DT64-6-1a` | | [2026-10-28-d-dt64-6-ogit-predicates-impl.md](2026-10-28-d-dt64-6-ogit-predicates-impl.md) | diff --git a/crates/lance-graph-planner/src/nars/mod.rs b/crates/lance-graph-planner/src/nars/mod.rs index 4e6b8393e..eff63839f 100644 --- a/crates/lance-graph-planner/src/nars/mod.rs +++ b/crates/lance-graph-planner/src/nars/mod.rs @@ -18,6 +18,7 @@ pub mod insights; pub mod meta_basin; pub mod reach_out; pub mod regulate; +pub mod scent; pub mod stance; pub mod support; pub mod tactic_select; diff --git a/crates/lance-graph-planner/src/nars/scent.rs b/crates/lance-graph-planner/src/nars/scent.rs new file mode 100644 index 000000000..cf69f0cbc --- /dev/null +++ b/crates/lance-graph-planner/src/nars/scent.rs @@ -0,0 +1,524 @@ +//! `scent`: which candidate mediator to investigate next for `A → Y` +//! (D-EPI-COT C3b). +//! +//! An edge at `IndirectUnknown × Causes` says an intervention certified that +//! `A` causes `Y` and that the mechanism is open. [`scent`] ranks candidate +//! mediators `B` by whether one investigation could settle them, reading only +//! the ledger and the sealed chain. It decides nothing: +//! +//! - it never writes an edge, a truth value or a certification; +//! - `pearl::hydrate` alone moves the topology, and only on a sealed chain +//! that holds both `A → B` and `B → Y`; +//! - a score is a reason to look, never evidence. +//! +//! # The ranking +//! +//! A lead is **testable** when exactly one of its two links is missing from +//! the sealed chain and the other is in the chain *and* grounded in the +//! ledger. One observation of the missing link then decides whether `B` +//! hydrates. A lead with both links missing needs two discoveries, and a +//! lead with an unanchored link has no grounded mechanism behind it. +//! +//! Testable leads come first; within each group, higher uncertainty of the +//! missing link first; then mediator id, for determinism. +//! +//! Uncertainty is the binary entropy of the NARS expectation of a link's +//! pooled evidence, and 1 for a link without evidence. On its own it cannot +//! tell a lead worth testing from noise: every lead with no evidence scores +//! the maximum. The D-CE64-STAUNEN-0 probe measured the same failure for raw +//! surprise (0.649 resolved against 0.964 for round-robin). +//! +//! # Settled is not closed +//! +//! A lead is [`LeadStatus::Settled`] when both links are in the chain and both +//! have pooled frequency at least 0.5 (a policy pin). Independent +//! contradicting evidence on a link reopens it as [`LeadStatus::Contested`] +//! without touching the edge: re-examining a mature relation does not demote +//! its certification or topology. +//! +//! Nothing is stored between calls, so repeated calls and replayed +//! observations cannot advance anything (S3, S7). + +use causal_edge::edge::CausalEdge64; +use lance_graph_contract::epistemic_state5::Epi5ReadError; + +use super::belief::CStmt; +use super::support::{Ablation, DerivationLedger, Refusal}; +use super::truth::TruthValue; +use crate::chain_replay::ChainStep; +use crate::pearl::{hydrate, Hydration, Reading}; + +/// Pooled frequency at or above which a link reads as holding. Policy pin. +pub const HOLDS_FREQUENCY: f32 = 0.5; + +/// What one link of `A → B → Y` looks like now. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Link { + /// The link's pooled evidence in the ledger, if any. + pub truth: Option, + /// Whether the sealed chain binds it. + pub in_chain: bool, +} + +impl Link { + /// Binary entropy of the expectation; 1 without evidence. + #[must_use] + pub fn uncertainty(&self) -> f32 { + self.truth.map_or(1.0, |t| binary_entropy(t.expectation())) + } + + fn holds(&self) -> bool { + self.in_chain && self.truth.is_some_and(|t| t.frequency >= HOLDS_FREQUENCY) + } + + fn anchored(&self) -> bool { + self.in_chain && self.truth.is_some() + } +} + +fn binary_entropy(p: f32) -> f32 { + if p <= 0.0 || p >= 1.0 { + return 0.0; + } + -(p * p.log2() + (1.0 - p) * (1.0 - p).log2()) +} + +/// Where a lead stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeadStatus { + /// Both links bound and holding. + Settled, + /// Both links bound, but the evidence on one no longer holds. + Contested, + /// One link missing, the other bound and grounded: one observation decides. + Testable, + /// Neither link anchored, or both missing. + Open, +} + +/// One candidate mediator. +#[derive(Debug, Clone, PartialEq)] +pub struct Lead { + /// The candidate `B`. + pub mediator: u16, + /// `A → B`. + pub first: Link, + /// `B → Y`. + pub second: Link, + /// What `pearl::hydrate` reports for this path. + pub hydration: Hydration, + /// The lead's status. + pub status: LeadStatus, + /// Uncertainty of the link to investigate (the missing or weaker one). + pub uncertainty: f32, +} + +/// Why nothing was ranked. +#[derive(Debug, Clone, PartialEq)] +pub enum ScentRefusal { + /// A term does not fit the edge's 8-bit index. + TermRange(u16), + /// The edge is not `A → Y`. + EdgeMismatch, + /// The ledger refused. + Ledger(Refusal), + /// The edge's bits 59..63 do not project. + Epi5(Epi5ReadError), +} + +fn idx(term: u16) -> Result { + u8::try_from(term).map_err(|_| ScentRefusal::TermRange(term)) +} + +fn rank(status: LeadStatus) -> u8 { + match status { + LeadStatus::Testable => 0, + LeadStatus::Contested => 1, + LeadStatus::Open => 2, + LeadStatus::Settled => 3, + } +} + +/// Rank `candidates` as mediators of `target` (`A → Y`, the relation `edge` +/// carries). Reads only; returns leads, most worth investigating first. +/// +/// # Errors +/// A [`ScentRefusal`]. +pub fn scent( + ledger: &DerivationLedger, + edge: CausalEdge64, + target: CStmt, + candidates: &[u16], + sealed: &[ChainStep], + reading: Reading<'_>, +) -> Result, ScentRefusal> { + let (a, y) = (idx(target.s)?, idx(target.p)?); + if edge.s_idx() != a || edge.o_idx() != y { + return Err(ScentRefusal::EdgeMismatch); + } + let bound = |s: u8, o: u8| sealed.iter().any(|(_, e)| e.s_idx() == s && e.o_idx() == o); + let link = |s: u16, p: u16| -> Result { + let stmt = CStmt { + s, + cop: target.cop, + p, + }; + let truth = ledger + .pooled(stmt, &Ablation::none()) + .map_err(ScentRefusal::Ledger)? + .map(|p| p.truth); + Ok(Link { + truth, + in_chain: bound(idx(s)?, idx(p)?), + }) + }; + + let mut leads = Vec::with_capacity(candidates.len()); + for &m in candidates { + let b = idx(m)?; + let first = link(target.s, m)?; + let second = link(m, target.p)?; + let (_, hydration) = + hydrate(edge, (a, b, y), sealed, reading).map_err(ScentRefusal::Epi5)?; + let status = match (first.in_chain, second.in_chain) { + (true, true) if first.holds() && second.holds() => LeadStatus::Settled, + (true, true) => LeadStatus::Contested, + (true, false) if first.anchored() => LeadStatus::Testable, + (false, true) if second.anchored() => LeadStatus::Testable, + _ => LeadStatus::Open, + }; + let uncertainty = match (first.in_chain, second.in_chain) { + (true, false) => second.uncertainty(), + (false, true) => first.uncertainty(), + _ => first.uncertainty().max(second.uncertainty()), + }; + leads.push(Lead { + mediator: m, + first, + second, + hydration, + status, + uncertainty, + }); + } + leads.sort_by(|x, y| { + rank(x.status) + .cmp(&rank(y.status)) + .then(y.uncertainty.total_cmp(&x.uncertainty)) + .then(x.mediator.cmp(&y.mediator)) + }); + Ok(leads) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nars::belief::Copula; + use causal_edge::edge::InferenceType; + use causal_edge::{CausalMask, PlasticityState}; + use lance_graph_contract::band_reading::EdgeProvenance; + use lance_graph_contract::class_view::ClassId; + use lance_graph_contract::epistemic_state5::{ + Certification3, Epi5Declarations, Epi5Gen, Epi5Reading, EpistemicState5, Topology2, + }; + use lance_graph_contract::rail_geometry::RailAxis; + + const CLASS: ClassId = 0x0902; + const RAIL: RailAxis = RailAxis::Taxonomy; + const A: u16 = 10; + const Y: u16 = 30; + /// The true mediator: `A → B` is known, `B → Y` is not yet. + const TRUE_B: u16 = 20; + /// Noise candidates: nothing known about them. Lower ids, so an + /// uncertainty-only order with an id tie-break visits them first. + const NOISE: [u16; 3] = [11, 12, 13]; + + fn imp(s: u16, p: u16) -> CStmt { + CStmt { + s, + cop: Copula::Impl, + p, + } + } + + fn decls() -> Epi5Declarations { + let mut d = Epi5Declarations::new(); + d.declare( + CLASS, + RAIL, + Epi5Reading { + generation: Epi5Gen::V1, + }, + ); + d + } + + fn reading(d: &Epi5Declarations) -> Reading<'_> { + Reading { + declarations: d, + class: CLASS, + rail: RAIL, + provenance: EdgeProvenance::V2Stamped, + } + } + + fn edge(s: u16, o: u16, t: Topology2, c: Certification3) -> CausalEdge64 { + CausalEdge64::pack( + s as u8, + 0x91, + o as u8, + 200, + 200, + CausalMask::SO, + causal_edge::Orientation::from_code(0b101), + InferenceType::Deduction, + PlasticityState::S_HOT, + 0, + ) + .with_epistemic_raw5(EpistemicState5::new(Epi5Gen::V1, t, c).raw()) + } + + fn step(s: u16, o: u16) -> ChainStep { + ( + 0xFF, + edge(s, o, Topology2::Direct, Certification3::Associated), + ) + } + + fn query() -> CausalEdge64 { + edge(A, Y, Topology2::IndirectUnknown, Certification3::Causes) + } + + fn candidates() -> Vec { + let mut c = NOISE.to_vec(); + c.push(TRUE_B); + c + } + + /// The world the oracle knows: `A → TRUE_B → Y`. The noise candidates + /// have no links. + struct World { + ledger: DerivationLedger, + sealed: Vec, + next_source: u32, + } + + impl World { + fn new() -> Self { + let mut ledger = DerivationLedger::new(); + ledger.observe(imp(A, TRUE_B), TruthValue::new(0.9, 0.9), 1); + World { + ledger, + sealed: vec![step(A, TRUE_B)], + next_source: 2, + } + } + + /// Investigate a lead: observe its first missing link. A link that + /// exists in the world is recorded; one that does not leaves nothing. + fn investigate(&mut self, m: u16) { + let exists = |s: u16, p: u16| (s, p) == (A, TRUE_B) || (s, p) == (TRUE_B, Y); + for (s, p) in [(A, m), (m, Y)] { + if self + .sealed + .iter() + .any(|(_, e)| e.s_idx() == s as u8 && e.o_idx() == p as u8) + { + continue; + } + if exists(s, p) { + self.ledger + .observe(imp(s, p), TruthValue::new(0.9, 0.9), self.next_source); + self.next_source += 1; + self.sealed.push(step(s, p)); + } + return; + } + } + + fn hydrated(&self, d: &Epi5Declarations) -> bool { + hydrate( + query(), + (A as u8, TRUE_B as u8, Y as u8), + &self.sealed, + reading(d), + ) + .unwrap() + .1 == Hydration::Hydrated + } + } + + /// Investigations a policy needs before the true mediator hydrates. A + /// policy picks from the leads it has not investigated yet. + fn investigations(d: &Epi5Declarations, order: impl Fn(&[Lead]) -> u16) -> usize { + let mut w = World::new(); + let mut visited: Vec = Vec::new(); + for n in 1..=10 { + let leads: Vec = scent( + &w.ledger, + query(), + imp(A, Y), + &candidates(), + &w.sealed, + reading(d), + ) + .unwrap() + .into_iter() + .filter(|l| !visited.contains(&l.mediator)) + .collect(); + let pick = order(&leads); + visited.push(pick); + w.investigate(pick); + if w.hydrated(d) { + return n; + } + } + usize::MAX + } + + /// S4. All four candidates have the same uncertainty on the link to + /// investigate (no evidence, 1.0). Only the true mediator has a grounded, + /// chain-bound first link, so only it can be decided by one observation. + /// Scent finds it in one investigation; ordering by uncertainty alone + /// spends three on noise first. + #[test] + fn s4_a_testable_lead_outranks_equally_uncertain_noise() { + let d = decls(); + let w = World::new(); + let leads = scent( + &w.ledger, + query(), + imp(A, Y), + &candidates(), + &w.sealed, + reading(&d), + ) + .unwrap(); + assert!(leads.iter().all(|l| (l.uncertainty - 1.0).abs() < 1e-6)); + assert_eq!(leads[0].mediator, TRUE_B); + assert_eq!(leads[0].status, LeadStatus::Testable); + assert!(leads[1..].iter().all(|l| l.status == LeadStatus::Open)); + + let scented = investigations(&d, |l| l[0].mediator); + let by_uncertainty = investigations(&d, |l| { + let mut v = l.to_vec(); + v.sort_by(|x, y| { + y.uncertainty + .total_cmp(&x.uncertainty) + .then(x.mediator.cmp(&y.mediator)) + }); + v[0].mediator + }); + assert_eq!(scented, 1); + assert_eq!(by_uncertainty, 4); + } + + /// S1/S8. A settled lead (both links bound and holding) reopens as + /// contested when independent evidence contradicts a link. The edge is + /// untouched: scent writes nothing. + #[test] + fn s1_a_settled_lead_reopens_on_independent_contradiction() { + let d = decls(); + let mut l = DerivationLedger::new(); + l.observe(imp(A, TRUE_B), TruthValue::new(0.9, 0.9), 1); + l.observe(imp(TRUE_B, Y), TruthValue::new(0.9, 0.9), 2); + let sealed = vec![step(A, TRUE_B), step(TRUE_B, Y)]; + let known = edge(A, Y, Topology2::IndirectKnown, Certification3::Causes); + let before = scent(&l, known, imp(A, Y), &[TRUE_B], &sealed, reading(&d)).unwrap(); + assert_eq!(before[0].status, LeadStatus::Settled); + + l.observe(imp(TRUE_B, Y), TruthValue::new(0.0, 0.95), 9); + let after = scent(&l, known, imp(A, Y), &[TRUE_B], &sealed, reading(&d)).unwrap(); + assert_eq!(after[0].status, LeadStatus::Contested); + assert!(after[0].second.truth.unwrap().frequency < HOLDS_FREQUENCY); + assert_eq!( + known.epistemic_raw5(), + EpistemicState5::new( + Epi5Gen::V1, + Topology2::IndirectKnown, + Certification3::Causes + ) + .raw() + ); + } + + /// S3. Replaying one source's observation 63 times leaves the lead's + /// uncertainty where one observation puts it. + #[test] + fn s3_replay_does_not_change_the_scent() { + let d = decls(); + let sealed = vec![step(A, TRUE_B)]; + let mut once = DerivationLedger::new(); + once.observe(imp(A, TRUE_B), TruthValue::new(0.9, 0.9), 1); + once.observe(imp(TRUE_B, Y), TruthValue::new(0.7, 0.6), 2); + let mut many = once.clone(); + for _ in 0..63 { + many.observe(imp(TRUE_B, Y), TruthValue::new(0.7, 0.6), 2); + } + let a = scent(&once, query(), imp(A, Y), &[TRUE_B], &sealed, reading(&d)).unwrap(); + let b = scent(&many, query(), imp(A, Y), &[TRUE_B], &sealed, reading(&d)).unwrap(); + assert_eq!(a, b); + assert!(a[0].uncertainty < 1.0); + } + + /// S7. Ranking again changes nothing: no state advances with compute. + #[test] + fn s7_repeated_scenting_advances_nothing() { + let d = decls(); + let w = World::new(); + let first = scent( + &w.ledger, + query(), + imp(A, Y), + &candidates(), + &w.sealed, + reading(&d), + ) + .unwrap(); + for _ in 0..100 { + assert_eq!( + scent( + &w.ledger, + query(), + imp(A, Y), + &candidates(), + &w.sealed, + reading(&d) + ) + .unwrap(), + first + ); + } + } + + /// An unanchored half-link (bound in the chain, no ledger evidence) is + /// not testable: there is no grounded mechanism behind it. + #[test] + fn a_chain_link_without_evidence_is_not_an_anchor() { + let d = decls(); + let l = DerivationLedger::new(); + let sealed = vec![step(A, TRUE_B)]; + let leads = scent(&l, query(), imp(A, Y), &[TRUE_B], &sealed, reading(&d)).unwrap(); + assert_eq!(leads[0].status, LeadStatus::Open); + } + + #[test] + fn the_edge_must_be_the_target_relation() { + let d = decls(); + let l = DerivationLedger::new(); + let other = edge( + A, + TRUE_B, + Topology2::IndirectUnknown, + Certification3::Causes, + ); + assert_eq!( + scent(&l, other, imp(A, Y), &[TRUE_B], &[], reading(&d)), + Err(ScentRefusal::EdgeMismatch) + ); + assert_eq!( + scent(&l, query(), imp(A, Y), &[300], &[], reading(&d)), + Err(ScentRefusal::TermRange(300)) + ); + } +} diff --git a/docs/research/epistemic-cot/c3b-scenting.md b/docs/research/epistemic-cot/c3b-scenting.md new file mode 100644 index 000000000..89182f938 --- /dev/null +++ b/docs/research/epistemic-cot/c3b-scenting.md @@ -0,0 +1,62 @@ +# D-EPI-COT C3b — scenting candidate mediators + +Status: TEST-PINNED on a fixture world. Code: +`crates/lance-graph-planner/src/nars/scent.rs`. Stacked on #1840. Independent +of #1841. + +## Question + +An edge at `IndirectUnknown × Causes` (raw5 22) is certified, but its +mechanism is open. Which candidate mediator `B` should be investigated next? + +## Answer: `nars::scent::scent` + +`scent` reads the #1840 ledger and the sealed chain and ranks the +candidates. It writes no edge, truth value or certification, and it stores +nothing between calls. Only `pearl::hydrate` moves the topology. + +**Lead status.** Each candidate gets one of four statuses. A link "holds" +when it is bound in the sealed chain and its pooled frequency is at least +0.5 (policy pin). + +| status | when | +|---|---| +| `Testable` | one link is missing from the chain, and the other is in the chain and grounded in the ledger. One observation decides whether `B` hydrates. | +| `Contested` | both links are bound, but one link no longer holds. | +| `Open` | neither link is anchored, or both are missing. | +| `Settled` | both links are bound and both hold. | + +**Order.** Leads sort by status in the order above. Within a status, the +link to investigate with the highest uncertainty comes first. Uncertainty is +the binary entropy of the NARS expectation, or 1 for a link with no +evidence. Ties go to the lower mediator id. + +**Why uncertainty alone is not enough.** Every candidate with no evidence +scores the maximum uncertainty. D-CE64-STAUNEN-0 measured the same failure +for raw surprise: 0.649 resolved, against 0.964 for round-robin. + +## Falsifiers + +| amendment item | test | disable that turns it red | +|---|---|---| +| S4: a productive lead beats equally uncertain noise | `s4_a_testable_lead_outranks_equally_uncertain_noise`: scent hydrates in 1 investigation, uncertainty-only in 4 | order by uncertainty only | +| no grounded mechanism, no test | `a_chain_link_without_evidence_is_not_an_anchor` | an anchor that needs only the chain binding | +| S1/S8: receptivity survives maturity | `s1_a_settled_lead_reopens_on_independent_contradiction`: the edge stays `IndirectKnown × Causes` | `Settled` ignores the evidence | +| S3: replay is not evidence | `s3_replay_does_not_change_the_scent` | — (the ledger's root dedup) | +| S7: compute does not mature anything | `s7_repeated_scenting_advances_nothing` | — (stateless by construction) | +| S2: no certification change | structural: `scent` takes the edge by value and returns only leads | — | + +## What this does not show + +- **The 1-vs-4 result comes from one hand-built world** with one true + mediator and three noise candidates. It shows the ranking can separate a + testable lead from noise. It does not measure performance across world + shapes or seeds. The STAUNEN-0 probe, extended with this ranking and a + second world, is the place for that measurement. +- **Maturation (bits 50..52) is not used.** The order for new declared + readings is `7 = Staunen … 0 = Wisdom`. Until the reading, provenance, + migration and the `learn` collision are settled, nothing writes those bits. +- **No learning.** C3c (LUT9 ranking) is separate, after the EXO-152 owner + check. +- **No budget, habituation or learning-progress terms yet.** STAUNEN-0 + showed these matter under scarcity.