diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 143814371..9c3ae2192 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -53,8 +53,8 @@ Plan: `.claude/plans/nal-polyfill-v1.md`. Template: D-SQLP. No opcode, tenant or | D-id | scope | status | gate / falsifier | |---|---|---|---| | **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 | 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 | Queued | equal to a row-at-a-time oracle; one-pattern threshold disable red; no new opcode | +| **D-NALP-1** | Statement layout + declared ClassView reading; additive `WideFieldMask` projection path on `ClassView`; binder's mask; term width decision | In PR #1525 (NodeGuid reading `nal-statement-v1`, term width u16, aperture selection; ClassView wide projection and binder mask still 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 | In PR #1525 (`crates/lance-graph-nal`: NodeGuid key, byte apertures; 7 oracle tests, 7 disable runs red; 19–248 ns at 1,048,576 rows) | 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-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 | diff --git a/.claude/board/entries/2026-10-12-nal-coordinate-queries.md b/.claude/board/entries/2026-10-12-nal-coordinate-queries.md new file mode 100644 index 000000000..44812221f --- /dev/null +++ b/.claude/board/entries/2026-10-12-nal-coordinate-queries.md @@ -0,0 +1,27 @@ +# 2026-10-12 — NAL query operations as coordinate ranges (D-NALP-1/2) + +**Status:** In PR (#1525). Plan: `.claude/plans/nal-polyfill-v1.md` (status note before § 5). + +**Decision (operator, 2026-10-12):** the NodeGuid is always the address and the +distinguished name; a selection is the 16-byte key minus an `n`-byte aperture. +A belief's key carries only its name (classid, S, copula, P, id); its truth +value lives in value lanes in the sealed order. + +**Finding (MEASURED):** a statement, a subject or a whole class is an aperture +of 10, 6 or 4 cared bytes and one witnessed range +(`SealedFacetLane::bound_aperture`). The typed `nal::` operations bind once +(R2IL procedure → kept mask-risc program) and each call runs only that program +over the range as its execution extent. At 1,048,576 beliefs: range counts +19–20 ns, a confidence threshold over a 245-row subject 127 ns, the maximum +confidence 248 ns, against 495–559 µs for the row loop +(`crates/lance-graph-nal/examples/nal_cost.rs`, AVX-512, release). + +**Tests:** `crates/lance-graph-nal/tests/w2.rs`, 7 row-oracle tests over the +unsorted input; 7 disable runs red (statement aperture too narrow, aperture +one byte short, threshold off by one, threshold without its extent, choice +reading `f`, truth lanes misaligned with keys, witness not validated). + +**OPEN:** R2IL `RANGE` takes `u8` immediates and cannot name a range past row +255 (request to `ogar-r2il`); no argmax fold (which belief holds the maximum); +the rest of D-NALP-1 (ClassView `WideFieldMask` projection, binder mask) and +W3+ are queued. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 1718bc9c5..7cb1c22f4 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. -324 entries, 2026-08-06 .. 2026-10-12. +325 entries, 2026-08-06 .. 2026-10-12. | date | entry id | finding | file | |---|---|---|---| @@ -36,6 +36,7 @@ exactly one of them, never both. | 2026-10-12 | `D-CMH-G1-6` | | [2026-10-12-ordered-conditions-and-extrema.md](2026-10-12-ordered-conditions-and-extrema.md) | | 2026-10-12 | `opencog-pln-entropy-research-map` | Source inventory and falsifiable PLN/ECAN/Shannon/Moore attention comparison: entropy target, evidence overlap, graph projection, resource fairness | [2026-10-12-opencog-pln-entropy-research-map.md](2026-10-12-opencog-pln-entropy-research-map.md) | | 2026-10-12 | `nars-self-evidence-literature` | NARS internal trace versus evidence, Jøsang dependent/independent fusion and fission, and the snapshot-stable fold assumption; source-grounded red tests for merged #1496 | [2026-10-12-nars-self-evidence-literature.md](2026-10-12-nars-self-evidence-literature.md) | +| 2026-10-12 | `D-NALP-1` | | [2026-10-12-nal-coordinate-queries.md](2026-10-12-nal-coordinate-queries.md) | | 2026-10-12 | `D-SAG-CF-1` | counterfactual dependency = factual W0 vs detached W_X; RFC + Boolean smoke oracle (F0/F7/F8 unverified) | [2026-10-12-inverted-temporal-ablation.md](2026-10-12-inverted-temporal-ablation.md) | | 2026-10-12 | `D-IAM-SAG-0` | IAM categories as read-only projections over dir_sim/OGAR/SAG; observed != inferred != authorized | [2026-10-12-iam-substrate-sag-dir-sim.md](2026-10-12-iam-substrate-sag-dir-sim.md) | | 2026-10-12 | `hyperon-metta-lg-translation` | Hyperon/MeTTa to LG sibling map: atom/hypergraph roundtrip, binding and rewrite parity, typed folds, scoped effects and 15 open questions | [2026-10-12-hyperon-metta-lg-translation.md](2026-10-12-hyperon-metta-lg-translation.md) | diff --git a/.claude/plans/nal-polyfill-v1.md b/.claude/plans/nal-polyfill-v1.md index a15bfbbc9..a27030e08 100644 --- a/.claude/plans/nal-polyfill-v1.md +++ b/.claude/plans/nal-polyfill-v1.md @@ -296,6 +296,50 @@ Rules that follow, each enforced in W6: - **`PROC_DEF` execution.** The same OPEN item as D-SQLP: until it exists, procedures are inlined call sequences. +### Status 2026-10-12 (D-NALP-1/2, `crates/lance-graph-nal`) + +- **NAL operations are global core methods** (operator, 2026-10-12: "NARS + ist global"). They run over any belief population, check no classid, and + need no hot-plug and no minted concept; they read the one canonical reading + `nal-statement-v1`. Classid-dependent methods are the other kind: declared at + compile time through a consumer's `HotPlug` and checked against the row's + classid (the D-CMH path, not this crate). This removes W8's concept-id mint + request; W8 keeps only the bytes W4/W5 earn. +- **The NodeGuid is the address and the distinguished name** (operator, + 2026-10-12). Reading `nal-statement-v1`: semantic tiles `[classid hi, + classid lo, S, copula, P, id, 0, 0]`. The key names the belief and nothing + else; `f` and `c` are value lanes written in the key's sealed order, so key + row `i` and truth row `i` are one belief by construction. Truth is never + part of a name. +- **A selection is the 16-byte key minus an aperture** (operator, + 2026-10-12): `Aperture { key, bytes }` cares the leading `bytes` bytes and + frees the rest. On the witnessed HHTL order (classid, then each tile coarse + to fine) the matches are one range, found by the contract's + `SealedFacetLane::bound_aperture`: 4 bytes = the class, 6 = a subject, 10 = + a statement. This replaces the §4 A lowering column for the belief query: + it is a range, not a lane predicate. +- **Term width (OPEN above): decided `u16` per tile.** A vocabulary past + 65,536 terms scales through the classid prefix, not by widening a tile. +- **Bind once, run the kept program.** `nal::{Question, TruthAtLeast, Choice}` + run their R2IL procedure once at bind and keep the mask-risc `Program`; a + call runs only `execute_compiled`. The aperture's range is the execution + extent, so the confidence predicate and the `MAX` fold read only its rows + (a range passed as an ANDed mask instead cost 195 µs at 1,048,576 rows; + as the extent, 127 ns). +- **Measured** (`examples/nal_cost.rs`, release, AVX-512, median of 101, + answers asserted first), n = 1,048,576: range counts 19–20 ns; confidence + threshold over one statement 81 ns, over a 245-row subject 127 ns; choice + (max confidence) over the subject 248 ns; bind 274 ns; the row loop + 495–559 µs. +- **OPEN, found here:** R2IL `RANGE` (arity 0) takes its bounds from two `u8` + immediates, so through the byte it cannot name a range past row 255. The + procedures emit the bound with `FoldDialect::emit_pred(Pred::Range)`. An + `ogar-r2il` request, not minted here. +- **OPEN:** which belief holds the maximum (argmax) is not a mask-risc fold; + `Choice` answers the maximum confidence only. +- Not yet: the ClassView `WideFieldMask` projection and the binder's mask + (rest of D-NALP-1); W3 onwards. + ## 5. Waves | Wave | D-id | Repo | Work | Gate (all must pass) | diff --git a/Cargo.toml b/Cargo.toml index d18234f7e..2b04104c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,10 @@ exclude = [ # the OGAR sibling. Verify via # `cargo test --manifest-path crates/lance-graph-r2il/Cargo.toml`. "crates/lance-graph-r2il", + # NAL over the fold path (D-NALP-1/2): path-deps lance-graph-r2il, so it + # needs the OGAR checkout too. Own workspace root; verify via + # `cargo test --manifest-path crates/lance-graph-nal/Cargo.toml`. + "crates/lance-graph-nal", "crates/symbiont", # OGAR (Open Graph of Active Record) activation crate — re-exports OGAR's full # AR surface (ogar-vocab Class/codebook + ogar-class-view impl ClassView + diff --git a/crates/lance-graph-mask-risc/src/ir.rs b/crates/lance-graph-mask-risc/src/ir.rs index 069c45750..aa977c39b 100644 --- a/crates/lance-graph-mask-risc/src/ir.rs +++ b/crates/lance-graph-mask-risc/src/ir.rs @@ -1416,6 +1416,45 @@ impl<'p> Compiled<'p> { } } +/// A [`Program`] owned together with its recognised lowering. +/// +/// [`Compiled`] borrows its program, so a caller that keeps a prepared query +/// (bound once, run many times) cannot keep a `Compiled` beside the program +/// it borrows from. This type holds both; [`CompiledProgram::compiled`] hands +/// out a `Compiled` without recognising the lowering again. The program is +/// private, so it cannot change after the lowering was recognised. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledProgram { + program: Program, + lowering: Lowering, +} + +impl CompiledProgram { + /// Recognise `program`'s lowering once and keep both. + pub fn new(program: Program) -> Self { + let lowering = program.lowering(); + CompiledProgram { program, lowering } + } + + /// The program. + pub fn program(&self) -> &Program { + &self.program + } + + /// The lowering recognised at construction. + pub fn lowering(&self) -> Lowering { + self.lowering + } + + /// The compiled view for [`crate::exec::execute_compiled`]; no recognition. + pub fn compiled(&self) -> Compiled<'_> { + Compiled { + program: &self.program, + lowering: self.lowering, + } + } +} + /// A program the executor folds without writing membership bits: see /// [`Program::fused_terminal`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/lance-graph-mask-risc/src/lib.rs b/crates/lance-graph-mask-risc/src/lib.rs index bf90bb0bf..7fd7d3e96 100644 --- a/crates/lance-graph-mask-risc/src/lib.rs +++ b/crates/lance-graph-mask-risc/src/lib.rs @@ -127,10 +127,10 @@ pub use exec::{ }; pub use fuse::{fuse, fuse_program, ternlog_imm, BoolExpr, FuseError, Fused}; pub use ir::{ - touched_words, Compiled, Foreign, ForeignPlane, FusedFold, FusedKeep, FusedTerminal, - FusedTern2, FusedTern3, FusedTernlog, GroupFold, GroupKey, LaneRef, Lowering, MaskOp, Operand, - Planes, Pred, Program, StridedRef, Terminal, Tern2Fold, Tern3In, Tern3Step, FUSED_SLOT_CAP, - GROUP_SUM_SYM_MAX_ROWS, MASKED_SUM_I32_MAX_ROWS, MAX_SCRATCH_SLOTS, + touched_words, Compiled, CompiledProgram, Foreign, ForeignPlane, FusedFold, FusedKeep, + FusedTerminal, FusedTern2, FusedTern3, FusedTernlog, GroupFold, GroupKey, LaneRef, Lowering, + MaskOp, Operand, Planes, Pred, Program, StridedRef, Terminal, Tern2Fold, Tern3In, Tern3Step, + FUSED_SLOT_CAP, GROUP_SUM_SYM_MAX_ROWS, MASKED_SUM_I32_MAX_ROWS, MAX_SCRATCH_SLOTS, }; pub use reference::{ reference_execute, reference_execute_into, reference_scratch, reference_scratch_with_foreign, diff --git a/crates/lance-graph-mask-risc/tests/compiled_program.rs b/crates/lance-graph-mask-risc/tests/compiled_program.rs new file mode 100644 index 000000000..52c28327a --- /dev/null +++ b/crates/lance-graph-mask-risc/tests/compiled_program.rs @@ -0,0 +1,52 @@ +//! `CompiledProgram`: a program owned with its recognised lowering. + +use lance_graph_mask_risc::{ + execute_compiled, CompiledProgram, Foreign, Lowering, MaskOp, Operand, Out, Planes, Pred, + Program, Scratch, Terminal, Value, +}; + +fn range_count(lo: u32, hi: u32) -> Program { + Program::new( + vec![MaskOp::Pred { + pred: Pred::Range { lo, hi }, + under: None, + dst: 0, + }], + Terminal::Count { + mask: Operand::Scratch(0), + }, + ) +} + +/// FAILS IF: the owned lowering differs from the one the program recognises, +/// or the compiled view does not answer like `Program::compile`. +#[test] +fn the_owned_lowering_is_the_recognised_one_and_answers_alike() { + let p = range_count(3, 70); + let owned = CompiledProgram::new(p.clone()); + assert_eq!(owned.lowering(), p.lowering()); + assert!(matches!(owned.lowering(), Lowering::Range(_))); + assert_eq!(owned.program(), &p); + let planes = Planes { + n_rows: 100, + masks: &[], + lanes: &[], + }; + let foreign = Foreign { + planes: &[], + lanes: &[], + }; + let mut s = Scratch::for_program(&p, 100).unwrap(); + let a = execute_compiled( + &owned.compiled(), + &planes, + &foreign, + &mut s, + Out::None, + 0..100, + ) + .unwrap(); + let b = execute_compiled(&p.compile(), &planes, &foreign, &mut s, Out::None, 0..100).unwrap(); + assert_eq!(a, b); + assert_eq!(a, Value::Count(67)); +} diff --git a/crates/lance-graph-nal/Cargo.toml b/crates/lance-graph-nal/Cargo.toml new file mode 100644 index 000000000..040fb3501 --- /dev/null +++ b/crates/lance-graph-nal/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lance-graph-nal" +version = "0.1.0" +edition = "2021" +publish = false +description = "NAL over the fold path: Narsese statements as sealed facet coordinates, NAL operations as R2IL procedures compiled once into mask-risc programs (D-NALP-1/2)." + +# Its OWN workspace root, like `lance-graph-r2il`: it path-deps the r2il crate, +# which path-deps the OGAR sibling. Verify via +# `cargo test --manifest-path crates/lance-graph-nal/Cargo.toml`. +[workspace] + +[dependencies] +# The coordinate machinery: FacetCascade keys, the sealed ordered lane and its witness. +lance-graph-contract = { path = "../lance-graph-contract" } +# The only bulk engine. +lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } +# The procedure executor: NAL operations are R2IL call sequences over it. +lance-graph-r2il = { path = "../lance-graph-r2il" } +ogar-loco = { path = "../../../OGAR/crates/ogar-loco" } + +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../lance-graph-contract" } + +[patch."https://github.com/AdaWorldAPI/ndarray"] +ndarray = { path = "../../../ndarray" } diff --git a/crates/lance-graph-nal/examples/nal_cost.rs b/crates/lance-graph-nal/examples/nal_cost.rs new file mode 100644 index 000000000..ced0bd81d --- /dev/null +++ b/crates/lance-graph-nal/examples/nal_cost.rs @@ -0,0 +1,147 @@ +//! Per-call cost of the bound NAL operations against a scalar row loop. +//! +//! `cargo run --release --manifest-path crates/lance-graph-nal/Cargo.toml --example nal_cost` +//! +//! Every answer is asserted equal to the row loop before anything is timed. + +use std::hint::black_box; +use std::time::Instant; + +use lance_graph_nal::{nal, Aperture, Belief, Beliefs, Copula}; + +const CLASS: u32 = 0x0E01_0001; + +fn fixture(n: usize) -> Vec { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + (0..n) + .map(|i| { + 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); + let r = z ^ (z >> 31); + Belief { + s: (r % 4096) as u16, + copula: Copula::ALL[((r >> 12) % 4) as usize], + p: ((r >> 16) % 4096) as u16, + id: i as u16, + f: (r >> 32) as u8, + c: (r >> 40) as u8, + } + }) + .collect() +} + +/// Median nanoseconds per call over 101 batches of `reps` calls. +fn median_ns(reps: u32, mut f: impl FnMut() -> usize) -> f64 { + let mut v: Vec = (0..101) + .map(|_| { + let t = Instant::now(); + for _ in 0..reps { + black_box(f()); + } + t.elapsed().as_nanos() as f64 / f64::from(reps) + }) + .collect(); + v.sort_by(f64::total_cmp); + v[50] +} + +fn main() { + let simd = if cfg!(target_feature = "avx512f") { + "avx512f" + } else if cfg!(target_feature = "avx2") { + "avx2" + } else { + "baseline" + }; + println!("target: {simd}"); + for n in [65_536usize, 1_048_576] { + let input = fixture(n); + let beliefs = Beliefs::seal(CLASS, &input, 1).unwrap(); + let b = beliefs.bind(&beliefs.witness()).unwrap(); + let st = input[n / 2].statement(); + let s = st.s; + let ast = Aperture::statement(CLASS, st); + let asub = Aperture::subject(CLASS, s); + + let q = nal::Question::bind(&b, ast).unwrap(); + let mut qs = q.scratch(&b).unwrap(); + let t = nal::TruthAtLeast::bind(&b, ast, 128).unwrap(); + let mut ts = t.scratch(&b).unwrap(); + let sup = nal::Question::bind(&b, asub).unwrap(); + let mut ss = sup.scratch(&b).unwrap(); + let across = nal::TruthAtLeast::bind(&b, asub, 128).unwrap(); + let mut asx = across.scratch(&b).unwrap(); + let ch = nal::Choice::bind(&b, asub).unwrap(); + let mut cs = ch.scratch(&b).unwrap(); + + let same = |x: &Belief| x.statement() == st; + assert_eq!( + q.count(&b, &mut qs).unwrap(), + input.iter().filter(|x| same(x)).count() + ); + assert_eq!( + t.count(&b, &mut ts).unwrap(), + input.iter().filter(|x| same(x) && x.c >= 128).count() + ); + assert_eq!( + sup.count(&b, &mut ss).unwrap(), + input.iter().filter(|x| x.s == s).count() + ); + assert_eq!( + across.count(&b, &mut asx).unwrap(), + input.iter().filter(|x| x.s == s && x.c >= 128).count() + ); + assert_eq!( + ch.max(&b, &mut cs).unwrap(), + input + .iter() + .filter(|x| x.s == s) + .map(|x| i32::from(x.c)) + .max() + ); + + println!( + "n = {n} (subject support = {})", + sup.count(&b, &mut ss).unwrap() + ); + let r = |name: &str, ns: f64| println!(" {name:<34} {ns:>12.1} ns"); + r( + "bind Question (search + procedure)", + median_ns(100, || { + nal::Question::bind(&b, ast).unwrap().program().ops.len() + }), + ); + r( + "Question::count (statement)", + median_ns(10_000, || q.count(&b, &mut qs).unwrap()), + ); + r( + "TruthAtLeast::count (statement)", + median_ns(10_000, || t.count(&b, &mut ts).unwrap()), + ); + r( + "Question::count (subject)", + median_ns(10_000, || sup.count(&b, &mut ss).unwrap()), + ); + r( + "TruthAtLeast::count (subject)", + median_ns(1_000, || across.count(&b, &mut asx).unwrap()), + ); + r( + "Choice::max (subject)", + median_ns(1_000, || ch.max(&b, &mut cs).unwrap().unwrap_or(0) as usize), + ); + r( + "row loop: count statement", + median_ns(3, || input.iter().filter(|x| same(x)).count()), + ); + r( + "row loop: subject with c >= 128", + median_ns(3, || { + input.iter().filter(|x| x.s == s && x.c >= 128).count() + }), + ); + } +} diff --git a/crates/lance-graph-nal/src/lib.rs b/crates/lance-graph-nal/src/lib.rs new file mode 100644 index 000000000..fa9552b54 --- /dev/null +++ b/crates/lance-graph-nal/src/lib.rs @@ -0,0 +1,365 @@ +//! `lance-graph-nal` — NAL over the fold path (D-NALP-1/2, +//! `.claude/plans/nal-polyfill-v1.md`). +//! +//! # The NodeGuid is the address and the distinguished name +//! +//! A belief's key is its [`NodeGuid`]: 16 bytes, `classid(4) | 6 × (8:8)`, read +//! here as the reading `nal-statement-v1`, +//! +//! ```text +//! semantic tiles: [classid hi, classid lo, S, copula, P, id, 0, 0] +//! ``` +//! +//! The key names the belief and nothing else. Its truth value is not part of +//! its name: `f` and `c` are value lanes beside the key, in the key's sealed +//! order. +//! +//! # A selection is the key minus an aperture +//! +//! A query names its rows with an [`Aperture`]: a key and a count `n` of +//! leading bytes that must match; the remaining `16 − n` bytes are free. On the +//! sealed, witnessed order (HHTL Cartesian: classid, then each tile coarse to +//! fine) the matches are one contiguous range, which +//! [`SealedFacetLane::bound_aperture`] finds with two `partition_point`s. +//! +//! - `n = 10`: one statement `` (classid 4 + S 2 + copula 2 + P 2); +//! - `n = 6`: every belief about subject `S`; +//! - `n = 4`: the whole statement class. +//! +//! A query runs over its range as the execution extent; a lane predicate (a +//! confidence threshold) reads only the rows of that range. +//! +//! # Operations are procedures, compiled once +//! +//! Each [`nal`] operation is an R2IL procedure ([`nal::NalOp::lower`]) over +//! [`lance_graph_r2il::FoldDialect`]. Binding a query runs that procedure once +//! and keeps the mask-risc [`Program`](lance_graph_mask_risc::Program) it produced; every later call runs the +//! kept program through [`execute_compiled`]. A call does not parse, lower or +//! allocate. +//! +//! # Global core methods, not class methods +//! +//! NARS is global (operator, 2026-10-12). The NAL operations are **global core +//! methods**: they run over any belief population and check no classid, need +//! no hot-plug and no minted concept. The classid in a key is only the prefix +//! that addresses the population. They read one canonical reading, +//! `nal-statement-v1`, the same for every class. +//! +//! Methods that depend on a classid (a method of one business class) are the +//! other kind: declared at compile time through the consumer's `HotPlug` and +//! checked against the row's classid. That path is not this crate. +//! +//! # 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 term width is `u16` per tile: a vocabulary larger than 65,536 terms +//! scales through the classid prefix, not by widening the tile. +//! +//! OPEN: R2IL `RANGE` takes its bounds from two `u8` immediates (`ogar-r2il` +//! arity 0), so it cannot name a range past row 255. The procedures emit the +//! bound through [`lance_graph_r2il::FoldDialect::emit_pred`] instead. + +#![forbid(unsafe_code)] + +pub mod nal; + +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}; + +/// The NAL copulas this reading stores. NAL-1 and NAL-2 (inheritance, +/// similarity) and NAL-5 (implication, equivalence) statements over atomic +/// terms; compound terms are out of scope (ledger R10). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(u16)] +pub enum Copula { + /// `-->` + Inheritance = 1, + /// `<->` + Similarity = 2, + /// `==>` + Implication = 3, + /// `<=>` + Equivalence = 4, +} + +impl Copula { + /// Every copula, in code order. + pub const ALL: [Copula; 4] = [ + Copula::Inheritance, + Copula::Similarity, + Copula::Implication, + Copula::Equivalence, + ]; +} + +/// A statement ``: two term addresses and a copula. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Statement { + /// Subject term address. + pub s: u16, + /// Copula. + pub copula: Copula, + /// Predicate term address. + pub p: u16, +} + +/// One belief: its name (statement and identity) and its truth value in `u8` +/// codes (`f`, `c` scaled to `0..=255`, the codes `causal_edge::isa::truth` +/// reads). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Belief { + /// Subject term address. + pub s: u16, + /// Copula. + pub copula: Copula, + /// Predicate term address. + pub p: u16, + /// Identity of this belief among the beliefs of its statement. + pub id: u16, + /// Frequency code. + pub f: u8, + /// Confidence code. + pub c: u8, +} + +impl Belief { + /// The statement this belief is about. + pub fn statement(&self) -> Statement { + Statement { + s: self.s, + copula: self.copula, + p: self.p, + } + } +} + +/// The NodeGuid of a belief of class `class`. +pub fn guid(class: u32, s: u16, copula: Copula, p: u16, id: u16) -> NodeGuid { + FacetCascade::from_semantic_tiles([ + (class >> 16) as u16, + class as u16, + s, + copula as u16, + p, + id, + 0, + 0, + ]) + .into() +} + +/// A selection: `key` with only its first `bytes` bytes (in semantic order: +/// classid, then tiles coarse to fine) cared, the rest free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aperture { + /// The key the cared bytes are read from. + pub key: NodeGuid, + /// Leading bytes that must match, `0..=16`. + pub bytes: u8, +} + +impl Aperture { + /// One statement of class `class` (10 bytes cared). + pub fn statement(class: u32, st: Statement) -> Self { + Aperture { + key: guid(class, st.s, st.copula, st.p, 0), + bytes: 10, + } + } + + /// Every belief about subject `s` in class `class` (6 bytes cared). + pub fn subject(class: u32, s: u16) -> Self { + Aperture { + key: guid(class, s, Copula::Inheritance, 0, 0), + bytes: 6, + } + } + + /// The contract's aperture: care = the leading `bytes` bytes. + pub fn semantic(self) -> SemanticAperture { + let bits = 8 * u32::from(self.bytes.min(16)); + let care: u128 = if bits == 0 { + 0 + } else { + u128::MAX << (128 - bits) + }; + let tiles = core::array::from_fn(|i| (care >> (112 - 16 * i)) as u16); + SemanticAperture::new(self.key.facet(), FacetCascade::from_semantic_tiles(tiles)) + } +} + +/// A sealed belief population: the ordered NodeGuid lane and the truth lanes +/// in the same order. +#[derive(Debug, Clone)] +pub struct Beliefs { + class: u32, + lane: SealedFacetLane, + pub(crate) f: Vec, + pub(crate) c: Vec, +} + +impl Beliefs { + /// Seal `beliefs` under the statement class `class` at `version`. The + /// beliefs are sorted by key here, once, and the truth lanes are written in + /// that order, so key row `i` and truth row `i` are one belief by + /// construction. + /// + /// # Errors + /// + /// [`WitnessError::TooManyRows`] past `u32::MAX` rows. + pub fn seal( + class: u32, + beliefs: &[Belief], + version: LanceVersion, + ) -> Result { + let mut rows: Vec<(FacetCascade, i32, i32)> = beliefs + .iter() + .map(|b| { + ( + guid(class, b.s, b.copula, b.p, b.id).facet(), + i32::from(b.f), + i32::from(b.c), + ) + }) + .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 lane = + SealedFacetLane::attest_sorted(rows.into_iter().map(|r| r.0).collect(), version)?; + Ok(Beliefs { class, lane, f, c }) + } + + /// The statement class. + pub fn class(&self) -> u32 { + self.class + } + + /// The witness the lane was sealed with. A planner holds it and hands it + /// back to [`Beliefs::bind`]. + pub fn witness(&self) -> OrderedLaneWitness { + self.lane.witness() + } + + /// Validate `witness` against this lane (O(1)) and return the bound view + /// the operations run over. + /// + /// # Errors + /// + /// [`NalError::Witness`] when the witness is from another seal. + pub fn bind(&self, witness: &OrderedLaneWitness) -> Result, NalError> { + self.lane.validate(witness)?; + Ok(Bound { + beliefs: self, + witness: *witness, + }) + } +} + +/// A belief population whose ordering witness has been validated. +#[derive(Debug, Clone, Copy)] +pub struct Bound<'a> { + pub(crate) beliefs: &'a Beliefs, + witness: OrderedLaneWitness, +} + +/// Lane index of `c` in the planes every NAL program runs over (`f` is lane 0). +pub(crate) const LANE_C: u16 = 1; + +impl Bound<'_> { + /// Row count. + pub fn n_rows(&self) -> usize { + self.beliefs.c.len() + } + + /// The statement class. + pub fn class(&self) -> u32 { + self.beliefs.class + } + + /// The rows `[lo, hi)` an aperture selects. + /// + /// # Errors + /// + /// [`NalError::Witness`] if the witness no longer validates; + /// [`NalError::NotAnInterval`] if the aperture's care has a hole (it + /// cannot: [`Aperture`] cares a leading run of bytes). + pub fn range(&self, a: Aperture) -> Result<(u32, u32), NalError> { + self.beliefs + .lane + .bound_aperture(&self.witness, &a.semantic())? + .ok_or(NalError::NotAnInterval) + } + + /// The witness this view was bound with. + pub(crate) fn witness(&self) -> OrderedLaneWitness { + self.witness + } + + /// Run a kept program over `extent`. + pub(crate) fn run( + &self, + program: &CompiledProgram, + scratch: &mut lance_graph_mask_risc::Scratch<'_>, + extent: std::ops::Range, + ) -> Result { + let lanes = [LaneRef::I32(&self.beliefs.f), LaneRef::I32(&self.beliefs.c)]; + let planes = lance_graph_mask_risc::Planes { + n_rows: self.n_rows(), + masks: &[], + lanes: &lanes, + }; + let foreign = lance_graph_mask_risc::Foreign { + planes: &[], + lanes: &[], + }; + execute_compiled( + &program.compiled(), + &planes, + &foreign, + scratch, + lance_graph_mask_risc::Out::None, + extent, + ) + } +} + +/// Why a NAL operation did not answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NalError { + /// The ordering witness does not belong to this lane. + Witness(WitnessError), + /// The aperture is not one interval of the sealed order. + NotAnInterval, + /// The procedure was refused while binding. + Fold(lance_graph_r2il::FoldError), + /// mask-risc refused the kept program. + Exec(ExecError), + /// The procedure ended without the one scalar it promised. + NoAnswer, + /// 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, +} + +impl From for NalError { + fn from(e: WitnessError) -> Self { + NalError::Witness(e) + } +} + +impl From for NalError { + fn from(e: lance_graph_r2il::FoldError) -> Self { + NalError::Fold(e) + } +} + +impl From for NalError { + fn from(e: ExecError) -> Self { + NalError::Exec(e) + } +} diff --git a/crates/lance-graph-nal/src/nal.rs b/crates/lance-graph-nal/src/nal.rs new file mode 100644 index 000000000..6fdadcd14 --- /dev/null +++ b/crates/lance-graph-nal/src/nal.rs @@ -0,0 +1,234 @@ +//! The typed NAL surface: `nal::{Operation}::bind(…)` (plan §4 G). +//! +//! Each operation is a zero-sized type. [`NalOp::lower`] is its procedure: the +//! R2IL calls it emits through [`FoldDialect`], reading its scalars off the +//! loco stack. `bind` resolves the [`Aperture`] to its range on the witnessed +//! order, runs the procedure ONCE and keeps the mask-risc program it produced +//! as a [`Prepared`]; every call after that runs the kept program over the +//! range. +//! +//! None of the operations here earns a byte (plan §4 A): `FN` is `None` for +//! all of them. + +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, +}; +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}; + +/// One NAL operation: its byte, if it earned one, and its procedure. +pub trait NalOp { + /// The `ogar-nal` byte when the operation earned one, else `None`: the + /// operation is a procedure over existing R2IL calls. + const FN: Option; + /// Scalars popped, scalars pushed. + const ARITY: (u8, u8); + /// Emit the operation's calls. Never loops over rows. + fn lower(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError>; +} + +fn pop_i64(stack: &mut Vec) -> Result { + match stack.pop() { + Some(Val::Scalar(v)) => Ok(v), + Some(_) => Err(FoldError::WrongOperandKind(POP_COUNT)), + None => Err(FoldError::Underflow(POP_COUNT)), + } +} + +fn pop_u32(stack: &mut Vec) -> Result { + u32::try_from(pop_i64(stack)?).map_err(|_| FoldError::WrongOperandKind(POP_COUNT)) +} + +/// `lo hi --`, pushing the mask of rows `[lo, hi)`. +fn range(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError> { + let hi = pop_u32(stack)?; + let lo = pop_u32(stack)?; + d.emit_pred(Pred::Range { lo, hi }, stack); + Ok(()) +} + +/// A query bound to its range, with the program its procedure produced. +#[derive(Debug, Clone)] +pub struct Prepared { + program: CompiledProgram, + extent: Range, + /// The population the range belongs to; a run on any other is refused. + witness: OrderedLaneWitness, +} + +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 planes = Planes { + n_rows: b.n_rows(), + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign { + planes: &[], + lanes: &[], + }; + let mut d = FoldDialect::new(&planes, &foreign); + let mut stack: Vec = args.iter().map(|&v| Val::Scalar(v)).collect(); + O::lower(&mut d, &mut stack)?; + if let Some(e) = d.poison() { + return Err(e.into()); + } + match (stack.as_slice(), d.programs_run(), d.last_program()) { + ([Val::Scalar(_)], 1, Some(p)) => Ok(Prepared { + program: CompiledProgram::new(p.clone()), + extent, + witness: b.witness(), + }), + _ => Err(NalError::NoAnswer), + } + } + + /// The scratch this query needs: none for a range fold, one tile per slot + /// otherwise. Allocate it once and reuse it across calls. + /// + /// # 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())?) + } + + /// How mask-risc folds this query. + pub fn lowering(&self) -> Lowering { + self.program.lowering() + } + + /// The kept program. + pub fn program(&self) -> &Program { + self.program.program() + } + + /// The rows the program runs over. + pub fn extent(&self) -> Range { + self.extent.clone() + } + + fn value(&self, b: &Bound<'_>, scratch: &mut Scratch<'_>) -> Result { + if b.witness() != self.witness { + return Err(NalError::WrongPopulation); + } + Ok(b.run(&self.program, scratch, self.extent.clone())?) + } + + /// Run a counting query. + /// + /// # Errors + /// + /// [`NalError::WrongPopulation`] if `b` is not the population the query was + /// bound on, [`NalError::Exec`] if mask-risc refuses the program, or + /// [`NalError::NoAnswer`] if the query does not count. + pub fn count(&self, b: &Bound<'_>, scratch: &mut Scratch<'_>) -> Result { + match self.value(b, scratch)? { + Value::Count(n) => Ok(n), + _ => Err(NalError::NoAnswer), + } + } + + /// Run a maximum query; `None` for an empty selection. + /// + /// # Errors + /// + /// As [`Prepared::count`]. + pub fn max(&self, b: &Bound<'_>, scratch: &mut Scratch<'_>) -> Result, NalError> { + match self.value(b, scratch)? { + Value::OptI32(v) => Ok(v), + _ => Err(NalError::NoAnswer), + } + } +} + +/// How many beliefs an aperture selects: `?` with a statement +/// aperture, the support of `S` with a subject aperture. +pub struct Question; + +impl NalOp for Question { + const FN: Option = None; + const ARITY: (u8, u8) = (2, 1); + /// `lo hi -- n`. Calls: the range predicate, `PopCount`. + fn lower(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError> { + range(d, stack)?; + procs::count(d, stack) + } +} + +impl Question { + /// Bind the aperture's range. + /// + /// # Errors + /// + /// [`NalError`] if the witness or the procedure is refused. + pub fn bind(b: &Bound<'_>, a: Aperture) -> Result { + let (lo, hi) = b.range(a)?; + Prepared::of::(b, &[i64::from(lo), i64::from(hi)], 0..b.n_rows()) + } +} + +/// The beliefs an aperture selects with confidence code `>= t`. Confidence is +/// a value lane, not part of the name, so this is a lane predicate; it reads +/// only the aperture's range, which is the execution extent. +pub struct TruthAtLeast; + +impl NalOp for TruthAtLeast { + const FN: Option = None; + const ARITY: (u8, u8) = (1, 1); + /// `t -- n`, counting `t − 1 < c`. Calls: `LOAD`, `IntSLess`, `PopCount`. + fn lower(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError> { + let t = pop_i64(stack)?; + stack.push(Val::Scalar(t - 1)); + stack.push(Val::Scalar(i64::from(LANE_C))); + d.call(LOAD, [1, 0, 0], stack)?; + d.call(INT_S_LESS, [0; 3], stack)?; + procs::count(d, stack) + } +} + +impl TruthAtLeast { + /// Bind the aperture's range and the threshold. + /// + /// # Errors + /// + /// [`NalError`] if the witness or the procedure is refused. + pub fn bind(b: &Bound<'_>, a: Aperture, t: u8) -> Result { + let (lo, hi) = b.range(a)?; + Prepared::of::(b, &[i64::from(t)], lo as usize..hi as usize) + } +} + +/// NAL choice: the highest confidence among the beliefs an aperture selects. +/// Which belief holds it (an argmax) is not a fold mask-risc has yet. +pub struct Choice; + +impl NalOp for Choice { + const FN: Option = None; + const ARITY: (u8, u8) = (2, 1); + /// `lo hi -- max c`. Calls: the range predicate, `LOAD`, `MAX`. + fn lower(d: &mut FoldDialect<'_>, stack: &mut Vec) -> Result<(), FoldError> { + range(d, stack)?; + procs::max(d, stack, LANE_C) + } +} + +impl Choice { + /// Bind the aperture's range. + /// + /// # Errors + /// + /// [`NalError`] if the witness or the procedure is refused. + pub fn bind(b: &Bound<'_>, a: Aperture) -> Result { + let (lo, hi) = b.range(a)?; + Prepared::of::(b, &[i64::from(lo), i64::from(hi)], lo as usize..hi as usize) + } +} diff --git a/crates/lance-graph-nal/tests/w2.rs b/crates/lance-graph-nal/tests/w2.rs new file mode 100644 index 000000000..6f41b10cb --- /dev/null +++ b/crates/lance-graph-nal/tests/w2.rs @@ -0,0 +1,273 @@ +//! D-NALP-2: the query operations that need no new byte, each checked against +//! a row-at-a-time oracle over the UNSORTED input beliefs. + +use lance_graph_contract::ordered_lane::OrderedLaneWitness; +use lance_graph_mask_risc::Lowering; +use lance_graph_nal::{nal, Aperture, Belief, Beliefs, Copula, NalError, Statement}; + +const CLASS: u32 = 0x0E01_0001; + +/// Deterministic beliefs: SplitMix64 over 40 terms, 4 copulas, every truth code. +fn fixture(n: usize) -> Vec { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = || { + 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) + }; + (0..n) + .map(|i| { + let r = next(); + Belief { + s: (r % 40) as u16, + copula: Copula::ALL[((r >> 8) % 4) as usize], + p: ((r >> 16) % 40) as u16, + id: i as u16, + f: (r >> 24) as u8, + c: (r >> 32) as u8, + } + }) + .collect() +} + +fn sealed(input: &[Belief]) -> Beliefs { + Beliefs::seal(CLASS, input, 7).expect("seal") +} + +fn statements() -> Vec { + let mut v = Vec::new(); + for s in [0u16, 3, 17, 39, 40, 999] { + for copula in Copula::ALL { + for p in [0u16, 5, 39, 41] { + v.push(Statement { s, copula, p }); + } + } + } + v +} + +fn same(b: &Belief, st: Statement) -> bool { + b.statement() == st +} + +/// FAILS IF: the statement aperture cares the wrong bytes, or the compiled +/// fold counts anything but the bound range. +#[test] +fn question_count_equals_the_row_oracle() { + let input = fixture(5_000); + let beliefs = sealed(&input); + let bound = beliefs.bind(&beliefs.witness()).unwrap(); + let mut hits = 0; + for st in statements() { + let q = nal::Question::bind(&bound, Aperture::statement(CLASS, st)).unwrap(); + let mut scratch = q.scratch(&bound).unwrap(); + let want = input.iter().filter(|b| same(b, st)).count(); + assert_eq!(q.count(&bound, &mut scratch).unwrap(), want, "{st:?}"); + // The kept program answers again, unchanged. + assert_eq!(q.count(&bound, &mut scratch).unwrap(), want, "{st:?}"); + hits += usize::from(want > 0); + } + // Anti-vacuity: the statement set hits and misses. + assert!(hits > 5 && hits < statements().len(), "hits = {hits}"); +} + +/// FAILS IF: the aperture byte count does not decide the selection: the class +/// (4 bytes), the subject (6) and the statement (10) of one key must nest, and +/// another class must select nothing. +#[test] +fn the_aperture_width_is_the_selection() { + let input = fixture(5_000); + let beliefs = sealed(&input); + let b = beliefs.bind(&beliefs.witness()).unwrap(); + let st = input[17].statement(); + let key = lance_graph_nal::guid(CLASS, st.s, st.copula, st.p, 0); + let count = |a: Aperture| { + let q = nal::Question::bind(&b, a).unwrap(); + q.count(&b, &mut q.scratch(&b).unwrap()).unwrap() + }; + let class = count(Aperture { key, bytes: 4 }); + let subject = count(Aperture { key, bytes: 6 }); + let statement = count(Aperture { key, bytes: 10 }); + assert_eq!(class, input.len()); + assert_eq!(subject, input.iter().filter(|x| x.s == st.s).count()); + assert_eq!(statement, input.iter().filter(|x| same(x, st)).count()); + assert!(class > subject && subject > statement && statement > 0); + let other = lance_graph_nal::guid(CLASS + 1, st.s, st.copula, st.p, 0); + assert_eq!( + count(Aperture { + key: other, + bytes: 4 + }), + 0 + ); + // Zero bytes cared selects every row. + assert_eq!( + count(Aperture { + key: other, + bytes: 0 + }), + input.len() + ); +} + +/// FAILS IF: the question folds through anything but the fused range, i.e. +/// the coordinate bound did not reach mask-risc as a range. +#[test] +fn question_lowers_to_the_fused_range() { + let beliefs = sealed(&fixture(2_000)); + let bound = beliefs.bind(&beliefs.witness()).unwrap(); + let st = Statement { + s: 3, + copula: Copula::Inheritance, + p: 5, + }; + let q = nal::Question::bind(&bound, Aperture::statement(CLASS, st)).unwrap(); + assert!( + matches!(q.lowering(), Lowering::Range(_)), + "{:?}", + q.lowering() + ); +} + +/// FAILS IF: choice is not the highest confidence of the selection, or reports +/// one for an empty selection. +#[test] +fn choice_is_the_highest_confidence() { + let input = fixture(5_000); + let beliefs = sealed(&input); + let bound = beliefs.bind(&beliefs.witness()).unwrap(); + let (mut some, mut none) = (0, 0); + for st in statements() { + let want = input + .iter() + .filter(|b| same(b, st)) + .map(|b| i32::from(b.c)) + .max(); + let q = nal::Choice::bind(&bound, Aperture::statement(CLASS, st)).unwrap(); + let got = q.max(&bound, &mut q.scratch(&bound).unwrap()).unwrap(); + assert_eq!(got, want, "{st:?}"); + if want.is_some() { + some += 1; + } else { + none += 1; + } + } + assert!(some > 0 && none > 0); +} + +/// FAILS IF: the confidence threshold is off by one, inverted, ignores the +/// selection, or reads outside the selection's range. +#[test] +fn truth_at_least_equals_the_row_oracle() { + let input = fixture(5_000); + let beliefs = sealed(&input); + let bound = beliefs.bind(&beliefs.witness()).unwrap(); + let mut partial = 0; + let selections = statements() + .into_iter() + .map(|st| { + ( + Aperture::statement(CLASS, st), + Box::new(move |b: &Belief| same(b, st)) as Box bool>, + ) + }) + .chain([0u16, 3, 39, 40].into_iter().map(|s| { + ( + Aperture::subject(CLASS, s), + Box::new(move |b: &Belief| b.s == s) as Box bool>, + ) + })); + for (a, sel) in selections { + for t in [0u8, 1, 100, 204, 255] { + let q = nal::TruthAtLeast::bind(&bound, a, t).unwrap(); + let mut scratch = q.scratch(&bound).unwrap(); + let all = input.iter().filter(|b| sel(b)).count(); + let want = input.iter().filter(|b| sel(b) && b.c >= t).count(); + assert_eq!(q.count(&bound, &mut scratch).unwrap(), want, "{a:?} t={t}"); + partial += usize::from(want > 0 && want < all); + } + } + // Anti-vacuity: some thresholds cut inside a selection. + assert!(partial > 0); +} + +/// FAILS IF: the empty and the full population answer wrongly. +#[test] +fn empty_and_full_populations() { + let st = Statement { + s: 1, + copula: Copula::Similarity, + p: 2, + }; + let a = Aperture::statement(CLASS, st); + let empty = sealed(&[]); + let b = empty.bind(&empty.witness()).unwrap(); + let q = nal::Question::bind(&b, a).unwrap(); + assert_eq!(q.count(&b, &mut q.scratch(&b).unwrap()).unwrap(), 0); + let q = nal::Choice::bind(&b, a).unwrap(); + assert_eq!(q.max(&b, &mut q.scratch(&b).unwrap()).unwrap(), None); + + let one: Vec = (0..300) + .map(|i| Belief { + s: 1, + copula: Copula::Similarity, + p: 2, + id: i, + f: 9, + c: 200, + }) + .collect(); + let full = sealed(&one); + let b = full.bind(&full.witness()).unwrap(); + let q = nal::TruthAtLeast::bind(&b, a, 200).unwrap(); + assert_eq!(q.count(&b, &mut q.scratch(&b).unwrap()).unwrap(), 300); + let q = nal::TruthAtLeast::bind(&b, a, 201).unwrap(); + assert_eq!(q.count(&b, &mut q.scratch(&b).unwrap()).unwrap(), 0); +} + +/// FAILS IF: a witness from another seal binds. A stale witness must make the +/// coordinate path unavailable, never answer from the wrong order. +#[test] +fn a_stale_witness_refuses_to_bind() { + let beliefs = sealed(&fixture(1_000)); + let forged = OrderedLaneWitness::forged(7, beliefs.witness().n_rows(), 0xDEAD); + assert!(matches!(beliefs.bind(&forged), Err(NalError::Witness(_)))); +} + +/// FAILS IF: a query bound on one population runs on another of the same +/// size. Its kept range is in the first population's coordinates, so the +/// second would answer silently for the wrong beliefs. +#[test] +fn a_prepared_query_refuses_another_population() { + let a = sealed(&fixture(2_000)); + let mut other = fixture(2_000); + other.iter_mut().for_each(|b| b.s = (b.s + 7) % 40); + let b = sealed(&other); + assert_eq!(a.witness().n_rows(), b.witness().n_rows()); + let (ba, bb) = (a.bind(&a.witness()).unwrap(), b.bind(&b.witness()).unwrap()); + let st = Statement { + s: 3, + copula: Copula::Inheritance, + p: 5, + }; + let ap = Aperture::statement(CLASS, st); + let q = nal::Question::bind(&ba, ap).unwrap(); + assert!(matches!( + q.count(&bb, &mut q.scratch(&bb).unwrap()), + Err(NalError::WrongPopulation) + )); + let t = nal::TruthAtLeast::bind(&ba, ap, 100).unwrap(); + assert!(matches!( + t.count(&bb, &mut t.scratch(&bb).unwrap()), + Err(NalError::WrongPopulation) + )); + let c = nal::Choice::bind(&ba, ap).unwrap(); + assert!(matches!( + c.max(&bb, &mut c.scratch(&bb).unwrap()), + Err(NalError::WrongPopulation) + )); + // The population it was bound on still answers. + assert!(q.count(&ba, &mut q.scratch(&ba).unwrap()).is_ok()); +}