Skip to main content

xlog_induce/
nary_reference.rs

1//! Host-side reference scorer for n-ary rule patterns.
2//!
3//! The parity anchor for the device n-ary scoring stage: a direct,
4//! obviously-correct interpretation of [`NaryRulePattern`] coverage
5//! semantics over host fact tables. The device kernel, when it lands, must
6//! reproduce these counts bit-for-bit on bounded inputs — exactly the role
7//! the Python prototype played for the binary engine.
8//!
9//! Coverage semantics, stated once: an example tuple (one head assignment)
10//! is covered by a pattern iff there exists an assignment of the pattern's
11//! join variables such that every body atom's bound row exists in its
12//! candidate relation. Head positions are fixed by the example; join
13//! variables are searched. The search is a plain backtracking walk — the
14//! reference optimizes for auditability, not speed.
15
16use crate::nary::{BodyAtomPattern, NaryRulePattern, PatternVar};
17
18/// One candidate relation's facts as host rows; row length is the arity.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HostRelation {
21    pub rows: Vec<Vec<u64>>,
22}
23
24/// Coverage counts for one pattern over one example set pair.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct ReferenceCoverage {
27    pub positives_covered: u32,
28    pub negatives_covered: u32,
29}
30
31/// Score one pattern against positive and negative example tuples.
32///
33/// `candidates` is indexed by the pattern's `candidate_slot` values; each
34/// example tuple's length must equal the pattern's head arity. The caller
35/// validates the pattern first ([`NaryRulePattern::validate`]) — this
36/// interpreter assumes canonical form and panics only on indexing bugs,
37/// never on data.
38pub fn score_pattern_reference(
39    pattern: &NaryRulePattern,
40    candidates: &[HostRelation],
41    positives: &[Vec<u64>],
42    negatives: &[Vec<u64>],
43) -> ReferenceCoverage {
44    let count = |examples: &[Vec<u64>]| -> u32 {
45        examples
46            .iter()
47            .filter(|example| covers(pattern, candidates, example))
48            .count() as u32
49    };
50    ReferenceCoverage {
51        positives_covered: count(positives),
52        negatives_covered: count(negatives),
53    }
54}
55
56/// Does the pattern cover one head assignment?
57fn covers(pattern: &NaryRulePattern, candidates: &[HostRelation], example: &[u64]) -> bool {
58    let join_count = join_variable_count(pattern);
59    let mut joins: Vec<Option<u64>> = vec![None; join_count];
60    satisfy(&pattern.body, candidates, example, &mut joins)
61}
62
63/// Backtracking satisfaction over the remaining body atoms.
64fn satisfy(
65    body: &[BodyAtomPattern],
66    candidates: &[HostRelation],
67    example: &[u64],
68    joins: &mut Vec<Option<u64>>,
69) -> bool {
70    let Some((atom, rest)) = body.split_first() else {
71        return true;
72    };
73    let relation = &candidates[atom.candidate_slot as usize];
74    'rows: for row in &relation.rows {
75        // assert_eq!, not debug_assert_eq!: this interpreter is the PARITY
76        // ANCHOR, so in release builds a ragged row would let zip() silently
77        // truncate and produce a confident wrong count — the worst failure
78        // mode an oracle can have.
79        assert_eq!(
80            row.len(),
81            atom.bindings.len(),
82            "candidate relation row width does not match the atom's bindings",
83        );
84        // Check the row against fixed positions, collecting the join
85        // variables this row would newly bind so they can be undone.
86        let mut newly_bound: Vec<u8> = Vec::new();
87        for (value, binding) in row.iter().zip(&atom.bindings) {
88            match *binding {
89                PatternVar::Head(i) => {
90                    if example[i as usize] != *value {
91                        undo(joins, &newly_bound);
92                        continue 'rows;
93                    }
94                }
95                PatternVar::Join(j) => match joins[j as usize] {
96                    Some(bound) => {
97                        if bound != *value {
98                            undo(joins, &newly_bound);
99                            continue 'rows;
100                        }
101                    }
102                    None => {
103                        joins[j as usize] = Some(*value);
104                        newly_bound.push(j);
105                    }
106                },
107            }
108        }
109        if satisfy(rest, candidates, example, joins) {
110            return true;
111        }
112        undo(joins, &newly_bound);
113    }
114    false
115}
116
117fn undo(joins: &mut [Option<u64>], newly_bound: &[u8]) {
118    for j in newly_bound {
119        joins[*j as usize] = None;
120    }
121}
122
123fn join_variable_count(pattern: &NaryRulePattern) -> usize {
124    let mut count = 0usize;
125    for atom in &pattern.body {
126        for binding in &atom.bindings {
127            if let PatternVar::Join(j) = *binding {
128                count = count.max(j as usize + 1);
129            }
130        }
131    }
132    count
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::nary::canonical_binary_pattern;
139    use crate::types::Topology;
140
141    fn pairs(rows: &[(u64, u64)]) -> HostRelation {
142        HostRelation {
143            rows: rows.iter().map(|(a, b)| vec![*a, *b]).collect(),
144        }
145    }
146
147    /// The exact hand-computed fixture the CUDA kernel test pins
148    /// (`ilp_exact.rs::ilp_exact_score_matches_hand_computed_fixture`):
149    /// candidates p_B={(1,2),(2,3)}, p_C={(2,4),(3,5),(4,6)}, positives
150    /// {(1,4),(2,5)}, negatives {(7,8)}. Only chain(p_B, p_C) covers, and it
151    /// covers both positives (joins z=2 and z=3); every other
152    /// (topology, L, R) combination covers nothing, negatives included.
153    #[test]
154    fn reference_matches_shipped_binary_kernel_fixture() {
155        let p_b = pairs(&[(1, 2), (2, 3)]);
156        let p_c = pairs(&[(2, 4), (3, 5), (4, 6)]);
157        let candidates = vec![p_b, p_c];
158        let positives = vec![vec![1u64, 4u64], vec![2, 5]];
159        let negatives = vec![vec![7u64, 8u64]];
160
161        let mut nonzero = Vec::new();
162        for topology in Topology::ALL {
163            for left in 0..2u32 {
164                for right in 0..2u32 {
165                    let pattern = canonical_binary_pattern(topology, left, right);
166                    let coverage =
167                        score_pattern_reference(&pattern, &candidates, &positives, &negatives);
168                    assert_eq!(
169                        coverage.negatives_covered, 0,
170                        "{topology:?}({left},{right}) covered a negative"
171                    );
172                    if coverage.positives_covered > 0 {
173                        nonzero.push((topology, left, right, coverage.positives_covered));
174                    }
175                }
176            }
177        }
178        assert_eq!(
179            nonzero,
180            vec![(Topology::Chain, 0, 1, 2)],
181            "coverage disagrees with the shipped kernel fixture"
182        );
183    }
184
185    /// Ternary head with a two-atom body sharing one join variable —
186    /// hand-computed. H(x0,x1,x2) :- T(x0,x1,z0), P(z0,x2) with
187    /// T={(1,2,9),(4,5,8)}, P={(9,3),(8,7)}: (1,2,3) covers via z0=9,
188    /// (4,5,6) fails (P has (8,7) not (8,6)), (1,2,7) fails (z0=9 forces
189    /// P(9,7) which is absent — the join must be consistent across atoms).
190    #[test]
191    fn ternary_head_join_consistency_is_enforced() {
192        use crate::nary::{BodyAtomPattern, NaryRulePattern};
193        use PatternVar::{Head, Join};
194        let pattern = NaryRulePattern {
195            head_arity: 3,
196            body: vec![
197                BodyAtomPattern {
198                    candidate_slot: 0,
199                    bindings: vec![Head(0), Head(1), Join(0)],
200                },
201                BodyAtomPattern {
202                    candidate_slot: 1,
203                    bindings: vec![Join(0), Head(2)],
204                },
205            ],
206        };
207        let ternary = HostRelation {
208            rows: vec![vec![1, 2, 9], vec![4, 5, 8]],
209        };
210        let binary = pairs(&[(9, 3), (8, 7)]);
211        let candidates = vec![ternary, binary];
212        let positives = vec![vec![1u64, 2, 3], vec![4, 5, 6], vec![1, 2, 7]];
213        let coverage = score_pattern_reference(&pattern, &candidates, &positives, &[]);
214        assert_eq!(coverage.positives_covered, 1);
215
216        // Pin the COMPOSITION, not just the sum: a count of 1 would also
217        // pass if a compensating defect covered (1,2,7) while missing the
218        // (1,2,3) the hand-derivation names. Scoring each example alone
219        // says WHICH one is covered.
220        let per_example: Vec<u32> = positives
221            .iter()
222            .map(|example| {
223                score_pattern_reference(&pattern, &candidates, std::slice::from_ref(example), &[])
224                    .positives_covered
225            })
226            .collect();
227        assert_eq!(
228            per_example,
229            vec![1, 0, 0],
230            "(1,2,3) must be the covered example; (4,5,6) and (1,2,7) must not be",
231        );
232    }
233
234    /// A join variable appearing in a single atom is a don't-care position
235    /// (the Fanout/Fanin shape): any row value satisfies it.
236    #[test]
237    fn single_occurrence_join_is_a_dont_care_position() {
238        let pattern = canonical_binary_pattern(Topology::Fanout, 0, 1);
239        // L(X,Z), R(X,Y) — L's second column can be anything.
240        let l = pairs(&[(1, 999)]);
241        let r = pairs(&[(1, 5)]);
242        let coverage = score_pattern_reference(&pattern, &[l, r], &[vec![1u64, 5u64]], &[]);
243        assert_eq!(coverage.positives_covered, 1);
244    }
245
246    /// Backtracking must try later rows of an earlier atom when the first
247    /// binding dead-ends: T={(1,8),(1,9)}, P={(9,2)} — z0=8 fails P, z0=9
248    /// succeeds. A greedy first-row-only walk would miss the cover.
249    #[test]
250    fn backtracking_revisits_earlier_atom_rows() {
251        use crate::nary::{BodyAtomPattern, NaryRulePattern};
252        use PatternVar::{Head, Join};
253        let pattern = NaryRulePattern {
254            head_arity: 2,
255            body: vec![
256                BodyAtomPattern {
257                    candidate_slot: 0,
258                    bindings: vec![Head(0), Join(0)],
259                },
260                BodyAtomPattern {
261                    candidate_slot: 1,
262                    bindings: vec![Join(0), Head(1)],
263                },
264            ],
265        };
266        let t = pairs(&[(1, 8), (1, 9)]);
267        let p = pairs(&[(9, 2)]);
268        let coverage = score_pattern_reference(&pattern, &[t, p], &[vec![1u64, 2u64]], &[]);
269        assert_eq!(coverage.positives_covered, 1);
270    }
271}