diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 43bf9f653..cbeccab43 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -90,7 +90,7 @@ Plan: `.claude/plans/nal-polyfill-v1.md`. Template: D-SQLP. No opcode, tenant or | **D-NALP-0** | Plan, board rows | In PR | docs only | | **D-NALP-1** | Statement layout + declared ClassView reading; additive `WideFieldMask` projection path on `ClassView`; binder's mask; term width decision | Partial: #1525 merged `08635ed` (NodeGuid reading `nal-statement-v1`, term width u16, aperture selection); ClassView wide projection and binder mask Queued | only the mask's lanes are read; undeclared reading and masked-out field refuse; Small = Wide answers | | **D-NALP-2** | Query procedures: select, truth threshold, choice by confidence, support count | Shipped #1525 `08635ed` (`crates/lance-graph-nal`: NodeGuid key, byte apertures; 7 oracle tests, 7 disable runs red; 19–248 ns at 1,048,576 rows); support count and argmax not built | equal to a row-at-a-time oracle; one-pattern threshold disable red; no new opcode | -| **D-NALP-3** | Revision as `GroupCrossPowerSumsI32` over stored `w`/`f` | Queued | equal to exact integer revision; OpenNARS revision points; duplicate evidence counts once | +| **D-NALP-3** | Revision as `GroupCrossPowerSumsI32` over stored `w`/`f` | In PR (`nal::Revision`, key = evidence class from the stamps; refuses overlap and missing provenance; 10 tests, 7 disable runs red) | equal to exact integer revision; OpenNARS revision points; duplicate evidence counts once | | **D-NALP-4** | Per-row truth law into a lane: `MapPairU8` vs fixed-point multiply-high | Queued | insufficiency proof; per-row parity with `isa::truth`, error in codes; one shape also serves SQL nullable arithmetic | | **D-NALP-5** | Deduction / induction / abduction / analogy over populations | Queued (after D-NALP-4) | equals pairwise scalar rule; abduction direction test red for the reversed statement | | **D-NALP-6** | `lance-graph-nal`: typed `nal::{Operation}` surface (`NalOp`), parser (tokens only), binder over the reverse label index, `NAL::narsese()`, typed refusals | Queued | text and typed calls give byte-identical `Program`s; no string in the plan, rows or wire; ambiguous / unknown label refuses; lowers from the bound plan; no fallback; no `Program` outside procedures | diff --git a/.claude/plans/nal-polyfill-v1.md b/.claude/plans/nal-polyfill-v1.md index a27030e08..a6b37e890 100644 --- a/.claude/plans/nal-polyfill-v1.md +++ b/.claude/plans/nal-polyfill-v1.md @@ -340,6 +340,54 @@ Rules that follow, each enforced in W6: - Not yet: the ClassView `WideFieldMask` projection and the binder's mask (rest of D-NALP-1); W3 onwards. +### Status 2026-10-13 (D-NALP-3, revision) + +- **Revision is one fold.** `nal::Revision::bind(bound, statement)` keeps one + mask-risc program: the statement's range, then + `GroupCrossPowerSumsI32 { key = evidence class, x = w, y = f }` into a + four-group sink. The commit law reads `Σw` (`sum_x`) and `Σw·f` (`sum_xy`, + `i128`) of group 0 and rounds once: `f = round(Σw·f / Σw)`, + `c = round(255 · Σw / (Σw + k · 2¹⁶))`, capped at ONA's `MAX_CONFIDENCE` + (0.99, code 252). `k = 1` (ONA `Config.h:168`). **Divergence from ONA, + kept on purpose:** the cap applies to the result only. ONA caps after every + pairwise step, so once its running confidence saturates its answer depends + on arrival order (10 × `(255,230)` then 10 × `(0,230)`: f = 104; reversed: + 151). The fold is order-independent and keeps the exact `Σw` (f = 128); + pinned by `saturated_evidence_is_the_exact_sum_where_onas_fold_depends_on_order` + (codex P2 on #1538). +- **The key is the evidential base, read as a group.** `Beliefs::seal_evidence` + takes `(belief, stamp)` pairs and classes each piece, per statement in sealed + order: 0 independent; 1 exact duplicate (same stamp set and truth as an + earlier independent piece, counted once); 2 overlapping (shares a root + without being a duplicate, or one stamp with two truths); 3 no stamp. Groups + 2 and 3 must be empty or revision refuses (`DependentEvidence`, + `MissingProvenance`). A stamp naming one root twice is refused at seal (ONA + drops such events, `Memory.c:501`), which also refuses the stamp of ONA's + reasoningbench answer #9 (`[10,2,11,2,1]`, c = 0.815686). A population + sealed with `Beliefs::seal` (W2) has no stamps and never revises. +- **§4 B kept, with one change.** The weight is stored at seal, `w = + round(2¹⁶ · c / (255 − c))` from the confidence code; code 255 is refused at + write. The group key is the evidence class, not the statement: the + statement is already the execution extent. +- **No R2IL byte.** The fold band names no cross-moment call, so + `lance-graph-r2il` gains `FoldDialect::with_cross_sums` / + `fold_cross_power_sums`, a dialect method with the standing of `emit_pred`. +- **Oracles.** ONA `Truth_Revision` transcribed from `Truth.c` at `dc4efd0` + (folded pairwise) and the three OpenNARS points of `revision_points.nal`. + Gate held: within one code of ONA on 60 random statements (at least 10 with + three or more pieces, at least one empty) and on the three points; `Σw` and `Σw·f` equal the integer + row sums exactly. Seven disable runs, each red on its named test: no overlap + refusal, overlap classed independent, duplicate classed independent, `w` + scale dropped (4 tests red), no provenance refusal, no repeated-root check, + no confidence cap. +- **Differs from ONA by policy.** ONA's `Inference_RevisionAndChoice` keeps the + stronger of two overlapping beliefs (choice); this fold refuses instead. + Choice on overlap is not built. +- **OPEN.** Revising every statement of a subject or class in one pass needs a + statement-ordinal key and a sink per statement; not built. The `w` lane is a + second reading of `c`; deltas that carry their own weight (D-HL-7) would + remove it. Not measured on the v4 arm yet. + ## 5. Waves | Wave | D-id | Repo | Work | Gate (all must pass) | diff --git a/crates/lance-graph-nal/src/lib.rs b/crates/lance-graph-nal/src/lib.rs index fa9552b54..145b31cc2 100644 --- a/crates/lance-graph-nal/src/lib.rs +++ b/crates/lance-graph-nal/src/lib.rs @@ -49,10 +49,22 @@ //! other kind: declared at compile time through the consumer's `HotPlug` and //! checked against the row's classid. That path is not this crate. //! +//! # Revision and evidential independence (W3) +//! +//! A population sealed with [`Beliefs::seal_evidence`] carries, per belief, +//! its evidence weight `w = c / (1 − c)` as a fixed-point `I32` lane +//! ([`weight`], scale [`WEIGHT_SCALE`], written once) and an evidence class +//! (`U32`) computed across the rows of each statement from the stamps: +//! independent, an exact duplicate of an earlier piece (same stamp, same +//! truth: counted once), overlapping, or without provenance. +//! [`nal::Revision`] is one grouped cross-moment fold keyed by that class; the +//! commit law reads `Σw` and `Σw·f` of the independent group and refuses when +//! the overlapping or unprovenanced group is not empty. +//! //! # Not here yet //! -//! Revision (W3), the per-row truth laws (W4), syllogisms (W5), the Narsese -//! text front end (W6), and an argmax fold (which belief holds the maximum). +//! The per-row truth laws (W4), syllogisms (W5), the Narsese text front end +//! (W6), and an argmax fold (which belief holds the maximum). //! The term width is `u16` per tile: a vocabulary larger than 65,536 terms //! scales through the classid prefix, not by widening the tile. //! @@ -64,11 +76,15 @@ pub mod nal; +pub use nal::Revised; + use lance_graph_contract::canonical_node::NodeGuid; use lance_graph_contract::facet::{FacetCascade, SemanticAperture}; use lance_graph_contract::ordered_lane::{OrderedLaneWitness, SealedFacetLane, WitnessError}; use lance_graph_contract::temporal_pov::LanceVersion; -use lance_graph_mask_risc::{execute_compiled, CompiledProgram, ExecError, LaneRef}; +use std::collections::{HashMap, HashSet}; + +use lance_graph_mask_risc::{execute_compiled, CompiledProgram, ExecError, LaneRef, Out}; /// The NAL copulas this reading stores. NAL-1 and NAL-2 (inheritance, /// similarity) and NAL-5 (implication, equivalence) statements over atomic @@ -192,7 +208,53 @@ impl Aperture { } } -/// A sealed belief population: the ordered NodeGuid lane and the truth lanes +/// Fixed-point scale of the weight lane: a weight of 1 is stored as `2¹⁶`. +pub const WEIGHT_SCALE: i32 = 1 << 16; + +/// The evidential horizon `k` of `c = w / (w + k)`. ONA's +/// `TRUTH_EVIDENTIAL_HORIZON_INITIAL` (`Config.h:168` at `dc4efd0`). +pub const HORIZON: i32 = 1; + +/// The highest confidence code revision answers: `round(255 · 0.99)`, ONA's +/// `MAX_CONFIDENCE` (`Config.h:172`). +pub const MAX_CONFIDENCE_CODE: u8 = 252; + +/// The stored weight of confidence code `c`: `round(WEIGHT_SCALE · c / (255 +/// − c))`, the evidence `w = c / (1 − c)` of `c / 255` at horizon 1. `None` +/// for `c = 255`: a confidence of 1 has no finite weight. +pub fn weight(c: u8) -> Option { + if c == 255 { + return None; + } + let (num, den) = (i64::from(WEIGHT_SCALE) * i64::from(c), 255 - i64::from(c)); + i32::try_from((2 * num + den) / (2 * den)).ok() +} + +/// Evidence classes of the class lane, read as the group key of revision. +pub(crate) mod evidence { + /// Independent of every earlier piece of the statement. + pub const INDEPENDENT: u32 = 0; + /// The same stamp and truth as an earlier independent piece. + pub const DUPLICATE: u32 = 1; + /// Shares a root with an earlier piece without being its duplicate. + pub const OVERLAP: u32 = 2; + /// No stamp. + pub const NO_PROVENANCE: u32 = 3; + /// The group universe. + pub const CLASSES: usize = 4; +} + +/// One piece of evidence: a belief and its stamp, the roots of the evidence it +/// rests on (ONA's evidential base). An empty stamp is no provenance. +#[derive(Debug, Clone, Copy)] +pub struct Evidence<'a> { + /// The belief. + pub belief: Belief, + /// Its evidential base. + pub stamp: &'a [u64], +} + +/// A sealed belief population: the ordered NodeGuid lane and the value lanes /// in the same order. #[derive(Debug, Clone)] pub struct Beliefs { @@ -200,6 +262,10 @@ pub struct Beliefs { lane: SealedFacetLane, pub(crate) f: Vec, pub(crate) c: Vec, + /// Fixed-point evidence weight ([`weight`]); 0 where `c = 255`. + pub(crate) w: Vec, + /// Evidence class per row ([`evidence`]). + pub(crate) evidence: Vec, } impl Beliefs { @@ -216,22 +282,71 @@ impl Beliefs { beliefs: &[Belief], version: LanceVersion, ) -> Result { - let mut rows: Vec<(FacetCascade, i32, i32)> = beliefs + let rows: Vec> = beliefs .iter() - .map(|b| { - ( - guid(class, b.s, b.copula, b.p, b.id).facet(), - i32::from(b.f), - i32::from(b.c), - ) + .map(|&belief| Evidence { belief, stamp: &[] }) + .collect(); + Self::seal_rows(class, &rows, version) + } + + /// Seal `evidence` with its stamps, so the population can be revised. + /// Per statement, in sealed order, each piece is classed independent, + /// duplicate (same stamp set and truth as an earlier independent piece), + /// overlapping, or without provenance (empty stamp). + /// + /// # Errors + /// + /// [`NalError::UnboundedWeight`] for a confidence code of 255; + /// [`NalError::InvalidStamp`] for a stamp naming one root twice (ONA drops + /// such events at input, `Memory.c:501`); [`NalError::Witness`] past + /// `u32::MAX` rows. + pub fn seal_evidence( + class: u32, + evidence: &[Evidence<'_>], + version: LanceVersion, + ) -> Result { + for (i, e) in evidence.iter().enumerate() { + if weight(e.belief.c).is_none() { + return Err(NalError::UnboundedWeight { input: i }); + } + let roots: HashSet = e.stamp.iter().copied().collect(); + if roots.len() != e.stamp.len() { + return Err(NalError::InvalidStamp { input: i }); + } + } + Ok(Self::seal_rows(class, evidence, version)?) + } + + fn seal_rows( + class: u32, + rows: &[Evidence<'_>], + version: LanceVersion, + ) -> Result { + let mut rows: Vec<(FacetCascade, &Evidence<'_>)> = rows + .iter() + .map(|e| { + let b = &e.belief; + (guid(class, b.s, b.copula, b.p, b.id).facet(), e) }) .collect(); rows.sort_by(|a, b| a.0.cmp_numeric_projection(&b.0)); - let f = rows.iter().map(|r| r.1).collect(); - let c = rows.iter().map(|r| r.2).collect(); + let f = rows.iter().map(|r| i32::from(r.1.belief.f)).collect(); + let c = rows.iter().map(|r| i32::from(r.1.belief.c)).collect(); + let w = rows + .iter() + .map(|r| weight(r.1.belief.c).unwrap_or(0)) + .collect(); + let evidence = classify(rows.iter().map(|r| r.1)); let lane = SealedFacetLane::attest_sorted(rows.into_iter().map(|r| r.0).collect(), version)?; - Ok(Beliefs { class, lane, f, c }) + Ok(Beliefs { + class, + lane, + f, + c, + w, + evidence, + }) } /// The statement class. @@ -260,6 +375,42 @@ impl Beliefs { } } +/// The evidence class of each row, in sealed order. Pieces of one statement +/// are contiguous there; a statement's roots are compared only with its own +/// earlier pieces, because revision merges one statement. +fn classify<'e>(rows: impl Iterator>) -> Vec { + let mut out = Vec::new(); + let mut current: Option = None; + let mut roots: HashSet = HashSet::new(); + let mut stamps: HashMap, (u8, u8)> = HashMap::new(); + for e in rows { + let st = e.belief.statement(); + if current != Some(st) { + current = Some(st); + roots.clear(); + stamps.clear(); + } + let class = if e.stamp.is_empty() { + evidence::NO_PROVENANCE + } else { + let mut key = e.stamp.to_vec(); + key.sort_unstable(); + match stamps.get(&key) { + Some(&truth) if truth == (e.belief.f, e.belief.c) => evidence::DUPLICATE, + Some(_) => evidence::OVERLAP, + None if e.stamp.iter().any(|r| roots.contains(r)) => evidence::OVERLAP, + None => { + roots.extend(e.stamp.iter().copied()); + stamps.insert(key, (e.belief.f, e.belief.c)); + evidence::INDEPENDENT + } + } + }; + out.push(class); + } + out +} + /// A belief population whose ordering witness has been validated. #[derive(Debug, Clone, Copy)] pub struct Bound<'a> { @@ -267,8 +418,14 @@ pub struct Bound<'a> { witness: OrderedLaneWitness, } -/// Lane index of `c` in the planes every NAL program runs over (`f` is lane 0). +/// Lane index of `f` in the planes every NAL program runs over. +pub(crate) const LANE_F: u16 = 0; +/// Lane index of `c`. pub(crate) const LANE_C: u16 = 1; +/// Lane index of the weight `w`. +pub(crate) const LANE_W: u16 = 2; +/// Lane index of the evidence class (`U32`). +pub(crate) const LANE_EVIDENCE: u16 = 3; impl Bound<'_> { /// Row count. @@ -300,14 +457,25 @@ impl Bound<'_> { self.witness } - /// Run a kept program over `extent`. + /// The value lanes, in lane-index order ([`LANE_F`] … [`LANE_EVIDENCE`]). + pub(crate) fn lanes(&self) -> [LaneRef<'_>; 4] { + [ + LaneRef::I32(&self.beliefs.f), + LaneRef::I32(&self.beliefs.c), + LaneRef::I32(&self.beliefs.w), + LaneRef::U32(&self.beliefs.evidence), + ] + } + + /// Run a kept program over `extent`, writing into `out`. pub(crate) fn run( &self, program: &CompiledProgram, scratch: &mut lance_graph_mask_risc::Scratch<'_>, extent: std::ops::Range, + out: Out<'_>, ) -> Result { - let lanes = [LaneRef::I32(&self.beliefs.f), LaneRef::I32(&self.beliefs.c)]; + let lanes = self.lanes(); let planes = lance_graph_mask_risc::Planes { n_rows: self.n_rows(), masks: &[], @@ -317,14 +485,7 @@ impl Bound<'_> { planes: &[], lanes: &[], }; - execute_compiled( - &program.compiled(), - &planes, - &foreign, - scratch, - lance_graph_mask_risc::Out::None, - extent, - ) + execute_compiled(&program.compiled(), &planes, &foreign, scratch, out, extent) } } @@ -344,6 +505,27 @@ pub enum NalError { /// A prepared query was run on a population other than the one it was /// bound on: its kept range is in the other population's coordinates. WrongPopulation, + /// Revision met evidence that shares roots with an earlier piece of the + /// statement without being its duplicate; no exact revision exists. + DependentEvidence { + /// Overlapping pieces in the statement. + rows: u64, + }, + /// Revision met evidence without a stamp; its independence is unknown. + MissingProvenance { + /// Unprovenanced pieces in the statement. + rows: u64, + }, + /// Input `input` has confidence code 255: no finite weight. + UnboundedWeight { + /// Index into the sealed input. + input: usize, + }, + /// Input `input` has a stamp naming one root twice. + InvalidStamp { + /// Index into the sealed input. + input: usize, + }, } impl From for NalError { diff --git a/crates/lance-graph-nal/src/nal.rs b/crates/lance-graph-nal/src/nal.rs index 6fdadcd14..624d094e8 100644 --- a/crates/lance-graph-nal/src/nal.rs +++ b/crates/lance-graph-nal/src/nal.rs @@ -8,18 +8,22 @@ //! range. //! //! None of the operations here earns a byte (plan §4 A): `FN` is `None` for -//! all of them. +//! all of them. [`Revision`] folds through a dialect method rather than a +//! byte ([`FoldDialect::fold_cross_power_sums`]). use std::ops::Range; use lance_graph_contract::ordered_lane::OrderedLaneWitness; use lance_graph_mask_risc::{ - CompiledProgram, Foreign, LaneRef, Lowering, Planes, Pred, Program, Scratch, Value, + CompiledProgram, CrossPowerSums, Foreign, Lowering, Out, Planes, Pred, Program, Scratch, Value, }; use lance_graph_r2il::{procs, FoldDialect, FoldError, Val, INT_S_LESS, LOAD, POP_COUNT}; use ogar_loco::{Dialect, FnIndex}; -use crate::{Aperture, Bound, NalError, LANE_C}; +use crate::{ + evidence, Aperture, Bound, NalError, Statement, HORIZON, LANE_C, LANE_EVIDENCE, LANE_F, LANE_W, + MAX_CONFIDENCE_CODE, WEIGHT_SCALE, +}; /// One NAL operation: its byte, if it earned one, and its procedure. pub trait NalOp { @@ -65,7 +69,7 @@ impl Prepared { /// Run `O`'s procedure once over `extent` with `args` on the stack and keep /// the program. fn of(b: &Bound<'_>, args: &[i64], extent: Range) -> Result { - let lanes = [LaneRef::I32(&b.beliefs.f), LaneRef::I32(&b.beliefs.c)]; + let lanes = b.lanes(); let planes = Planes { n_rows: b.n_rows(), masks: &[], @@ -120,7 +124,7 @@ impl Prepared { if b.witness() != self.witness { return Err(NalError::WrongPopulation); } - Ok(b.run(&self.program, scratch, self.extent.clone())?) + Ok(b.run(&self.program, scratch, self.extent.clone(), Out::None)?) } /// Run a counting query. @@ -232,3 +236,171 @@ impl Choice { Prepared::of::(b, &[i64::from(lo), i64::from(hi)], lo as usize..hi as usize) } } + +/// NAL revision of one statement: its independent evidence pooled, `w = Σwᵢ`, +/// `f = Σwᵢ·fᵢ / Σwᵢ`, `c = w / (w + k)`. One grouped cross-moment fold over +/// the statement's range, keyed by the evidence class: group 0 is the +/// independent evidence, the others are checked empty (overlap, no +/// provenance) or reported (duplicates, counted once). +pub struct Revision; + +impl NalOp for Revision { + const FN: Option = None; + const ARITY: (u8, u8) = (2, 0); + /// `lo hi --`, the sink holding the moments. Calls: the range predicate, + /// then [`FoldDialect::fold_cross_power_sums`] with key = evidence class, + /// `x = w`, `y = f`. + fn lower(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError> { + range(d, stack)?; + d.fold_cross_power_sums(LANE_EVIDENCE, LANE_W, LANE_F, stack) + } +} + +/// A revised truth, with the exact sums it was rounded from once. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Revised { + /// Frequency code, `round(Σw·f / Σw)`. + pub f: u8, + /// Confidence code, `round(255 · Σw / (Σw + k·WEIGHT_SCALE))`, capped at + /// [`MAX_CONFIDENCE_CODE`]. + pub c: u8, + /// `Σw` of the independent evidence, at [`WEIGHT_SCALE`]. + pub w: i64, + /// `Σw·f`, `f` in codes. + pub wf: i128, + /// Independent pieces pooled. + pub rows: u64, + /// Exact duplicates seen and counted once. + pub duplicates: u64, +} + +/// `round(a / b)` for `a ≥ 0`, `b > 0`. +fn round_div(a: i128, b: i128) -> i128 { + (2 * a + b) / (2 * b) +} + +/// The commit law over the four evidence groups. Rounds once. +/// +/// This is the exact NAL revision `w = Σwᵢ`, capped only at the end. It +/// matches ONA's pairwise `Truth_Revision` until ONA's running confidence hits +/// its 0.99 cap; past that ONA turns the capped confidence back into a bounded +/// weight, so its answer depends on arrival order (ten `(255, 230)` then ten +/// `(0, 230)` give f = 104, the reverse order 151). A fold over a set cannot +/// have an order, so it keeps the exact sum (f = 128) — a documented +/// divergence, pinned in `tests/w3.rs`. +fn commit(g: &[CrossPowerSums; evidence::CLASSES]) -> Result, NalError> { + let overlap = g[evidence::OVERLAP as usize].n; + if overlap > 0 { + return Err(NalError::DependentEvidence { rows: overlap }); + } + let unprovenanced = g[evidence::NO_PROVENANCE as usize].n; + if unprovenanced > 0 { + return Err(NalError::MissingProvenance { + rows: unprovenanced, + }); + } + let ind = &g[evidence::INDEPENDENT as usize]; + if ind.sum_x <= 0 { + return Ok(None); + } + let w = i128::from(ind.sum_x); + let f = round_div(ind.sum_xy, w); + let c = round_div(255 * w, w + i128::from(HORIZON) * i128::from(WEIGHT_SCALE)); + Ok(Some(Revised { + f: u8::try_from(f).unwrap_or(u8::MAX), + c: u8::try_from(c).unwrap_or(u8::MAX).min(MAX_CONFIDENCE_CODE), + w: ind.sum_x, + wf: ind.sum_xy, + rows: ind.n, + duplicates: g[evidence::DUPLICATE as usize].n, + })) +} + +/// A revision bound to its statement's range, with the program its +/// procedure produced. +#[derive(Debug, Clone)] +pub struct PreparedRevision { + program: CompiledProgram, + extent: Range, + witness: OrderedLaneWitness, +} + +impl Revision { + /// Bind the statement's range and keep the fold. + /// + /// # Errors + /// + /// [`NalError`] if the witness or the procedure is refused. + pub fn bind(b: &Bound<'_>, st: Statement) -> Result { + let (lo, hi) = b.range(Aperture::statement(b.class(), st))?; + let lanes = b.lanes(); + let planes = Planes { + n_rows: b.n_rows(), + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign { + planes: &[], + lanes: &[], + }; + let mut sink = [CrossPowerSums::default(); evidence::CLASSES]; + let mut d = FoldDialect::with_cross_sums(&planes, &foreign, &mut sink); + let mut stack = vec![Val::Scalar(i64::from(lo)), Val::Scalar(i64::from(hi))]; + Self::lower(&mut d, &mut stack)?; + if let Some(e) = d.poison() { + return Err(e.into()); + } + match (stack.is_empty(), d.programs_run(), d.last_program()) { + (true, 1, Some(p)) => Ok(PreparedRevision { + program: CompiledProgram::new(p.clone()), + extent: lo as usize..hi as usize, + witness: b.witness(), + }), + _ => Err(NalError::NoAnswer), + } + } +} + +impl PreparedRevision { + /// The scratch this revision needs. Allocate it once and reuse it. + /// + /// # Errors + /// + /// [`NalError::Exec`] if the program declares more slots than can exist. + pub fn scratch(&self, b: &Bound<'_>) -> Result, NalError> { + Ok(Scratch::for_program(self.program.program(), b.n_rows())?) + } + + /// The kept program. + pub fn program(&self) -> &Program { + self.program.program() + } + + /// Run the fold and commit: the revised truth, `None` without evidence. + /// + /// # Errors + /// + /// [`NalError::WrongPopulation`] on another population; + /// [`NalError::DependentEvidence`] / [`NalError::MissingProvenance`] when + /// the statement holds evidence that cannot be revised exactly; + /// [`NalError::Exec`] if mask-risc refuses the program. + pub fn revise( + &self, + b: &Bound<'_>, + scratch: &mut Scratch<'_>, + ) -> Result, NalError> { + if b.witness() != self.witness { + return Err(NalError::WrongPopulation); + } + let mut sink = [CrossPowerSums::default(); evidence::CLASSES]; + match b.run( + &self.program, + scratch, + self.extent.clone(), + Out::CrossPowerSums(&mut sink), + )? { + Value::GroupCrossPowerSums => commit(&sink), + _ => Err(NalError::NoAnswer), + } + } +} diff --git a/crates/lance-graph-nal/tests/w3.rs b/crates/lance-graph-nal/tests/w3.rs new file mode 100644 index 000000000..a12dd0d89 --- /dev/null +++ b/crates/lance-graph-nal/tests/w3.rs @@ -0,0 +1,442 @@ +//! D-NALP-3: revision as one grouped cross-moment fold over the weight and +//! frequency lanes, checked against an independent scalar oracle (ONA's +//! `Truth_Revision`, transcribed from `src/Truth.c` at +//! `dc4efd0abd520cdb79bf53bfa3c285ebb24f2e8a`) and against the values +//! OpenNARS printed for `revision_points.nal` (`oracles.md`). + +use lance_graph_nal::{ + nal, weight, Belief, Beliefs, Copula, Evidence, NalError, Revised, Statement, HORIZON, + MAX_CONFIDENCE_CODE, WEIGHT_SCALE, +}; + +const CLASS: u32 = 0x0E01_0001; + +// --- the independent oracle ------------------------------------------------- + +/// ONA's truth, in reals. +#[derive(Debug, Clone, Copy)] +struct Truth { + f: f64, + c: f64, +} + +/// ONA `Truth_c2w` with `TRUTH_EVIDENTIAL_HORIZON = 1.0` (`Config.h:168`). +fn c2w(c: f64) -> f64 { + c / (1.0 - c) +} + +/// ONA `Truth_w2c`. +fn w2c(w: f64) -> f64 { + w / (w + 1.0) +} + +/// ONA `Truth_Revision` (`Truth.c:46-75`), `MAX_CONFIDENCE = 0.99` +/// (`Config.h:172`): the pairwise law, folded left over the evidence. +fn ona_revision(a: Truth, b: Truth) -> Truth { + if a.c == 0.0 { + return b; + } + if b.c == 0.0 { + return a; + } + let (w1, w2) = (c2w(a.c), c2w(b.c)); + let w = w1 + w2; + Truth { + f: ((w1 * a.f + w2 * b.f) / w).min(1.0), + c: w2c(w).max(a.c).max(b.c).min(0.99), + } +} + +fn code(x: f64) -> u8 { + (x * 255.0).round() as u8 +} + +fn real(k: u8) -> f64 { + f64::from(k) / 255.0 +} + +/// The oracle answer for evidence given in codes, folded pairwise. +fn oracle(evidence: &[(u8, u8)]) -> Option<(u8, u8)> { + let mut it = evidence.iter().filter(|e| e.1 > 0); + let first = it.next()?; + let t = it.fold( + Truth { + f: real(first.0), + c: real(first.1), + }, + |acc, &(f, c)| { + ona_revision( + acc, + Truth { + f: real(f), + c: real(c), + }, + ) + }, + ); + Some((code(t.f), code(t.c))) +} + +// --- fixtures --------------------------------------------------------------- + +fn st(s: u16) -> Statement { + Statement { + s, + copula: Copula::Inheritance, + p: s + 100, + } +} + +fn belief(s: u16, id: u16, f: f64, c: f64) -> Belief { + let t = st(s); + Belief { + s: t.s, + copula: t.copula, + p: t.p, + id, + f: code(f), + c: code(c), + } +} + +fn revise(beliefs: &Beliefs, s: Statement) -> Result, NalError> { + let b = beliefs.bind(&beliefs.witness()).unwrap(); + let r = nal::Revision::bind(&b, s)?; + r.revise(&b, &mut r.scratch(&b).unwrap()) +} + +fn within_one(got: u8, want: u8) -> bool { + got.abs_diff(want) <= 1 +} + +// --- agreement -------------------------------------------------------------- + +/// The three pairs of `revision_points.nal` (` b1>.` is `(1.0, 0.9)`). +/// `(f, c)` in reals. +type Fc = (f64, f64); + +/// Two inputs and the revised truth OpenNARS printed. +const POINTS: [(Fc, Fc, Fc); 3] = [ + ((1.0, 0.9), (0.1, 0.6), (0.87, 0.91)), + ((0.8, 0.5), (0.3, 0.7), (0.45, 0.77)), + ((0.0, 0.9), (1.0, 0.9), (0.50, 0.95)), +]; + +/// FAILS IF: the compiled revision disagrees with ONA's pairwise law by more +/// than one code, or with the truth OpenNARS printed (two decimals) by more +/// than the printing plus one code. +#[test] +fn revision_agrees_with_ona_and_opennars() { + let roots = [[1u64], [2], [3], [4], [5], [6]]; + let mut evidence = Vec::new(); + for (i, &(a, b, _)) in POINTS.iter().enumerate() { + let s = i as u16; + evidence.push(Evidence { + belief: belief(s, 0, a.0, a.1), + stamp: &roots[2 * i], + }); + evidence.push(Evidence { + belief: belief(s, 1, b.0, b.1), + stamp: &roots[2 * i + 1], + }); + } + let beliefs = Beliefs::seal_evidence(CLASS, &evidence, 3).unwrap(); + for (i, &(a, b, printed)) in POINTS.iter().enumerate() { + let got = revise(&beliefs, st(i as u16)).unwrap().unwrap(); + let input = [(code(a.0), code(a.1)), (code(b.0), code(b.1))]; + let (of, oc) = oracle(&input).unwrap(); + assert!(within_one(got.f, of), "point {i}: f {} vs ona {of}", got.f); + assert!(within_one(got.c, oc), "point {i}: c {} vs ona {oc}", got.c); + let tol = 0.005 + 1.0 / 255.0; + assert!((real(got.f) - printed.0).abs() <= tol, "point {i}: {got:?}"); + assert!((real(got.c) - printed.1).abs() <= tol, "point {i}: {got:?}"); + assert_eq!(got.rows, 2); + assert_eq!(got.duplicates, 0); + } +} + +/// SplitMix64. +fn rng(seed: u64) -> impl FnMut() -> u64 { + let mut x = seed; + move || { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +/// FAILS IF: on any statement the fold's sums differ from the exact integer +/// sums of the stored weights (`Σw`, `Σw·f`), or its truth differs from ONA's +/// pairwise law by more than one code. +/// +/// The agreement with ONA holds on this fixture; where ONA's running +/// confidence saturates mid-fold the two laws part, see +/// `saturated_evidence_is_the_exact_sum_where_onas_fold_depends_on_order`. +#[test] +fn revision_is_the_exact_integer_sum_and_agrees_on_every_statement() { + let mut next = rng(0xC0FF_EE00); + let n_stmt = 60u16; + let mut rows: Vec<(Belief, [u64; 2])> = Vec::new(); + let mut root = 1u64; + for s in 0..n_stmt { + // 0..=5 pieces of evidence per statement; statement 7 has none. + let k = if s == 7 { 0 } else { (next() % 6) as u16 }; + for id in 0..k { + let r = next(); + let mut b = belief(s, id, 0.0, 0.0); + b.f = (r >> 8) as u8; + b.c = ((r >> 16) % 255) as u8; // never 255 + rows.push((b, [root, root + 1])); + root += 2; + } + } + let evidence: Vec> = rows + .iter() + .map(|(b, stamp)| Evidence { belief: *b, stamp }) + .collect(); + let beliefs = Beliefs::seal_evidence(CLASS, &evidence, 9).unwrap(); + let (mut multi, mut none) = (0, 0); + for s in 0..n_stmt { + let mine: Vec<&Belief> = rows.iter().map(|r| &r.0).filter(|b| b.s == s).collect(); + let got = revise(&beliefs, st(s)).unwrap(); + let w: i64 = mine.iter().map(|b| i64::from(weight(b.c).unwrap())).sum(); + let wf: i128 = mine + .iter() + .map(|b| i128::from(weight(b.c).unwrap()) * i128::from(b.f)) + .sum(); + let want = oracle(&mine.iter().map(|b| (b.f, b.c)).collect::>()); + match (got, want) { + (Some(g), Some((of, oc))) => { + assert_eq!(g.w, w, "s{s}"); + assert_eq!(g.wf, wf, "s{s}"); + assert_eq!(g.rows, mine.len() as u64, "s{s}"); + assert!(within_one(g.f, of), "s{s}: f {} vs {of}", g.f); + assert!(within_one(g.c, oc), "s{s}: c {} vs {oc}", g.c); + multi += usize::from(mine.len() >= 3); + } + (None, None) => none += 1, + other => panic!("s{s}: {other:?}"), + } + } + // Anti-vacuity: statements with three or more pieces, and empty ones. + assert!(multi >= 10 && none >= 1, "multi {multi}, none {none}"); +} + +/// FAILS IF: confidence is not capped at ONA's `MAX_CONFIDENCE` (0.99). +#[test] +fn confidence_is_capped_where_ona_caps_it() { + let roots: Vec<[u64; 1]> = (1..=20).map(|r| [r]).collect(); + let evidence: Vec> = roots + .iter() + .enumerate() + .map(|(i, stamp)| Evidence { + belief: belief(0, i as u16, 1.0, 0.9), + stamp, + }) + .collect(); + let beliefs = Beliefs::seal_evidence(CLASS, &evidence, 1).unwrap(); + let got = revise(&beliefs, st(0)).unwrap().unwrap(); + assert_eq!(got.c, MAX_CONFIDENCE_CODE); + assert_eq!( + oracle(&[(255, code(0.9)); 20]).unwrap().1, + MAX_CONFIDENCE_CODE + ); + // Non-vacuity: uncapped, twenty pieces at 0.9 give a confidence above it. + let w = 20.0 * c2w(real(code(0.9))); + assert!(code(w2c(w)) > MAX_CONFIDENCE_CODE); + assert_eq!((WEIGHT_SCALE, HORIZON), (1 << 16, 1)); +} + +/// Ten positive and ten negative pieces at `c = 230`, each with its own root. +fn mixed(positive_first: bool) -> Vec<(u8, u8)> { + let (pos, neg) = ([(255u8, 230u8); 10], [(0u8, 230u8); 10]); + if positive_first { + pos.iter().chain(neg.iter()).copied().collect() + } else { + neg.iter().chain(pos.iter()).copied().collect() + } +} + +/// A documented divergence, not an agreement. Once the running confidence +/// reaches ONA's 0.99 cap, ONA's pairwise law turns the capped confidence back +/// into a bounded weight for the next step, so its answer depends on the order +/// the evidence arrives in. The fold sums every weight once and caps only the +/// result, so it is order-independent: the exact NAL revision `w = Σwᵢ`. +/// +/// FAILS IF: the fold depends on the sealed order, stops being the exact +/// weighted mean, or ONA's pairwise fold stops disagreeing with itself on this +/// input (then the fixture no longer reaches saturation and pins nothing). +#[test] +fn saturated_evidence_is_the_exact_sum_where_onas_fold_depends_on_order() { + let roots: Vec<[u64; 1]> = (1..=20).map(|r| [r]).collect(); + let mut answers = Vec::new(); + for positive_first in [true, false] { + let evidence: Vec> = mixed(positive_first) + .iter() + .zip(&roots) + .enumerate() + .map(|(i, (&(f, c), stamp))| Evidence { + belief: Belief { + f, + c, + ..belief(0, i as u16, 0.0, 0.0) + }, + stamp, + }) + .collect(); + let beliefs = Beliefs::seal_evidence(CLASS, &evidence, 1).unwrap(); + let got = revise(&beliefs, st(0)).unwrap().unwrap(); + answers.push((got.f, got.c)); + } + // Order-independent, and the weighted mean of equal weights at 255 and 0. + assert_eq!(answers[0], answers[1]); + assert_eq!(answers[0], (128, MAX_CONFIDENCE_CODE)); + // ONA's pairwise fold disagrees with itself on the same multiset. + assert_eq!(oracle(&mixed(true)), Some((104, MAX_CONFIDENCE_CODE))); + assert_eq!(oracle(&mixed(false)), Some((151, MAX_CONFIDENCE_CODE))); +} + +// --- evidential independence ------------------------------------------------ + +/// The backup's counterexample: stamp 10 on statement 0 twice. +fn repeated() -> [(Belief, &'static [u64]); 3] { + [ + (belief(0, 0, 1.0, 0.9), &[10]), + (belief(0, 1, 0.1, 0.6), &[11]), + (belief(0, 2, 1.0, 0.9), &[10]), + ] +} + +fn seal(rows: &[(Belief, &[u64])]) -> Result { + let ev: Vec> = rows + .iter() + .map(|&(belief, stamp)| Evidence { belief, stamp }) + .collect(); + Beliefs::seal_evidence(CLASS, &ev, 2) +} + +/// FAILS IF: the same evidence (same stamp, same truth) is counted twice. +/// Counting it twice would give c = 0.951, not OpenNARS's 0.91. +#[test] +fn an_exact_duplicate_counts_once() { + let rows = repeated(); + let got = revise(&seal(&rows).unwrap(), st(0)).unwrap().unwrap(); + let once = revise(&seal(&rows[..2]).unwrap(), st(0)).unwrap().unwrap(); + assert_eq!(got.duplicates, 1); + assert_eq!(got.rows, 2); + assert_eq!( + (got.f, got.c, got.w, got.wf), + (once.f, once.c, once.w, once.wf) + ); + // Non-vacuity: summing all three rows inflates the confidence. + let inflated = oracle(&[(255, code(0.9)), (code(0.1), code(0.6)), (255, code(0.9))]) + .unwrap() + .1; + assert!(inflated >= got.c + 8, "{inflated} vs {}", got.c); +} + +/// FAILS IF: partially overlapping evidence, or one stamp carrying two truth +/// values, is revised instead of refused. Neither has an exact revision. +#[test] +fn overlapping_evidence_is_refused() { + let partial = [ + (belief(0, 0, 1.0, 0.9), &[1u64, 2][..]), + (belief(0, 1, 0.1, 0.6), &[2, 3][..]), + ]; + assert_eq!( + revise(&seal(&partial).unwrap(), st(0)), + Err(NalError::DependentEvidence { rows: 1 }) + ); + let disagreeing = [ + (belief(0, 0, 1.0, 0.9), &[7u64][..]), + (belief(0, 1, 0.2, 0.9), &[7][..]), + ]; + assert_eq!( + revise(&seal(&disagreeing).unwrap(), st(0)), + Err(NalError::DependentEvidence { rows: 1 }) + ); + // The same stamp on two DIFFERENT statements is not overlap. + let across = [ + (belief(0, 0, 1.0, 0.9), &[5u64][..]), + (belief(1, 0, 0.1, 0.6), &[5][..]), + ]; + let b = seal(&across).unwrap(); + assert!(revise(&b, st(0)).unwrap().is_some()); + assert!(revise(&b, st(1)).unwrap().is_some()); +} + +/// FAILS IF: evidence without provenance is revised. A population sealed +/// without stamps answers the W2 queries but refuses revision. +#[test] +fn evidence_without_provenance_is_refused() { + let plain = Beliefs::seal(CLASS, &[belief(0, 0, 1.0, 0.9), belief(0, 1, 0.1, 0.6)], 1).unwrap(); + assert_eq!( + revise(&plain, st(0)), + Err(NalError::MissingProvenance { rows: 2 }) + ); + let mixed = [ + (belief(0, 0, 1.0, 0.9), &[1u64][..]), + (belief(0, 1, 0.1, 0.6), &[][..]), + ]; + assert_eq!( + revise(&seal(&mixed).unwrap(), st(0)), + Err(NalError::MissingProvenance { rows: 1 }) + ); +} + +/// FAILS IF: a stamp carrying one root twice is accepted. ONA's +/// reasoningbench answer #9 (`c = 0.815686`, stamp `[10,2,11,2,1]`, +/// `oracles.md`) carries root 2 twice; ONA itself drops such events at input +/// (`Memory.c:501`, `Stamp_hasDuplicate`). +#[test] +fn a_stamp_with_a_repeated_root_is_refused_at_seal() { + let rows = [(belief(0, 0, 1.0, 0.815686), &[10u64, 2, 11, 2, 1][..])]; + assert!(matches!(seal(&rows), Err(NalError::InvalidStamp { .. }))); + let rows = [(belief(0, 0, 1.0, 0.815686), &[10u64, 2, 11, 1][..])]; + assert!(seal(&rows).is_ok()); +} + +/// FAILS IF: a confidence of 1 (unbounded weight) is stored instead of refused. +#[test] +fn unbounded_weight_is_refused_at_write() { + let mut b = belief(0, 0, 1.0, 0.9); + b.c = 255; + assert!(matches!( + seal(&[(b, &[1u64][..])]), + Err(NalError::UnboundedWeight { .. }) + )); + assert_eq!(weight(255), None); + assert_eq!(weight(254), Some(254 * WEIGHT_SCALE)); + assert_eq!(weight(0), Some(0)); +} + +/// FAILS IF: no evidence answers a truth. An absent statement and one with +/// only zero-confidence evidence both answer `None`. +#[test] +fn no_evidence_answers_none() { + let rows = [(belief(0, 0, 1.0, 0.0), &[1u64][..])]; + let b = seal(&rows).unwrap(); + assert_eq!(revise(&b, st(0)), Ok(None)); + assert_eq!(revise(&b, st(5)), Ok(None)); +} + +/// FAILS IF: a revision bound on one population runs on another. +#[test] +fn a_prepared_revision_refuses_another_population() { + let a = seal(&repeated()).unwrap(); + // Same size, different keys: the second belief of statement 0 moves to + // statement 1, so the kept range names other rows there. + let mut other = repeated(); + other[1].0 = belief(1, 1, 0.1, 0.6); + let b = seal(&other).unwrap(); + assert_eq!(a.witness().n_rows(), b.witness().n_rows()); + let (ba, bb) = (a.bind(&a.witness()).unwrap(), b.bind(&b.witness()).unwrap()); + let r = nal::Revision::bind(&ba, st(0)).unwrap(); + assert!(matches!( + r.revise(&bb, &mut r.scratch(&bb).unwrap()), + Err(NalError::WrongPopulation) + )); + assert!(r.revise(&ba, &mut r.scratch(&ba).unwrap()).is_ok()); +} diff --git a/crates/lance-graph-r2il/src/lib.rs b/crates/lance-graph-r2il/src/lib.rs index 767d0d554..0ed1ed4ca 100644 --- a/crates/lance-graph-r2il/src/lib.rs +++ b/crates/lance-graph-r2il/src/lib.rs @@ -101,8 +101,9 @@ pub mod procs; use std::cell::Cell; use lance_graph_mask_risc::{ - execute_into, fuse, scratch_words_for, tile_words_for, BoolExpr, Foreign, FuseError, LaneKind, - MaskOp, Operand, Out, Planes, Pred, Program, Scratch, Terminal, Value, + execute_into, fuse, scratch_words_for, tile_words_for, BoolExpr, CrossPowerSums, Foreign, + FuseError, GroupKey, LaneKind, MaskOp, Operand, Out, Planes, Pred, Program, Scratch, Terminal, + Value, }; use ogar_loco::{Dialect, FnIndex}; use ogar_r2il::{GROUP_SUM, KEEP, MAX, MIN, R2IL_BASE, RANGE, SUM, VIA}; @@ -248,6 +249,7 @@ enum OutKind { None, Group, Keep, + CrossSums, } /// Interprets one loco body, building [`MaskOp`]s and RUNNING them at every @@ -290,6 +292,10 @@ pub struct FoldDialect<'p> { /// demanded result when a body asks for the mask itself. `None` unless /// the dialect was built by [`Self::with_keep`]. keep_sink: Option<&'p mut [u64]>, + /// Caller-owned sink for [`FoldDialect::fold_cross_power_sums`], one + /// [`CrossPowerSums`] per group; empty unless the dialect was built by + /// [`Self::with_cross_sums`]. Its length is the group universe K. + cross_sink: &'p mut [CrossPowerSums], /// The last fold program this dialect finalized and ran, kept so a test /// can compare it physically against `quack::lower`'s. Moved in after /// execution, never cloned. @@ -331,6 +337,7 @@ impl<'p> FoldDialect<'p> { scratch: vec![0u64; cap], group_sink: &mut [], keep_sink: None, + cross_sink: &mut [], last_program: None, facade_passes: 0, programs_run: 0, @@ -366,6 +373,58 @@ impl<'p> FoldDialect<'p> { d } + /// [`Self::new`], writing [`Self::fold_cross_power_sums`] results into the + /// caller's `sink`. Its length is the group universe K; the dialect + /// allocates nothing for it. + pub fn with_cross_sums( + planes: &'p Planes<'p>, + foreign: &'p Foreign<'p>, + sink: &'p mut [CrossPowerSums], + ) -> Self { + let mut d = Self::new(planes, foreign); + d.cross_sink = sink; + d + } + + /// Grouped cross moments of two `I32` lanes over the mask on top of the + /// stack: pops the mask, folds `(lanes[x][i], lanes[y][i])` into group + /// `lanes[key][i]` of the lent sink as `n, Σx, Σy, Σx², Σy², Σxy` + /// (mask-risc `Terminal::GroupCrossPowerSumsI32`, keyed by a `U32` lane). + /// Pushes nothing; the sink is the result, like `GROUP_SUM`. + /// + /// Not an R2IL byte: the fold band names no cross-moment call. Like + /// [`Self::emit_pred`] it is a dialect method a procedure calls directly. + /// + /// # Errors + /// + /// [`FoldError::Underflow`] / [`FoldError::WrongOperandKind`] if the top + /// of the stack is not a mask, [`FoldError::StaleSlot`] for a slot of an + /// earlier fold, [`FoldError::Exec`] if mask-risc refuses the program (a + /// wrong lane kind, an empty sink). + pub fn fold_cross_power_sums( + &mut self, + key: u16, + x: u16, + y: u16, + stack: &mut Vec, + ) -> Result<(), FoldError> { + let m = fold_mask(stack.pop().ok_or(FoldError::Underflow(GROUP_SUM))?) + .ok_or(FoldError::WrongOperandKind(GROUP_SUM))?; + let mask = self.resolve_mask(m)?; + let terminal = Terminal::GroupCrossPowerSumsI32 { + mask, + key: GroupKey::Lane(key), + x, + y, + }; + match self.finalize_and_run(terminal, OutKind::CrossSums)? { + Value::GroupCrossPowerSums => Ok(()), + other => { + panic!("cross-moment terminal shape guarantees GroupCrossPowerSums, got {other:?}") + } + } + } + /// The population-branch poison flag, if [`Dialect::truthy`] ever set /// one during the run — read AFTER `Interpreter::run()` returns. pub fn poison(&self) -> Option { @@ -565,6 +624,7 @@ impl<'p> FoldDialect<'p> { OutKind::None => Out::None, OutKind::Group => Out::I64(self.group_sink), OutKind::Keep => Out::Mask(self.keep_sink.as_deref_mut().ok_or(FoldError::NoKeepSink)?), + OutKind::CrossSums => Out::CrossPowerSums(self.cross_sink), }; let value = execute_into(&program, self.planes, self.foreign, &mut scratch, out) .map_err(FoldError::Exec); diff --git a/crates/lance-graph-r2il/tests/dialect.rs b/crates/lance-graph-r2il/tests/dialect.rs index 79a815220..87a24ddfb 100644 --- a/crates/lance-graph-r2il/tests/dialect.rs +++ b/crates/lance-graph-r2il/tests/dialect.rs @@ -235,6 +235,55 @@ fn keep_writes_the_selection_into_the_lent_sink() { assert_eq!(kept, (3..70).collect::>()); } +/// FAILS IF: the cross-moment fold stops matching a row loop per group, folds +/// rows outside the mask, or drops a group. +#[test] +fn cross_power_sums_match_the_row_loop_per_group() { + use lance_graph_mask_risc::CrossPowerSums; + let xs = lane(); + let ys: Vec = (0..N).map(|i| (i % 7) as i32 - 3).collect(); + let keys: Vec = (0..N).map(|i| (i % 3) as u32).collect(); + let lanes = [LaneRef::I32(&xs), LaneRef::I32(&ys), LaneRef::U32(&keys)]; + let planes = Planes { + n_rows: N, + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign::NONE; + let mut sink = [CrossPowerSums::default(); 3]; + { + let mut d = FoldDialect::with_cross_sums(&planes, &foreign, &mut sink); + let mut stack = vec![]; + call(&mut d, RANGE, [40, 200, 0], &mut stack).expect("RANGE"); + d.fold_cross_power_sums(2, 0, 1, &mut stack).expect("folds"); + assert!(stack.is_empty(), "the sink is the result"); + assert_eq!(d.programs_run(), 1); + } + for (g, got) in sink.iter().enumerate() { + let rows = (40..200).filter(|&i| keys[i] as usize == g); + let (mut n, mut sx, mut sy, mut sxy) = (0u64, 0i64, 0i64, 0i128); + for i in rows { + n += 1; + sx += i64::from(xs[i]); + sy += i64::from(ys[i]); + sxy += i128::from(xs[i]) * i128::from(ys[i]); + } + assert_eq!( + (got.n, got.sum_x, got.sum_y, got.sum_xy), + (n, sx, sy, sxy), + "group {g}" + ); + assert!(n > 0); + } + // A scalar where the mask belongs is refused, not folded. + let mut sink = [CrossPowerSums::default(); 3]; + let mut d = FoldDialect::with_cross_sums(&planes, &foreign, &mut sink); + assert_eq!( + d.fold_cross_power_sums(2, 0, 1, &mut vec![Val::Scalar(1)]), + Err(FoldError::WrongOperandKind(GROUP_SUM)) + ); +} + /// FAILS IF: the scratch buffer starts growing with the row count. It is /// sized by the tile, so 1,000 and 1,048,576 rows get the same buffer once /// both exceed one tile's worth only through the tile cap. diff --git a/docs/architecture/ONA_NAL_R2IL_FOLD_METHOD_ASSIMILATION.md b/docs/architecture/ONA_NAL_R2IL_FOLD_METHOD_ASSIMILATION.md index 182af1c23..b233a500e 100644 --- a/docs/architecture/ONA_NAL_R2IL_FOLD_METHOD_ASSIMILATION.md +++ b/docs/architecture/ONA_NAL_R2IL_FOLD_METHOD_ASSIMILATION.md @@ -23,7 +23,7 @@ This extends, rather than reverses, the middle-ground ruling: | Surface | Verified evidence as of this research | Must not be overclaimed | |---|---|---| -| [`lance-graph-nal/src/lib.rs`](../../crates/lance-graph-nal/src/lib.rs) | `NodeGuid` belief identity, separate `f` and `c` value lanes, ordered facet witness, `Aperture` interval selection, prepared execution through mask-risc | Does **not** currently provide revision, row-wise truth laws, syllogisms, Narsese parser, executable goal operations | +| [`lance-graph-nal/src/lib.rs`](../../crates/lance-graph-nal/src/lib.rs) | `NodeGuid` belief identity, separate `f` and `c` value lanes, ordered facet witness, `Aperture` interval selection, prepared execution through mask-risc | Does **not** currently provide row-wise truth laws, syllogisms, Narsese parser, executable goal operations. Revision: W3 in #1538 (below) | | [`lance-graph-nal/src/nal.rs`](../../crates/lance-graph-nal/src/nal.rs) | `NalOp` trait, `Question` count, `TruthAtLeast` count, `Choice` highest-confidence value; `Prepared::of` demands a *scalar* at one fold | `Choice` is **max value**, not argmax identity; intermediate mask/range result shapes are not exposed as kept NAL outputs | | [`lance-graph-r2il/src/lib.rs`](../../crates/lance-graph-r2il/src/lib.rs) | `FoldDialect` with `Val::Scalar / Slot / Address`, typed error/refusal semantics, fused/in-place masks, fold-boundary compilation to mask-risc; `SUM`, `MIN`, `MAX`, `GROUP_SUM`, `KEEP` and other calls | Not a whole-chain normalizer; `KEY_RUNS`, `ANY`, `ALL`, `SCATTER_OR`, `BLEND` are explicitly refused in the current dialect. No row-sized data on Loco stack | | [`lance-graph-r2il/src/procs.rs`](../../crates/lance-graph-r2il/src/procs.rs) | Named procedures, no duplicated opcode allocation, calls lower through the existing dialect | `PROC_DEF`/`PROC_CALL` with real stack frames remain an OGAR ABI work item | @@ -104,7 +104,7 @@ Each row is a **separate coding PR** with raw engine fixtures pinned and indepen | Wave | First executable feature | Required dependencies | Completion / negative controls | |---|---|---|---| -| **W3** | `Revision` over grouped independent beliefs, evidence weight + `f,c` | `lance-graph-nal` trait + `r2il::procs` + existing grouped moments; no new global opcode | ONA 2-premise and N-premise cases; overlapping stamps rejected/choice-only; zero/one confidence and overflow; exact independent row oracle | +| **W3** | `Revision` over grouped independent beliefs, evidence weight + `f,c` | `lance-graph-nal` trait + `r2il::procs` + existing grouped moments; no new global opcode | ONA 2-premise and N-premise cases; overlapping stamps rejected/choice-only; zero/one confidence and overflow; exact independent row oracle
**Status 2026-10-13: in PR #1538.** `Beliefs::seal_evidence` + `nal::Revision`: one `GroupCrossPowerSumsI32` keyed by an evidence class from the stamps (independent / exact duplicate counted once / overlap / no stamp). Overlap and missing provenance are refused, not choice; ONA's choice-on-overlap is not built. Within one code of ONA `Truth_Revision` (transcribed at `dc4efd0`) and of the three OpenNARS points, up to ONA's mid-fold saturation: past it ONA's pairwise cap makes the answer depend on arrival order, and the fold keeps the exact `Σw` instead (documented divergence, pinned by a test); `Σw`, `Σw·f` exact; c = 1 refused at write; seven disable runs red. The result carries sums, counts and duplicates, not the stamps themselves (§6 item 4 partly open) | | **W4** | Per-row `Deduction`, `Induction`, `Abduction`, `Analogy` truth producing output lanes | Generic value-producing terminal (reuse/extend one shared terminal if measurements demand), portable SIMD/scalar parity | Exact oracle per rule, quantization boundaries, empty/masked rows, no wrong-sense premise swap | | **W5** | `Inference::derive` and `Revise` over matched premise pairs | Typed term unifier, bind-time ClassView/ReadingId, existing Quack/mask-risc joins + witnessed output | ONA NAL fixtures, independent premises, duplicate/cyclic/self-reference, multi-edge, source-generation mismatch | | **W6** | `Narsese` text cold parser and declared/method frontends onto the **same** typed ops | Existing parser precedent; no text in hot loop | One query in typed/Narsese form yields byte-identical prepared result or explicit unsupported-form refusal |