From bd0e832ae682ff4917b3a982c2bb1551d2bee7e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 21:27:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(arm-discovery):=20D-ARM-14=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20splat-top-k=20oracle=20+=20DOLCE=20skeleton=20proje?= =?UTF-8?q?ctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two aerial-side seams the Wikidata-HHTL pipeline needs, verifiable standalone (the real splat + ontology producers stay in heavy workspace crates / jc, which the zero-dep aerial crate can't build against — so they're documented as the consumer-side / offline step). - aerial::TopKDistance: a CodebookDistance backed by a sparse per-node top-k neighbour list — the shape the 10000^2 BLASGraph Gaussian-splat actually emits (keep top-k per node, certified by jc EWA-sandwich; not a dense dim^2 table). Symmetric, nearest-on-duplicate, bounds-checked. - FeatureSpec::checked_slot: shared bounds-checked item->slot mapping. - aerial::ontology: DolceCategory (Endurant/Perdurant/Quality/Abstract + basin nibble + IRI — the HHTL axis template) + OntologyProjector (FeedProjector -> rdfs:subClassOf / rdf:type DOLCE skeleton SPO). End-to-end test: a splat top-k makes each occupation near its DOLCE class; aerial discovers the occupation->class skeleton edge; the projector renders `wd:f0_0 rdfs:subClassOf dolce:Endurant`; an unused facet is never invented. Float stays OFFLINE in jc only (ewa_sandwich + sigma_codebook_probe rho=0.9973 + pflug Le); aerial's online path is integer (the CAM-PQ doctrine end-to-end). Verified: 41/41 tests (37 + 4 new) + clippy -D warnings clean on both default and --features ndarray-simd. Zero-dep preserved. Remaining (documented): real jc/blasgraph splat producing the lists; Wikidata loader; gated on D-ARM-7 Jirak floor (jc::jirak). Map: .claude/knowledge/splat-codebook-aerial-wikidata-compression.md https://claude.ai/code/session_012SorR8UbtEvYmbX8cXftj7 --- .claude/board/AGENT_LOG.md | 10 + .claude/board/STATUS_BOARD.md | 2 +- ...at-codebook-aerial-wikidata-compression.md | 3 +- .../src/aerial/codebook.rs | 81 +++++++ .../src/aerial/mod.rs | 4 +- .../src/aerial/ontology.rs | 204 ++++++++++++++++++ .../lance-graph-arm-discovery/src/encode.rs | 20 ++ crates/lance-graph-arm-discovery/src/lib.rs | 2 +- 8 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 crates/lance-graph-arm-discovery/src/aerial/ontology.rs diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 2fc18f9b..c27deb15 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,13 @@ +## [Main thread / Opus] D-ARM-14 Phase 1 — splat-top-k oracle + DOLCE skeleton projector + +**Branch:** claude/jolly-cori-clnf9-darm14 (off post-merge main) | **Files:** `crates/lance-graph-arm-discovery/src/{aerial/codebook.rs (+TopKDistance), aerial/ontology.rs (new), aerial/mod.rs, encode.rs (+checked_slot), lib.rs}` + STATUS_BOARD (D-ARM-14 → In progress). + +**Cargo:** **41/41** (37 + 4 new) + clippy `-D warnings` clean on BOTH default and `--features ndarray-simd`. Zero-dep preserved. + +**Outcome:** Phase 1 DONE. User chose D-ARM-14 next. Recon (consult-before-guess): `crates/lance-graph-ontology` (DOLCE hydrators) + the blasgraph splat top-k both live in heavy workspace crates; `jc::ewa_sandwich` is a PROOF not a table-builder — none buildable from the zero-dep aerial crate. So Phase 1 = the two **aerial-side seams**, verifiable standalone: (1) `TopKDistance` — a sparse per-node top-k `CodebookDistance` (the real shape the 10000² Gaussian-splat emits; you keep top-k per node, certified by jc EWA-sandwich, not a dense dim² table), symmetric, nearest-on-duplicate, bounds-checked via new `FeatureSpec::checked_slot`; (2) `aerial::ontology` — `DolceCategory` (Endurant/Perdurant/Quality/Abstract + basin nibble + IRI, the HHTL axis template) + `OntologyProjector` (FeedProjector → `rdfs:subClassOf`/`rdf:type` DOLCE skeleton SPO). End-to-end test proves splat-top-k → aerial discovers `occupation→DOLCE-class` → projects `wd:f0_0 rdfs:subClassOf dolce:Endurant`, and an unused facet is never invented. Float stays OFFLINE in jc only; aerial online path integer (CAM-PQ doctrine). Remaining (documented): real jc/blasgraph splat producing the lists, Wikidata loader, D-ARM-7 Jirak floor. NOT pushed yet — awaiting branch confirmation (prior branch merged). See `splat-codebook-aerial-wikidata-compression.md`. + +--- + ## [Main thread / Opus] PR #436 review response — 5 CodeRabbit findings (public-API hardening) **Branch:** claude/jolly-cori-clnf9 | **Files:** `crates/lance-graph-arm-discovery/src/{aerial/codebook.rs, aerial/extract.rs, translator.rs, encode.rs}` + `.claude/knowledge/aerial-arm-ruff-spo-codegen-synergies.md`. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index c69e794b..410edb0d 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -635,7 +635,7 @@ The missing UPSTREAM discovery leg. Today's proposers (curated L-docs + AST-extr | D-ARM-11 | `style_recipe.rs` rule 8 — ArmDiscovered backing adds `DAtom::Compute` weight 2 (provisional) | lance-graph-ontology::style_recipe | 80 | MED | **Queued** | depends on D-ARM-1 | | D-ARM-12 | End-to-end pipeline test + bench (synthetic Odoo feed → all 5 stages → council micro-batch) | lance-graph-arm-discovery::tests + benches | 400 | MED | **Queued** | depends on Waves 1-6; informs OQ-ARM-2 + OQ-ARM-7 | | D-ARM-13 | **Aerial+ Rust transcode — deterministic codebook-probe backend** (float-free). The paper's `f32` denoising autoencoder is REPLACED by an integer `CodebookDistance` oracle (palette256 distance, ρ=0.9973 vs cosine): the reconstruction probe is a codebook top-k, not a softmax over float weights. Integer evidence counts + ppm gates + `TruthU8` (= CausalEdge64 wire). `AerialProposer` impl of `Proposer`. Count loop is a row-bitset SoA (`RowMasks`) → AND+popcount, routed through `ndarray::simd::U64x8` under the `ndarray-simd` feature. | lance-graph-arm-discovery::aerial | ~1.1K | HIGH | **Shipped (branch)** | branch `claude/jolly-cori-clnf9`; standalone zero-dep crate (excluded); **33/33** tests + clippy `-D warnings` clean on BOTH default (scalar) and `--features ndarray-simd`; **zero f32 in the discovery path** (audit), float only at the `TruthValue`/`Triple` serialization edge. Bitwise-deterministic ⇒ joins the trunk; the nondeterminism firewall + D-ARM-9 IPC rationale are moot. SIMD target-cpu caveat: real AVX-512/AMX kernels need `-C target-cpu=native`/`x86-64-v4`. v1 (autoencoder) superseded per the user's no-float directive. | -| D-ARM-14 | **Splat-codebook oracle + Wikidata skeleton discovery** — wire the certified jc splat codebook into aerial as the `CodebookDistance` oracle, discover OWL/DOLCE+ SPO HHTL classes + basins, drive the `wikidata-hhtl-load.md` deterministic compression (skeleton + basins + CAM-dedup + thin rows). | lance-graph-arm-discovery::aerial + crates/jc + wikidata loader | ~? | MED | **Queued (architecture)** | CONJECTURE pipeline; seams concrete (`CodebookDistance` ← jc `[u32;dim²]` table built by `ewa_sandwich`+`sigma_codebook_probe` ρ=0.9973+`pflug` Lε; Jirak floor ← `jc::jirak`). Float lives OFFLINE in jc only; aerial online path integer. No new aerial dep (pass the frozen table in). Gated on D-ARM-7. Full map: `splat-codebook-aerial-wikidata-compression.md`; finding E-ARM-JC-RESOLVES-BOTH-SEAMS. | +| D-ARM-14 | **Splat-codebook oracle + Wikidata skeleton discovery** — wire the certified jc splat codebook into aerial as the `CodebookDistance` oracle, discover OWL/DOLCE+ SPO HHTL classes + basins, drive the `wikidata-hhtl-load.md` deterministic compression (skeleton + basins + CAM-dedup + thin rows). | lance-graph-arm-discovery::aerial + crates/jc + wikidata loader | ~? | MED | **In progress** | **Phase 1 (branch `claude/jolly-cori-clnf9-darm14`):** the two aerial-side seams — `aerial::TopKDistance` (the sparse splat-top-k `CodebookDistance` the 10000² BLASGraph splat actually emits; top-k per node, not a dense table) + `aerial::ontology::{DolceCategory, OntologyProjector}` (DOLCE 4-facet skeleton → `rdfs:subClassOf`/`rdf:type` SPO). End-to-end test: splat top-k → aerial discovers `occupation→DOLCE-class` → projects the skeleton triple. 41/41 + clippy clean (default + `ndarray-simd`), zero-dep. Float still OFFLINE in jc only (`ewa_sandwich`+`sigma_codebook_probe` ρ=0.9973+`pflug` Lε); aerial online path integer. **Remaining:** real jc/blasgraph splat producing the lists; Wikidata loader; gated on D-ARM-7 (`jc::jirak`). Map: `splat-codebook-aerial-wikidata-compression.md`; E-ARM-JC-RESOLVES-BOTH-SEAMS. | | D-ARM-SYN-1 | Add `Implies`/`CoOccursWith` to `ruff_spo_triplet::Predicate` closed vocabulary (+ `Provenance` tier) so ARM rules load through the same `parse_triples` ndjson path as the static extractor | ruff/ruff_spo_triplet | 40 | MED | **Queued** | council-gated (deliberate ontology change); blocks SYN-2; see `.claude/knowledge/aerial-arm-ruff-spo-codegen-synergies.md` §1 | | D-ARM-SYN-2 | `CandidateRule → ruff_spo_triplet::ModelGraph` adapter so the Aerial runtime-data leg joins the `ruff_python_dto_check` static-AST leg in one graph before `expand()` | lance-graph-arm-discovery + ruff_spo_triplet | 120 | MED | **Queued** | depends on SYN-1; synergy doc §2 | | D-ARM-SYN-3 | Calibrate `ProvenanceTier::ArmDiscovered` `(f,c)` below the `op_emitter` ratification gate + below static `Inferred (0.85,0.75)` so un-ratified ARM truth is council-visible but codegen-filtered | lance-graph-contract + lance-graph-ontology::op_emitter | 30 | MED | **Queued** | depends on D-ARM-1 + SYN-1; synergy doc §3/§4 | diff --git a/.claude/knowledge/splat-codebook-aerial-wikidata-compression.md b/.claude/knowledge/splat-codebook-aerial-wikidata-compression.md index f97cb521..631c72b1 100644 --- a/.claude/knowledge/splat-codebook-aerial-wikidata-compression.md +++ b/.claude/knowledge/splat-codebook-aerial-wikidata-compression.md @@ -94,7 +94,8 @@ codebook offline (float OK), address it online with integer codes.** jc is the ## Status & open questions -- **CONJECTURE** (architecture): the splat→aerial→Wikidata wiring as a whole. The *seams* are concrete (the `CodebookDistance` trait + `jc::jirak`); the end-to-end pipeline is not yet built. +- **Phase 1 SHIPPED** (D-ARM-14, branch `claude/jolly-cori-clnf9-darm14`): the two aerial-side seams are concrete code, not just a trait — `aerial::TopKDistance` (the sparse per-node splat-top-k `CodebookDistance` the 10000² splat actually emits — top-k per node, not a dense `dim²` table) and `aerial::ontology::{DolceCategory, OntologyProjector}` (the DOLCE 4-facet skeleton → `rdfs:subClassOf`/`rdf:type` SPO output). An end-to-end test runs splat-top-k → discovery → skeleton projection. Standalone, integer, 41/41. +- **CONJECTURE** (architecture): the splat→aerial→Wikidata pipeline *as a whole* — the real jc/blasgraph splat producing the neighbour lists, and the Wikidata loader, are not yet built. The seams they plug into now exist. - **Hard prerequisite:** D-ARM-7 (the Jirak floor) must land before aerial promotes any rule to a live skeleton — ISSUE ARM-JIRAK-FLOOR. jc::jirak is the engine; the *gate function* (rule → significant?) is the deliverable. - **No new aerial dependency needed:** jc emits a frozen integer table; aerial consumes it through the existing `MatrixDistance`/`CodebookDistance` seam. Do NOT make aerial depend on jc — keep the float-free standalone posture; pass the certified table in. - **Open (from wikidata-hhtl-load):** P279 fan-out is wildly uneven (2…4000 children) — whether it rebalances onto a clean 16ⁿ tree or forces adaptive fan-out is MEASURABLE; measure on a real P279 subtree before fixing the HHTL base. diff --git a/crates/lance-graph-arm-discovery/src/aerial/codebook.rs b/crates/lance-graph-arm-discovery/src/aerial/codebook.rs index c5083c79..01c3df83 100644 --- a/crates/lance-graph-arm-discovery/src/aerial/codebook.rs +++ b/crates/lance-graph-arm-discovery/src/aerial/codebook.rs @@ -88,6 +88,64 @@ impl CodebookDistance for MatrixDistance { } } +/// A [`CodebookDistance`] backed by a **sparse top-k neighbour list per item** — +/// the shape the BLASGraph Gaussian-splat spatial top-k (10000²) actually +/// emits. You keep only the `k` nearest neighbours per node (certified by jc's +/// EWA-sandwich Σ-push-forward), never a dense `dim²` table. `distance(a, b)` +/// returns the stored splat distance if `b ∈ topk(a)`, else `miss` (far). +/// +/// This is the production oracle shape for D-ARM-14 (the [`MatrixDistance`] +/// dense table is the test/reference impl). The splat that *builds* the +/// neighbour lists is the consumer-side `crates/jc` / blasgraph step; this crate +/// stays zero-dep and just consumes the frozen integer lists. +#[derive(Debug, Clone)] +pub struct TopKDistance { + spec: FeatureSpec, + miss: u32, + /// `neighbours[slot]` = ascending `(neighbour_slot, distance)`, dedup'd to + /// the nearest distance per neighbour. Binary-searchable. + neighbours: Vec>, +} + +impl TopKDistance { + /// Build from undirected splat edges `(a, b, distance)`. `miss` is the + /// distance returned for pairs not in each other's top-k (i.e. "far"). + #[must_use] + pub fn new(spec: FeatureSpec, miss: u32, edges: &[(Item, Item, u32)]) -> Self { + let dim = spec.dim(); + let mut neighbours: Vec> = vec![Vec::new(); dim]; + for &(a, b, d) in edges { + let (ca, cb) = (spec.checked_slot(a), spec.checked_slot(b)); + neighbours[ca].push((cb as u32, d)); + neighbours[cb].push((ca as u32, d)); // splat distance is symmetric + } + for list in &mut neighbours { + // sort by (neighbour, distance) so dedup_by_key keeps the nearest. + list.sort_unstable(); + list.dedup_by_key(|&mut (n, _)| n); + } + Self { + spec, + miss, + neighbours, + } + } +} + +impl CodebookDistance for TopKDistance { + fn distance(&self, a: Item, b: Item) -> u32 { + let ca = self.spec.checked_slot(a); + let cb = self.spec.checked_slot(b) as u32; + if ca as u32 == cb { + return 0; + } + match self.neighbours[ca].binary_search_by_key(&cb, |&(n, _)| n) { + Ok(i) => self.neighbours[ca][i].1, + Err(_) => self.miss, + } + } +} + /// Aggregate the distance from a (multi-item) antecedent to a candidate /// consequent item: the **nearest** antecedent item wins (min). For a /// single-item antecedent this is just `distance(a, item)`. @@ -149,4 +207,27 @@ mod tests { // category 5 ≥ feature-0 block width 2 — must fail, not alias feature 1. let _ = d.distance(Item::new(0, 5), Item::new(1, 0)); } + + #[test] + fn topk_distance_is_sparse_and_symmetric() { + let spec = FeatureSpec::new(vec![2, 2]); + // one splat edge (f0,c0)~(f1,c0) at distance 3; all else "far" (99). + let edges = [(Item::new(0, 0), Item::new(1, 0), 3)]; + let d = TopKDistance::new(spec, 99, &edges); + assert_eq!(d.distance(Item::new(0, 0), Item::new(1, 0)), 3); + assert_eq!(d.distance(Item::new(1, 0), Item::new(0, 0)), 3, "symmetric"); + assert_eq!(d.distance(Item::new(0, 0), Item::new(1, 1)), 99, "non-neighbour = far"); + assert_eq!(d.distance(Item::new(0, 0), Item::new(0, 0)), 0, "self = 0"); + } + + #[test] + fn topk_keeps_the_nearest_on_duplicate_edges() { + let spec = FeatureSpec::new(vec![2, 2]); + let edges = [ + (Item::new(0, 0), Item::new(1, 0), 9), + (Item::new(0, 0), Item::new(1, 0), 2), // nearer duplicate wins + ]; + let d = TopKDistance::new(spec, 99, &edges); + assert_eq!(d.distance(Item::new(0, 0), Item::new(1, 0)), 2); + } } diff --git a/crates/lance-graph-arm-discovery/src/aerial/mod.rs b/crates/lance-graph-arm-discovery/src/aerial/mod.rs index 69ef577a..c872fa58 100644 --- a/crates/lance-graph-arm-discovery/src/aerial/mod.rs +++ b/crates/lance-graph-arm-discovery/src/aerial/mod.rs @@ -29,9 +29,11 @@ pub mod codebook; pub mod extract; +pub mod ontology; -pub use codebook::{antecedent_distance, CodebookDistance, MatrixDistance}; +pub use codebook::{antecedent_distance, CodebookDistance, MatrixDistance, TopKDistance}; pub use extract::{extract_rules, ExtractParams}; +pub use ontology::{DolceCategory, OntologyProjector}; use crate::encode::Dataset; use crate::rule::{CandidateRule, Proposer}; diff --git a/crates/lance-graph-arm-discovery/src/aerial/ontology.rs b/crates/lance-graph-arm-discovery/src/aerial/ontology.rs new file mode 100644 index 00000000..3a83307a --- /dev/null +++ b/crates/lance-graph-arm-discovery/src/aerial/ontology.rs @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! OWL/DOLCE skeleton projection — the discovery **output** side of D-ARM-14. +//! +//! aerial discovers `(entity-pattern → class)` rules over the splat codebook +//! ([`crate::aerial::TopKDistance`]); this module renders them as the +//! `wikidata-hhtl-load.md` **skeleton** — `rdf:type` (P31) / `rdfs:subClassOf` +//! (P279) triples — with **DOLCE's four top facets** as the HHTL axis template +//! (`ogit-owl-dolce-ontology-compartments.md`: "DOLCE defines WHICH axes; +//! Wikidata fills WHAT occurs"). The discovered rules are candidate skeleton +//! edges; the D-ARM-7 Jirak floor decides which are significant enough to +//! persist, and codebook-HHTL is the 16ⁿ bucket router downstream. + +use crate::rule::Item; +use crate::translator::FeedProjector; + +/// DOLCE's four top categories — the "clean top facets" of +/// `ogit-owl-dolce-ontology-compartments.md`, the HHTL axis template. Each owns +/// a basin nibble (`0x0..=0x3`) and a stable IRI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DolceCategory { + /// Objects that are wholly present at each moment (DOLCE *Endurant*). + Endurant, + /// Things that happen / unfold in time (DOLCE *Perdurant*). + Perdurant, + /// Qualities inhering in entities (DOLCE *Quality*). + Quality, + /// Abstract entities outside space-time (DOLCE *Abstract*). + Abstract, +} + +impl DolceCategory { + /// The four facets in basin order. + pub const ALL: [DolceCategory; 4] = [ + DolceCategory::Endurant, + DolceCategory::Perdurant, + DolceCategory::Quality, + DolceCategory::Abstract, + ]; + + /// The basin nibble (`0x0..=0x3`) this facet routes to in the HHTL tree. + #[must_use] + pub fn basin(self) -> u8 { + self as u8 + } + + /// The stable prefixed IRI for this facet. + #[must_use] + pub fn iri(self) -> &'static str { + match self { + DolceCategory::Endurant => "dolce:Endurant", + DolceCategory::Perdurant => "dolce:Perdurant", + DolceCategory::Quality => "dolce:Quality", + DolceCategory::Abstract => "dolce:Abstract", + } + } + + /// Recover a facet from its basin nibble. + #[must_use] + pub fn from_basin(n: u8) -> Option { + Self::ALL.get(n as usize).copied() + } +} + +/// A [`FeedProjector`] that renders discovered rules as OWL/DOLCE skeleton SPO. +/// +/// The **consequent** is the class axis: its category indexes [`Self::class_iris`] +/// (e.g. the four [`DolceCategory`] IRIs, or a Wikidata class set). The +/// **antecedent** is the entity/feature pattern, rendered under [`Self::namespace`]. +#[derive(Debug, Clone)] +pub struct OntologyProjector { + /// IRI prefix for the discovered entity/feature subject (e.g. `"wd:"`). + pub namespace: String, + /// The skeleton relation IRI (`"rdfs:subClassOf"` or `"rdf:type"`). + pub predicate: String, + /// Class IRI per consequent category (the skeleton classes). + pub class_iris: Vec, +} + +impl OntologyProjector { + /// A `rdfs:subClassOf` (P279) projector over an explicit class IRI set. + #[must_use] + pub fn subclass_of(namespace: impl Into, class_iris: Vec) -> Self { + Self { + namespace: namespace.into(), + predicate: "rdfs:subClassOf".to_string(), + class_iris, + } + } + + /// A `rdf:type` (P31) projector over an explicit class IRI set. + #[must_use] + pub fn instance_of(namespace: impl Into, class_iris: Vec) -> Self { + Self { + namespace: namespace.into(), + predicate: "rdf:type".to_string(), + class_iris, + } + } + + /// A `rdfs:subClassOf` projector whose consequent categories index the four + /// [`DolceCategory`] facets — the DOLCE axis-template skeleton. + #[must_use] + pub fn dolce_subclass(namespace: impl Into) -> Self { + Self::subclass_of( + namespace, + DolceCategory::ALL.iter().map(|c| c.iri().to_string()).collect(), + ) + } +} + +fn render_pattern(namespace: &str, items: &[Item]) -> String { + let mut parts: Vec = items.to_vec(); + parts.sort(); + let body = parts + .iter() + .map(|it| format!("f{}_{}", it.feature, it.category)) + .collect::>() + .join("&"); + format!("{namespace}{body}") +} + +impl FeedProjector for OntologyProjector { + fn subject(&self, antecedent: &[Item]) -> String { + render_pattern(&self.namespace, antecedent) + } + + fn predicate(&self) -> String { + self.predicate.clone() + } + + fn object(&self, consequent: &[Item]) -> String { + // The consequent is the class axis; its category indexes class_iris. + consequent + .first() + .and_then(|it| self.class_iris.get(it.category as usize)) + .cloned() + .unwrap_or_else(|| format!("{}class:unmapped", self.namespace)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aerial::{extract_rules, ExtractParams, TopKDistance}; + use crate::encode::{Dataset, FeatureSpec}; + use crate::translator::{CandidateTriple, NARS_PERSONALITY_K}; + + #[test] + fn dolce_facets_round_trip_through_basins() { + for (n, c) in DolceCategory::ALL.iter().enumerate() { + assert_eq!(c.basin() as usize, n); + assert_eq!(DolceCategory::from_basin(n as u8), Some(*c)); + } + assert_eq!(DolceCategory::from_basin(4), None); + assert_eq!(DolceCategory::Quality.iri(), "dolce:Quality"); + } + + /// End-to-end: a splat codebook makes each occupation near its DOLCE class; + /// aerial discovers the `occupation → class` skeleton edge; the projector + /// renders it as `wd:… rdfs:subClassOf dolce:…`. + #[test] + fn discovers_and_projects_dolce_skeleton() { + // feature 0 = occupation (3), feature 1 = DOLCE class (4 facets). + let spec = FeatureSpec::new(vec![3, 4]); + // Plant occupation k ⇒ class k for k ∈ {0,1,2} (Abstract unused). + let rows: Vec> = (0..300).map(|i| { let k = (i % 3) as u32; vec![k, k] }).collect(); + let data = Dataset::new(spec.clone(), rows); + + // Splat top-k: occupation k is near DOLCE class k (distance 1), else far. + let edges: Vec<(Item, Item, u32)> = (0..3) + .map(|k| (Item::new(0, k), Item::new(1, k), 1)) + .collect(); + let oracle = TopKDistance::new(spec, 99, &edges); + + let params = ExtractParams { + theta: 2, // only the codebook-near class is a candidate consequent + max_antecedent: 1, + min_support_ppm: 50_000, // 5% + min_confidence_ppm: 700_000, // 70% + }; + let rules = extract_rules(&oracle, &data, ¶ms); + + // The planted occupation0 ⇒ Endurant(class0) skeleton edge is recovered. + let rule = rules + .iter() + .find(|r| r.antecedent == vec![Item::new(0, 0)] && r.consequent == vec![Item::new(1, 0)]) + .expect("occupation0 → DOLCE class0 not discovered"); + + let proj = OntologyProjector::dolce_subclass("wd:"); + let triple = CandidateTriple::from_rule(rule, &proj, NARS_PERSONALITY_K); + assert_eq!(triple.s, "wd:f0_0"); + assert_eq!(triple.p, "rdfs:subClassOf"); + assert_eq!(triple.o, "dolce:Endurant"); + assert!(triple.c > 0.9, "confident skeleton edge"); + + // Abstract (class 3) was never instantiated → never a discovered class. + assert!( + !rules.iter().any(|r| r.consequent == vec![Item::new(1, 3)]), + "unused DOLCE facet must not be invented" + ); + } +} diff --git a/crates/lance-graph-arm-discovery/src/encode.rs b/crates/lance-graph-arm-discovery/src/encode.rs index 2ba674b0..05100874 100644 --- a/crates/lance-graph-arm-discovery/src/encode.rs +++ b/crates/lance-graph-arm-discovery/src/encode.rs @@ -76,6 +76,26 @@ impl FeatureSpec { pub fn slot(&self, item: Item) -> usize { self.offsets[item.feature as usize] + item.category as usize } + + /// The absolute code slot, bounds-checked against the schema. Panics with a + /// clear message if the feature or category is out of range — so an oracle + /// fed an invalid [`Item`] fails fast instead of aliasing another block. + #[must_use] + pub fn checked_slot(&self, item: Item) -> usize { + let feature = item.feature as usize; + assert!( + feature < self.num_features(), + "item feature {feature} out of range ({} features)", + self.num_features() + ); + let category = item.category as usize; + assert!( + category < self.cardinality(feature) as usize, + "item category {category} out of range for feature {feature} (cardinality {})", + self.cardinality(feature) + ); + self.offsets[feature] + category + } } /// A window of rows in category-index form, sharing one [`FeatureSpec`]. diff --git a/crates/lance-graph-arm-discovery/src/lib.rs b/crates/lance-graph-arm-discovery/src/lib.rs index 96f8545f..d1c647e1 100644 --- a/crates/lance-graph-arm-discovery/src/lib.rs +++ b/crates/lance-graph-arm-discovery/src/lib.rs @@ -69,7 +69,7 @@ pub mod translator; pub use aerial::{ antecedent_distance, extract_rules, AerialParams, AerialProposer, CodebookDistance, - ExtractParams, MatrixDistance, + DolceCategory, ExtractParams, MatrixDistance, OntologyProjector, TopKDistance, }; pub use bitset::RowMasks; pub use encode::{Dataset, FeatureSpec};