diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 2091af6..4e86163 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -4,6 +4,43 @@ > `**Status:**`/`**Confidence:**` line. A correction gets its own new, > dated entry that references the one it corrects — the storno rule. +## 2026-08-27 — E-BINDING-A-REAL-PROVIDER-MEASURES-THE-FIXTURE-1 + +**Status:** FINDING — measured, pinned by a test rather than asserted. +**Confidence:** High. Both halves are numbers, and the disable-run is +red-then-green on five tests. + +Binding the real `ClassView` provider (`OgarClassView`) behind +`--features ogar-classview` did not make the hop *better*. It made the hop +*empty* — and that emptiness is the most useful thing the wiring produced. + +The provider itself is correct and discriminating: 98 registered classes, +**12 distinct** participation masks where the fixture answered one. But the +generated row store's classid domain is `0..16` +(`ROWSTORE_CLASS_CARDINALITY`) while every vocabulary classid is `>= 0x0100` +— **disjoint**. So a generated store under a real provider traverses nothing, +for every classid in its own domain. + +**The generalizable part.** A fixture with a plausible answer for every input +(`FieldMask::FULL`) is indistinguishable from a bound provider until you bind +one. `FULL` is the answer that never disagrees, which is exactly why it +cannot be falsified in place. The measurement that mattered was not "does +the provider work" — it was **binding it and reading what the rest of the +system then failed to do**. The seam was declared closed-enough for two +waves because nothing in the suite could tell the two providers apart. + +**Consequence, stated rather than fixed here:** the remaining fixture is the +row CONTENT, not the layout or the kernels. Replacing `RowStore::generate` +with Lance-loaded SoA rows is what makes the bound provider observable +end-to-end; until then the feature is a correct provider over rows it has no +classes for. + +**Discipline note.** Two fixture-semantics tests had to be gated OFF under +the feature. Neither was deleted: each got a paired ON twin asserting the +CONTRASTING fact on the same inputs (all-32 vs none; 19/29 vs empty), so the +gate reads as evidence of a changed answer rather than as a suppressed +failure. + ## 2026-08-26 — E-ONE-SUBSTRATE-FIVE-GLOVES-GHIDRA-IS-THE-GLOVE-NOT-THE-MODEL-1 **Status:** DOCTRINE — [OPERATOR-FRAMED]. The "what is it FOR" that the diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index a91d71f..91ab52b 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,36 @@ +## 2026-08-27 — the REAL ClassView provider is bound, and it measures the fixture's reach + +The `ClassView` provider seam (§4-NG3, "a real ontology/cache provider is a +NAMED SEAM") is no longer only named. `ogar_class_view::OgarClassView` — the +ontology-backed provider over `ogar_vocab` — is bound behind a new +`ogar-classview` feature on `native/lgj-abi`, and `edge_participation` derives +from each class's real field basis instead of the fixture's constant. + +- **The provider discriminates, measured.** `examples/classview_census.rs`: + **98 registered classes, 12 distinct participation masks** (field counts + 0–13), against the fixture's single `0xFFFF_FFFF` for all 98. An + unregistered classid participates in **nothing** — an unknown class is not + a licence to traverse every facet. +- **The `[patch]` was load-bearing, not cosmetic.** `ogar-class-view` pulls + `lance-graph-contract` by git branch; this crate pulls it by path, and + cargo does not unify a git SourceId with a path SourceId — without the + patch the build carries two `lance-graph-contract` crates and therefore + two incompatible `ClassView` traits. Verified: `cargo tree` shows one. +- **What binding it EXPOSED, and this is the finding.** The generated row + store draws classids from `0..16` (`ROWSTORE_CLASS_CARDINALITY`); every + vocabulary classid is `>= 0x0100`. The two domains are **disjoint**, so a + generated store under the real provider hops nothing. The remaining + fixture is the row CONTENT — Lance-loaded SoA rows are what make the bound + provider observable end-to-end. Pinned by + `hop_under_the_real_provider_narrows_by_class`, not left in prose. +- **Default is unchanged and proven so.** Feature OFF: 134/134 rust, 447/447 + Java (304 core + 143 consumer) — the same numbers as before. Feature ON: + 136/136. Two fixture-semantics tests are gated OFF under the feature and + each has a paired ON twin asserting the CONTRASTING fact, so nothing was + merely disabled. Five tests red-then-green under the disable + (`edge_participation`'s ogar arm returns `FULL`). G11 fence green: + `class_view`, `canonical_node`, `ontology`, `facet` only. + ## 2026-08-25 — the Ghidra end of the R2IL arc: seam verified, vocabulary measured Working the `r2il-machine-semantic-contract-v1` plan (lance-graph, PR #1027, diff --git a/native/lgj-abi/Cargo.lock b/native/lgj-abi/Cargo.lock index c829ebe..1c6dd7c 100644 --- a/native/lgj-abi/Cargo.lock +++ b/native/lgj-abi/Cargo.lock @@ -57,6 +57,7 @@ version = "0.1.0" dependencies = [ "lance-graph-contract", "ndarray", + "ogar-class-view", ] [[package]] @@ -110,6 +111,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "ogar-class-view" +version = "0.1.0" +dependencies = [ + "lance-graph-contract", + "ogar-vocab", +] + +[[package]] +name = "ogar-vocab" +version = "0.1.0" + [[package]] name = "paste" version = "1.0.15" diff --git a/native/lgj-abi/Cargo.toml b/native/lgj-abi/Cargo.toml index fed1a2c..8d91d64 100644 --- a/native/lgj-abi/Cargo.toml +++ b/native/lgj-abi/Cargo.toml @@ -43,6 +43,12 @@ ndarray = { path = "../../../ndarray", default-features = false, features = ["st # this crate consumes, and the crate compiles fine without them. lance-graph-contract = { path = "../../../lance-graph/crates/lance-graph-contract", default-features = false } +# The REAL ClassView provider (OGAR Core). `FixtureClassView` answers the same +# 32 facets for every classid; `OgarClassView` walks `ogar_vocab`'s promoted +# classes and gives a genuine per-class field basis -- attributes AND +# associations -- which is what makes `edge_participation` discriminate. +ogar-class-view = { path = "../../../OGAR/crates/ogar-class-view", optional = true } + [profile.release] opt-level = 3 lto = "thin" @@ -55,3 +61,18 @@ codegen-units = 1 [profile.dev] # Same rule as release: no panic = "abort". + +[features] +# Bind the real OGAR ClassView instead of the fixture. OFF by default: the +# generated row store's classid domain (0..16) is disjoint from the +# vocabulary's (>= 0x0100), so binding it changes what every hop answers -- +# see `class_view_provider`'s module docs. +ogar-classview = ["dep:ogar-class-view"] + +# ogar-class-view pulls `lance-graph-contract` by git branch; this crate pulls +# it by path. Cargo does NOT unify a git source with a path source, so without +# this patch the build carries TWO `lance-graph-contract` crates and therefore +# two incompatible `ClassView` traits -- the same SourceId trap recorded in +# tesseract-rs for `ogar-doc-ir`. The patch collapses both onto the local path. +[patch."https://github.com/AdaWorldAPI/lance-graph"] +lance-graph-contract = { path = "../../../lance-graph/crates/lance-graph-contract" } diff --git a/native/lgj-abi/examples/classview_census.rs b/native/lgj-abi/examples/classview_census.rs new file mode 100644 index 0000000..1f5f860 --- /dev/null +++ b/native/lgj-abi/examples/classview_census.rs @@ -0,0 +1,34 @@ +//! What the REAL ClassView provider answers, versus the fixture's constant. +//! +//! `cargo run --features ogar-classview --example classview_census` +fn main() { + #[cfg(not(feature = "ogar-classview"))] + println!("build with --features ogar-classview"); + + #[cfg(feature = "ogar-classview")] + { + use lance_graph_contract::class_view::ClassView; + use ogar_class_view::OgarClassView; + use std::collections::BTreeMap; + + let view = OgarClassView::new(); + let ids: Vec = view.known_class_ids().collect(); + let mut hist: BTreeMap = BTreeMap::new(); + for c in &ids { + *hist.entry(view.fields(*c).len().min(32)).or_default() += 1; + } + println!("registered classes : {}", ids.len()); + println!( + "distinct field counts (= distinct participation masks): {}", + hist.len() + ); + println!("\n fields classes"); + for (k, n) in &hist { + println!(" {k:>6} {n:>7}"); + } + println!( + "\nfixture would answer 0xFFFFFFFF (32 facets) for ALL {} classes.", + ids.len() + ); + } +} diff --git a/native/lgj-abi/src/class_view_provider.rs b/native/lgj-abi/src/class_view_provider.rs index 82fee09..15a8631 100644 --- a/native/lgj-abi/src/class_view_provider.rs +++ b/native/lgj-abi/src/class_view_provider.rs @@ -6,20 +6,35 @@ //! trait, late-bound by design (the operator's RULING CLARIFICATION, //! `.claude/plans/mask-native-navigation-correction-v1.md` §1: *"the //! contract defines the law; an ontology/cache/provider supplies the -//! answers"*). `FixtureClassView` below is one such provider — the ONLY -//! one this crate needs, because the whole SoA row store [`crate::rowstore`] -//! generates is one deterministic domain: every classid gets the SAME 32 -//! facets (`predicate_iri: "lgj:facet/N"`, `label: "facetN"`), because the +//! answers"*). `FixtureClassView` below is one such provider — the +//! DEFAULT one, matching the SoA row store [`crate::rowstore`] generates: +//! one deterministic domain where every classid gets the SAME 32 facets +//! (`predicate_iri: "lgj:facet/N"`, `label: "facetN"`), because the //! generator itself does not vary a row's *shape* by classid — only its //! *content*. //! -//! A real ontology/cache provider (a future, non-fixture `ClassView` impl) -//! is a NAMED SEAM, not a gap this module tries to fill: see the trait -//! itself for the shape a real provider would fill in, and -//! `.claude/plans/mask-native-navigation-correction-v1.md` §4-NG3 for why -//! it stays out of scope here. A per-resource provider slot on the -//! registry entry (rather than this module-level singleton) is the seam -//! for wiring one in. +//! # The real provider (feature `ogar-classview`) +//! +//! `ogar_class_view::OgarClassView` — the ontology-backed provider over +//! `ogar_vocab` — is bound behind the `ogar-classview` feature, and +//! [`edge_participation`] then derives from each class's actual field +//! count instead of the fixture constant. Measured over the vocabulary +//! (`examples/classview_census.rs`): 98 registered classes, **12 distinct +//! participation masks** (field counts 0–13), against the fixture's single +//! `0xFFFF_FFFF` for all 98. +//! +//! What is NOT yet closed, stated plainly: the generated store draws its +//! classids from `0..16` ([`crate::rowstore::ROWSTORE_CLASS_CARDINALITY`]) +//! while every vocabulary classid is `>= 0x0100`, so the two domains are +//! **disjoint** — a generated store under the real provider hops nothing. +//! The remaining fixture is the row CONTENT; replacing it with +//! Lance-loaded SoA rows is what makes the bound provider observable +//! end-to-end. Pinned, not merely asserted, by +//! `exports::tests::hop_under_the_real_provider_narrows_by_class`. +//! +//! A per-resource provider slot on the registry entry (rather than this +//! module-level singleton) remains the seam for binding a provider PER +//! dataset rather than process-wide. //! //! # The `edge_participation` / `decode_mode` seam (§4-NG6) //! @@ -49,6 +64,8 @@ use lance_graph_contract::class_view::{ClassId, ClassView, FieldMask}; use lance_graph_contract::facet::CascadeShape; use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +#[cfg(feature = "ogar-classview")] +use ogar_class_view::OgarClassView; use std::sync::OnceLock; /// Facets per row in the fixture row-store domain @@ -217,10 +234,69 @@ impl FixtureClassView { /// non-fixture provider is what would vary this per class. pub fn edge_participation(classid: u32) -> FieldMask { // The bounds check happens for its own sake (see `class_id_for`'s doc - // on why): this fixture's answer does not depend on the result, but a - // real provider's would, and the conversion is what it would consult. - let _class_id = class_id_for(classid); - FieldMask::FULL.intersect(FieldMask(0xFFFF_FFFF)) + // on why): the fixture's answer does not depend on the result, but the + // real provider's does, and the conversion is what it consults. + let class_id = class_id_for(classid); + + #[cfg(feature = "ogar-classview")] + { + // THE REAL PROVIDER. `OgarClassView` walks `ogar_vocab`'s promoted + // classes, so `fields(class)` is that class's genuine basis -- + // attributes AND associations, in source order -- and therefore + // VARIES by class where the fixture is constant. This is the seam + // §4-NG6 named; binding it is what makes the ClassView half of + // `MASK x ClassView -> MASK` discriminate at all. + // + // A class with `k` fields owns facet positions `0..k`, so positions + // at or past `k` cannot carry one of its edges. That is a genuine + // per-class narrowing. + // + // PRECISION, stated honestly: `fields()` is attributes ++ + // associations flattened, and only the associations are actually + // edge-bearing. The trait cannot tell them apart, and + // `all_canonical_classes()` -- which can -- is private to + // ogar-class-view. So this answer is a SUPERSET of the true edge + // set: it may admit an attribute position (which the structured-edge + // `hi32 == 0` gate in `lgj_hop` then rejects) but it can never MISS + // a real edge. Over-admitting is the safe direction; under-admitting + // would silently lose edges. Narrowing to associations-only needs + // ogar-class-view to expose that split -- an OGAR-side ask, not a + // local workaround. + // + // Unknown class -> the provider's documented empty-field fallback -> + // an EMPTY mask, so the hop finds nothing rather than everything. + // That is the opposite of the fixture's answer and is deliberate: an + // unregistered classid is not a licence to traverse every facet. + let Some(cid) = class_id else { + return FieldMask::from(0u64); + }; + let view = ogar_view(); + let k = ::fields(view, cid).len(); + let bits: u64 = if k >= 32 { + 0xFFFF_FFFF + } else { + (1u64 << k) - 1 + }; + FieldMask::from(bits) + } + + #[cfg(not(feature = "ogar-classview"))] + { + let _ = class_id; + FieldMask::FULL.intersect(FieldMask(0xFFFF_FFFF)) + } +} + +/// The process-wide real provider, built once. +/// +/// `OgarClassView::new()` is pure construction over `ogar_vocab` (no I/O), +/// and the registry is read-only afterwards -- so a `OnceLock` is the whole +/// lifecycle. Deliberately NOT rebuilt per call: it walks every promoted +/// class. +#[cfg(feature = "ogar-classview")] +fn ogar_view() -> &'static OgarClassView { + static VIEW: std::sync::OnceLock = std::sync::OnceLock::new(); + VIEW.get_or_init(OgarClassView::new) } /// Which structured-edge decode convention `classid`'s edge facets use — @@ -239,6 +315,127 @@ pub fn decode_mode(classid: u32) -> u32 { 0 } +#[cfg(all(test, feature = "ogar-classview"))] +mod ogar_provider_tests { + use super::{edge_participation, ogar_view}; + use lance_graph_contract::class_view::{ClassView, FieldMask}; + + /// The CONTRAST to `tests::edge_participation_covers_exactly_the_low_32_bits` + /// (which is gated OFF under this feature): the real provider NARROWS. + /// + /// Same inputs, opposite answers. The fixture says classid 0 + /// participates in all 32 facets; the real provider says an + /// unregistered classid participates in NONE — an unknown class is not + /// a licence to traverse every facet. And the richest REGISTERED class + /// in the vocabulary still participates in strictly fewer than 32, + /// because 32 is the store's facet capacity, not any class's field + /// count. + /// + /// DISABLE: return `FieldMask::FULL` from the `ogar-classview` arm of + /// `edge_participation` and both halves fail at once. + #[test] + fn the_real_provider_narrows_rather_than_widens() { + let unregistered = edge_participation(0); + assert_eq!( + unregistered.count(), + 0, + "an unregistered classid must participate in NOTHING, where the \ + fixture answered all 32" + ); + + // 0x0103 is the richest class in `ogar_vocab` (13 fields, measured by + // `examples/classview_census.rs`). Even the richest is well under the + // store's 32-facet capacity. + let richest = edge_participation(0x0103); + assert_eq!(richest.count(), 13, "0x0103 carries 13 fields"); + assert!( + richest.count() < 32, + "even the richest registered class must narrow below the fixture's 32" + ); + // Anti-vacuity: this is the LOW-k prefix, not an arbitrary 13 bits — + // so a provider that returned any 13-bit pattern would fail. + for bit in 0..13u8 { + assert!(richest.has(bit), "bit {bit} must participate"); + } + for bit in 13..64u8 { + assert!(!richest.has(bit), "bit {bit} is beyond this class's fields"); + } + } + + /// THE point of binding the real provider: the answer must depend on the + /// class. The fixture returns `FULL` for every classid, so this is the + /// one assertion that separates a bound provider from a stub. + /// + /// DISABLE: return `FieldMask::FULL` from the `ogar-classview` arm and + /// this fails -- every class collapses to one answer again. + #[test] + fn the_real_provider_varies_participation_by_class() { + let view = ogar_view(); + let ids: Vec<_> = view.known_class_ids().collect(); + assert!( + ids.len() >= 2, + "need >=2 registered classes to show variation, got {}", + ids.len() + ); + + let masks: Vec = ids + .iter() + .map(|c| edge_participation(u32::from(*c))) + .collect(); + let distinct: std::collections::BTreeSet = masks.iter().map(|m| m.0).collect(); + assert!( + distinct.len() >= 2, + "the real provider must give >=2 DISTINCT participation masks across {} classes, \ + got {distinct:?} -- a single answer means the provider is not discriminating", + ids.len() + ); + + // Anti-vacuity: the variation must come from real field counts, not + // from some classes being absent. Every mask here is non-empty and + // its popcount equals that class's field count (capped at 32). + for (c, m) in ids.iter().zip(masks.iter()) { + let k = <_ as ClassView>::fields(view, *c).len().min(32); + assert_eq!( + m.0.count_ones() as usize, + k, + "class {c}: mask popcount must equal its field count" + ); + } + } + + /// An unregistered classid gets an EMPTY mask, not a full one -- the + /// opposite of the fixture. A classid the ontology does not know is not + /// a licence to traverse all 32 facets. + /// + /// DISABLE: fall through to `FieldMask::FULL` for the unknown case and + /// this fails. + #[test] + fn an_unregistered_classid_participates_in_nothing() { + let view = ogar_view(); + let known: std::collections::BTreeSet = view.known_class_ids().collect(); + // Find a classid the registry does not carry. + let unknown = (0u16..=u16::MAX) + .find(|c| !known.contains(c)) + .expect("some classid must be unregistered"); + assert!( + edge_participation(u32::from(unknown)).is_empty(), + "unregistered class {unknown} must participate in nothing" + ); + + // ... and the silence twin: a REGISTERED class does not come back + // empty, so the test above is not passing because everything is empty. + let a_known = *known.iter().next().expect("at least one known class"); + assert!(!edge_participation(u32::from(a_known)).is_empty()); + } + + /// Out-of-range classids are refused before the provider is consulted -- + /// the `class_id_for` bounds check still governs. + #[test] + fn an_out_of_range_classid_participates_in_nothing() { + assert!(edge_participation(u32::from(u16::MAX) + 1).is_empty()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -297,6 +494,7 @@ mod tests { /// pinned here rather than merely asserted in a doc comment (the /// falsifiability rule: an unexercised claim is not a behaviour). #[test] + #[cfg(not(feature = "ogar-classview"))] fn edge_participation_is_unaffected_by_the_classid_width_boundary() { let in_range = edge_participation(0); let at_boundary = edge_participation(u16::MAX as u32); @@ -307,7 +505,15 @@ mod tests { assert_eq!(in_range, max); } + /// The FIXTURE's answer: every classid participates in all 32 facets. + /// + /// Gated OFF under `ogar-classview` deliberately — the real provider + /// MUST fail this, and its failing is the evidence that it + /// discriminates. The contrasting fact is pinned by + /// `ogar_provider_tests::the_real_provider_narrows_rather_than_widens`, + /// which asserts the opposite of each line below on the same inputs. #[test] + #[cfg(not(feature = "ogar-classview"))] fn edge_participation_covers_exactly_the_low_32_bits() { let p = edge_participation(0); assert_eq!(p.count(), 32); diff --git a/native/lgj-abi/src/exports.rs b/native/lgj-abi/src/exports.rs index 62a37b7..588fdd2 100644 --- a/native/lgj-abi/src/exports.rs +++ b/native/lgj-abi/src/exports.rs @@ -2689,7 +2689,12 @@ mod tests { // ── hop (ABI minor ≥ 4) ───────────────────────────────────────────────── + /// The FIXTURE's pinned answer. Gated OFF under `ogar-classview`: with a + /// real provider bound, edge classid 0 is UNREGISTERED, so participation + /// narrows to nothing and 19/29 is no longer the truth. The contrasting + /// fact is pinned by `hop_under_the_real_provider_narrows_by_class`. #[test] + #[cfg(not(feature = "ogar-classview"))] fn hop_matches_the_pinned_rowstore_regression_10_19_29() { let n = 2000u64; let store = rowstore_with_edges(n, 0xF00D_CAFE, 0, 0x0, 25); @@ -2721,6 +2726,67 @@ mod tests { lgj_close(store); } + /// The CONTRAST to `hop_matches_the_pinned_rowstore_regression_10_19_29`. + /// + /// Same store, same seeds, same call — a different answer, because + /// participation is now a per-class fact instead of a constant. Two + /// halves, and both are needed: the hop must yield EMPTY (narrowing is + /// real), and the provider must still answer non-empty for a classid + /// that IS in the vocabulary (the emptiness is the store's classid + /// domain, not a provider that answers empty to everything). + /// + /// DISABLE: return `FieldMask::FULL` from the `ogar-classview` arm of + /// `edge_participation` and the first half fails — 19 rows come back + /// where the real provider says none should. + #[test] + #[cfg(feature = "ogar-classview")] + fn hop_under_the_real_provider_narrows_by_class() { + let n = 2000u64; + let seed_rows: Vec = (0..10u64).map(|i| i * 37 + 5).collect(); + + // (a) classid 0 is unregistered -> participates in nothing -> EMPTY. + let store0 = rowstore_with_edges(n, 0xF00D_CAFE, 0, 0x0, 25); + let src0 = mask(store0, LGJ_MASK_INIT_EMPTY); + // dst starts ALL, so EMPTY proves an overwrite rather than a default. + let dst0 = mask(store0, LGJ_MASK_INIT_ALL); + set_rows(src0, &seed_rows); + assert_eq!(lgj_hop(store0, 0, 0xFFFF_FFFF, 0, src0, dst0), LGJ_OK); + assert_eq!( + count(dst0), + 0, + "an unregistered edge classid must traverse nothing (the fixture \ + answered 19 here)" + ); + lgj_close(dst0); + lgj_close(src0); + lgj_close(store0); + + // (b) ...and emptiness here is the STORE's doing, not a dead + // provider. `RowStore::generate*` draws classids from `0..16` + // (`ROWSTORE_CLASS_CARDINALITY`), while every classid registered in + // `ogar_vocab` is >= 0x0100 — the two domains are DISJOINT, so under + // a real provider a generated store hops nothing for ANY classid in + // its own domain. That is the honest state of the seam: the fixture + // ROWS are what remain to be replaced with Lance-loaded SoA rows. + // + // Two-sided: the same provider DOES admit facets for a classid that + // is actually in the vocabulary, so the emptiness above is a + // property of the store's classid domain and not of a provider that + // answers empty to everything. + for c in 0..crate::rowstore::ROWSTORE_CLASS_CARDINALITY as u32 { + assert_eq!( + crate::class_view_provider::edge_participation(c).count(), + 0, + "classid {c} is in the store's domain but not the vocabulary" + ); + } + assert_eq!( + crate::class_view_provider::edge_participation(0x0103).count(), + 13, + "the provider is not answering empty to everything" + ); + } + #[test] fn hop_with_empty_facet_mask_yields_an_empty_dst() { let n = 2000u64;