Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions crates/lance-graph-arm-discovery/src/aerial/codebook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ impl MatrixDistance {
pub fn new(spec: &FeatureSpec, table: Vec<u32>) -> Self {
let dim = spec.dim();
assert_eq!(table.len(), dim * dim, "distance table must be dim × dim");
let offsets: Vec<usize> = (0..spec.num_features())
.map(|f| spec.block(f).0)
.collect();
Self { dim, offsets, table }
let offsets: Vec<usize> = (0..spec.num_features()).map(|f| spec.block(f).0).collect();
Self {
dim,
offsets,
table,
}
}

/// The flat code (slot index) of an item. Validates bounds so an invalid
Expand Down Expand Up @@ -150,11 +152,7 @@ impl CodebookDistance for TopKDistance {
/// consequent item: the **nearest** antecedent item wins (min). For a
/// single-item antecedent this is just `distance(a, item)`.
#[must_use]
pub fn antecedent_distance(
oracle: &dyn CodebookDistance,
antecedent: &[Item],
item: Item,
) -> u32 {
pub fn antecedent_distance(oracle: &dyn CodebookDistance, antecedent: &[Item], item: Item) -> u32 {
antecedent
.iter()
.map(|&a| oracle.distance(a, item))
Expand All @@ -169,7 +167,7 @@ mod tests {
#[test]
fn matrix_distance_is_slot_addressed_and_symmetric_if_built_so() {
let spec = FeatureSpec::new(vec![2, 2]); // dim 4
// 4×4 table; (f0,c0)=slot0 near (f1,c0)=slot2 (dist 1), far from slot3.
// 4×4 table; (f0,c0)=slot0 near (f1,c0)=slot2 (dist 1), far from slot3.
#[rustfmt::skip]
let table = vec![
0, 5, 1, 9,
Expand Down Expand Up @@ -216,7 +214,11 @@ mod tests {
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(1, 1)),
99,
"non-neighbour = far"
);
assert_eq!(d.distance(Item::new(0, 0), Item::new(0, 0)), 0, "self = 0");
}

Expand Down
58 changes: 44 additions & 14 deletions crates/lance-graph-arm-discovery/src/aerial/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ impl Default for ExtractParams {
Self {
theta: u32::MAX,
max_antecedent: 2,
min_support_ppm: 10_000, // 1%
min_support_ppm: 10_000, // 1%
min_confidence_ppm: 500_000, // 50%
}
}
Expand Down Expand Up @@ -95,7 +95,15 @@ pub fn extract_rules(
for size in 1..=params.max_antecedent {
for feature_combo in feature_combinations(&candidate_features, size) {
for antecedent in item_product(&feature_combo, &frequent_by_feature) {
probe(oracle, data, &masks, &antecedent, &feature_combo, params, &mut rules);
probe(
oracle,
data,
&masks,
&antecedent,
&feature_combo,
params,
&mut rules,
);
}
}
}
Expand Down Expand Up @@ -220,8 +228,8 @@ mod tests {
/// in codebook space). Data plants f0 == f1 with a little noise; f2 random.
fn fixture(noise_every: usize) -> (Dataset, MatrixDistance) {
let spec = FeatureSpec::new(vec![2, 2, 2]); // dim 6
// slots: f0c0=0 f0c1=1 | f1c0=2 f1c1=3 | f2c0=4 f2c1=5
// near = 1, mid = 5, far = 50.
// slots: f0c0=0 f0c1=1 | f1c0=2 f1c1=3 | f2c0=4 f2c1=5
// near = 1, mid = 5, far = 50.
let near = 1u32;
let mid = 5u32;
let far = 50u32;
Expand All @@ -247,12 +255,19 @@ mod tests {
let rows: Vec<Vec<u32>> = (0..600)
.map(|i| {
let a = (i % 2) as u32;
let b = if noise_every != 0 && i % noise_every == 0 { 1 - a } else { a };
let b = if noise_every != 0 && i % noise_every == 0 {
1 - a
} else {
a
};
let c = ((i / 2) % 2) as u32;
vec![a, b, c]
})
.collect();
(Dataset::new(spec.clone(), rows), MatrixDistance::new(&spec, table))
(
Dataset::new(spec.clone(), rows),
MatrixDistance::new(&spec, table),
)
}

#[test]
Expand All @@ -261,7 +276,7 @@ mod tests {
let params = ExtractParams {
theta: 2, // codebook prune: only "near" (dist ≤ 2) consequents
max_antecedent: 1,
min_support_ppm: 50_000, // 5%
min_support_ppm: 50_000, // 5%
min_confidence_ppm: 700_000, // 70%
};
let rules = extract_rules(&dist, &data, &params);
Expand All @@ -273,33 +288,48 @@ mod tests {
assert!(has, "planted f0=0 ⇒ f1=0 not recovered: {rules:#?}");

// feature 2 is codebook-far → pruned by theta → never a consequent of f0
let spurious = rules.iter().any(|r| {
r.antecedent == vec![Item::new(0, 0)] && r.consequent[0].feature == 2
});
let spurious = rules
.iter()
.any(|r| r.antecedent == vec![Item::new(0, 0)] && r.consequent[0].feature == 2);
assert!(!spurious, "independent feature 2 leaked: {rules:#?}");

let rule = rules
.iter()
.find(|r| r.antecedent == vec![Item::new(0, 0)] && r.consequent == vec![Item::new(1, 0)])
.find(|r| {
r.antecedent == vec![Item::new(0, 0)] && r.consequent == vec![Item::new(1, 0)]
})
.unwrap();
assert!(rule.confidence_ppm() >= 700_000, "confidence below floor");
}

#[test]
fn fully_deterministic_no_seed() {
let (data, dist) = fixture(20);
let p = ExtractParams { theta: 2, max_antecedent: 1, min_support_ppm: 50_000, min_confidence_ppm: 700_000 };
let p = ExtractParams {
theta: 2,
max_antecedent: 1,
min_support_ppm: 50_000,
min_confidence_ppm: 700_000,
};
let a = extract_rules(&dist, &data, &p);
let b = extract_rules(&dist, &data, &p);
assert_eq!(a, b, "codebook probe is bitwise-deterministic by construction");
assert_eq!(
a, b,
"codebook probe is bitwise-deterministic by construction"
);
}

#[test]
fn theta_prune_blocks_far_consequents() {
// With a generous theta the far feature is *tested* (then data-gated);
// with a tight theta it is pruned before the data even sees it.
let (data, dist) = fixture(0); // no noise: f1==f0 exactly
let tight = ExtractParams { theta: 2, max_antecedent: 1, min_support_ppm: 50_000, min_confidence_ppm: 600_000 };
let tight = ExtractParams {
theta: 2,
max_antecedent: 1,
min_support_ppm: 50_000,
min_confidence_ppm: 600_000,
};
let rules = extract_rules(&dist, &data, &tight);
assert!(rules.iter().all(|r| r.consequent[0].feature != 2));
}
Expand Down
19 changes: 16 additions & 3 deletions crates/lance-graph-arm-discovery/src/aerial/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ mod tests {
vec![a, a, c]
})
.collect();
(Dataset::new(spec.clone(), rows), MatrixDistance::new(&spec, table))
(
Dataset::new(spec.clone(), rows),
MatrixDistance::new(&spec, table),
)
}

#[test]
Expand All @@ -131,7 +134,12 @@ mod tests {
#[test]
fn mined_rules_serialise_to_spo_ndjson() {
let (data, dist) = fixture();
let params = AerialParams { theta: 2, max_antecedent: 1, min_support_ppm: 50_000, min_confidence_ppm: 700_000 };
let params = AerialParams {
theta: 2,
max_antecedent: 1,
min_support_ppm: 50_000,
min_confidence_ppm: 700_000,
};
let p = AerialProposer::new(data, dist, params);
let rules = p.mine();
assert!(!rules.is_empty());
Expand All @@ -151,7 +159,12 @@ mod tests {
fn reproducible_no_seed_needed() {
let (d1, dist1) = fixture();
let (d2, dist2) = fixture();
let p = AerialParams { theta: 2, max_antecedent: 1, min_support_ppm: 50_000, min_confidence_ppm: 700_000 };
let p = AerialParams {
theta: 2,
max_antecedent: 1,
min_support_ppm: 50_000,
min_confidence_ppm: 700_000,
};
let r1 = AerialProposer::new(d1, dist1, p).mine();
let r2 = AerialProposer::new(d2, dist2, p).mine();
assert_eq!(r1, r2);
Expand Down
22 changes: 18 additions & 4 deletions crates/lance-graph-arm-discovery/src/aerial/ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ impl OntologyProjector {
is_dolce: true,
..Self::subclass_of(
namespace,
DolceCategory::ALL.iter().map(|c| c.iri().to_string()).collect(),
DolceCategory::ALL
.iter()
.map(|c| c.iri().to_string())
.collect(),
)
}
}
Expand Down Expand Up @@ -212,7 +215,11 @@ mod tests {
// consequent category == dolce_id == basin (the hub routing key)
assert_eq!(proj.dolce_id(&[Item::new(1, 0)]), Some(0)); // Endurant
assert_eq!(proj.dolce_id(&[Item::new(1, 1)]), Some(1)); // Perdurant
assert_eq!(proj.dolce_id(&[Item::new(1, 9)]), None, "out-of-range facet");
assert_eq!(
proj.dolce_id(&[Item::new(1, 9)]),
None,
"out-of-range facet"
);
// a non-DOLCE projector has no dolce_id to emit
let plain = OntologyProjector::subclass_of("wd:", vec!["wd:Q5".into()]);
assert_eq!(plain.dolce_id(&[Item::new(1, 0)]), None);
Expand All @@ -226,7 +233,12 @@ mod tests {
// 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<Vec<u32>> = (0..300).map(|i| { let k = (i % 3) as u32; vec![k, k] }).collect();
let rows: Vec<Vec<u32>> = (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.
Expand All @@ -246,7 +258,9 @@ mod tests {
// 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)])
.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:");
Expand Down
5 changes: 4 additions & 1 deletion crates/lance-graph-arm-discovery/src/bitset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ mod tests {
let m = RowMasks::build(&d);
// single-item: feature0=0 in 3 rows
assert_eq!(m.support_count(Item::new(0, 0)), 3);
assert_eq!(m.support_count(Item::new(0, 0)), d.count_matching(&[Item::new(0, 0)]));
assert_eq!(
m.support_count(Item::new(0, 0)),
d.count_matching(&[Item::new(0, 0)])
);
// conjunction f0=0 ∧ f1=0 → 2 rows
let both = [Item::new(0, 0), Item::new(1, 0)];
assert_eq!(m.and_count(&both), 2);
Expand Down
5 changes: 4 additions & 1 deletion crates/lance-graph-arm-discovery/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ mod tests {
fn item_ordering_is_feature_then_category() {
let mut items = vec![Item::new(1, 0), Item::new(0, 2), Item::new(0, 1)];
items.sort();
assert_eq!(items, vec![Item::new(0, 1), Item::new(0, 2), Item::new(1, 0)]);
assert_eq!(
items,
vec![Item::new(0, 1), Item::new(0, 2), Item::new(1, 0)]
);
}
}
2 changes: 1 addition & 1 deletion crates/lance-graph-arm-discovery/src/simd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ mod tests {
assert_eq!(popcount(&[0u64]), 0);
assert_eq!(popcount(&[u64::MAX]), 64);
assert_eq!(popcount(&[0b1011, 0b11]), 5); // 3 + 2 set bits
// exercise the 8-word vector chunk + tail
// exercise the 8-word vector chunk + tail
assert_eq!(popcount(&[u64::MAX; 9]), 64 * 9);
}

Expand Down
6 changes: 5 additions & 1 deletion crates/lance-graph-arm-discovery/src/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,11 @@ mod tests {

#[test]
fn triple_projection() {
let t = CandidateTriple::from_rule(&rule(90, 100, 400), &DebugProjector::default(), NARS_PERSONALITY_K);
let t = CandidateTriple::from_rule(
&rule(90, 100, 400),
&DebugProjector::default(),
NARS_PERSONALITY_K,
);
assert_eq!(t.s, "arm:feat0=cat1");
assert_eq!(t.p, "implies");
assert_eq!(t.o, "arm:feat1=cat0");
Expand Down
Loading
Loading