1- //! META-AGENT: add `pub mod triangle_bridge;` to lib.rs. Gate behind
2- //! feature `grammar-triangle`. Also requires adding to Cargo.toml:
3- //! lance-graph-cognitive = { path = "../lance-graph-cognitive", optional = true }
4- //! lance-graph-contract = { path = "../lance-graph-contract", optional = true }
5- //! [features] grammar-triangle = ["dep:lance-graph-cognitive", "dep:lance-graph-contract"]
6- //!
71//! Grammar Triangle bridge: merge DeepNSM SPO output with the Triangle's
82//! NSMField + CausalityFlow + QualiaField into a single SpoWithGrammar.
93//!
104//! The Triangle lives in `lance_graph_cognitive::grammar::GrammarTriangle`
115//! and is already-shipped (`from_text` constructs it). The actual API has
126//! `QualiaField` (18D phenomenal field) — not `Qualia18D`.
7+ //!
8+ //! PR-G1: real Causality footprint. The `causality_footprint` field on
9+ //! `SpoWithGrammar` carries a 3-bit Pearl 2³ mask derived from the SPO
10+ //! triple's active planes. This replaces the former neutral 0.5
11+ //! placeholder at lines 90 and 221.
1312
1413use crate :: parser:: SentenceStructure ;
14+ use crate :: spo:: { SpoTriple , NO_ROLE } ;
1515
1616#[ cfg( feature = "grammar-triangle" ) ]
1717use lance_graph_cognitive:: grammar:: {
@@ -45,6 +45,55 @@ pub struct SpoWithGrammar {
4545 /// footprint. Higher = more "novel domain" → routes to
4646 /// `NarsInference::Extrapolation` in the ticket.
4747 pub classification_distance : f32 ,
48+
49+ /// Pearl 2³ causality footprint: a 3-bit mask encoding which SPO
50+ /// planes are active in the sentence's primary triple.
51+ ///
52+ /// Bit layout (matches `causal-edge::pearl::CausalMask` repr):
53+ /// - bit 2 (0x04): Subject plane active
54+ /// - bit 1 (0x02): Predicate plane active
55+ /// - bit 0 (0x01): Object plane active
56+ ///
57+ /// Maps to Pearl's causal ladder:
58+ /// - `0b101` (S+O) = Level 1: Association P(Y|X)
59+ /// - `0b011` (P+O) = Level 2: Intervention P(Y|do(X))
60+ /// - `0b111` (SPO) = Level 3: Counterfactual
61+ /// - `0b110` (S+P) = intransitive: confounder detection
62+ ///
63+ /// Replaces the former neutral 0.5 placeholder. Downstream consumers
64+ /// compare two triangles' `causality_footprint` values to detect when
65+ /// the causal level has shifted (e.g., observational → interventional).
66+ pub causality_footprint : u8 ,
67+ }
68+
69+ /// Compute the Pearl 2³ causality mask from an SPO triple.
70+ ///
71+ /// Each bit corresponds to one SPO plane:
72+ /// - bit 2: Subject active (rank != NO_ROLE)
73+ /// - bit 1: Predicate active (rank != NO_ROLE)
74+ /// - bit 0: Object active (rank != NO_ROLE)
75+ ///
76+ /// This is a pure 3-bit projection — no `CausalEdge64` import needed.
77+ /// The resulting mask has the same bit layout as `causal-edge::pearl::CausalMask`
78+ /// so downstream consumers can cast directly.
79+ #[ inline]
80+ pub fn compute_pearl_mask ( triple : & SpoTriple ) -> u8 {
81+ let s_bit = if triple. subject ( ) != NO_ROLE { 0b100 } else { 0 } ;
82+ let p_bit = if triple. predicate ( ) != NO_ROLE { 0b010 } else { 0 } ;
83+ let o_bit = if triple. object ( ) != NO_ROLE { 0b001 } else { 0 } ;
84+ s_bit | p_bit | o_bit
85+ }
86+
87+ /// Compute the aggregate Pearl mask for a sentence structure.
88+ ///
89+ /// When the structure has one or more triples, returns the mask of
90+ /// the *primary* triple (index 0). When empty, returns `0b000` (no
91+ /// planes active — aggregate prior).
92+ pub fn causality_footprint_for ( structure : & SentenceStructure ) -> u8 {
93+ match structure. triples . first ( ) {
94+ Some ( triple) => compute_pearl_mask ( triple) ,
95+ None => 0b000 ,
96+ }
4897}
4998
5099/// Build the merged Triangle + SPO view. Default entry point.
@@ -57,20 +106,24 @@ pub struct SpoWithGrammar {
57106pub fn analyze_with_triangle ( text : & str , structure : SentenceStructure ) -> SpoWithGrammar {
58107 let triangle = GrammarTriangle :: from_text ( text) ;
59108 let dist = compute_classification_distance ( & structure, & triangle) ;
109+ let footprint = causality_footprint_for ( & structure) ;
60110 SpoWithGrammar {
61111 triples : structure,
62112 causality : triangle. causality ,
63113 nsm_field : triangle. nsm ,
64114 qualia_signature : triangle. qualia ,
65115 classification_distance : dist,
116+ causality_footprint : footprint,
66117 }
67118}
68119
69120/// Feature-off fallback: just carry the SPO with `classification_distance = 0`.
70121///
71122/// Available regardless of feature so the parser always has something to
72- /// hand the LLM router.
123+ /// hand the LLM router. The `causality_footprint` is still computed from
124+ /// the SPO triple — it does not require the Triangle feature.
73125pub fn analyze_without_triangle ( structure : SentenceStructure ) -> SpoWithGrammar {
126+ let footprint = causality_footprint_for ( & structure) ;
74127 SpoWithGrammar {
75128 triples : structure,
76129 #[ cfg( feature = "grammar-triangle" ) ]
@@ -80,17 +133,22 @@ pub fn analyze_without_triangle(structure: SentenceStructure) -> SpoWithGrammar
80133 #[ cfg( feature = "grammar-triangle" ) ]
81134 qualia_signature : QualiaField :: default ( ) ,
82135 classification_distance : 0.0 ,
136+ causality_footprint : footprint,
83137 }
84138}
85139
86140/// Normalized Hamming distance between the qualia fingerprint and the
87141/// SPO predicate's expected qualia footprint.
88142///
89- /// The footprint is derived from the verb's row in the 144-cell table
90- /// (currently a placeholder = neutral 0.5 prior, encoded as zero bits
91- /// after thresholding). Once D7 `GrammarStyleConfig` surfaces per-style
92- /// qualia expectations, `expected_qualia_footprint` will look up the row
93- /// by (verb, tense) and return that row's qualia footprint.
143+ /// The footprint is derived from the Pearl 2³ mask of the sentence's
144+ /// primary SPO triple. Active SPO planes seed qualia dimensions 0-2:
145+ /// dim 0 (Agency) ← Subject plane (bit 2)
146+ /// dim 1 (Activity) ← Predicate plane (bit 1)
147+ /// dim 2 (Affection) ← Object plane (bit 0)
148+ ///
149+ /// Once D7 `GrammarStyleConfig` surfaces per-style qualia expectations,
150+ /// `expected_qualia_footprint` will additionally look up the verb's row
151+ /// by (verb, tense) and merge that row's qualia footprint.
94152///
95153/// Returns `[0.0, 1.0]`:
96154/// - `0.0` = qualia exactly matches expected footprint (familiar domain).
@@ -134,13 +192,24 @@ fn qualia_to_binary_fingerprint(triangle: &GrammarTriangle) -> Vec<u64> {
134192
135193/// Expected qualia footprint for a given sentence structure.
136194///
137- /// Placeholder: zero-fingerprint = neutral expectation (every dimension
138- /// below 0.5). When the 144-cell verb table lands, this looks up the row
139- /// by (verb, tense) on `structure.triples[0]` and returns that row's
140- /// qualia footprint.
195+ /// Seeds bits 0-2 from the Pearl 2³ mask of the primary SPO triple:
196+ /// bit 0 ← Subject plane active (Agency qualia expected >= 0.5)
197+ /// bit 1 ← Predicate plane active (Activity qualia expected >= 0.5)
198+ /// bit 2 ← Object plane active (Affection qualia expected >= 0.5)
199+ ///
200+ /// When the 144-cell verb table lands, this will additionally look up
201+ /// the row by (verb, tense) on `structure.triples[0]` and merge that
202+ /// row's higher-dimensional qualia bits.
141203#[ cfg( feature = "grammar-triangle" ) ]
142- fn expected_qualia_footprint ( _structure : & SentenceStructure ) -> Vec < u64 > {
143- vec ! [ 0u64 ; 1 ]
204+ fn expected_qualia_footprint ( structure : & SentenceStructure ) -> Vec < u64 > {
205+ let pearl = causality_footprint_for ( structure) ;
206+ // Map Pearl mask bits (S=bit2, P=bit1, O=bit0) to qualia dimension
207+ // bits (dim0=Agency<-S, dim1=Activity<-P, dim2=Affection<-O).
208+ let mut packed: u64 = 0 ;
209+ if pearl & 0b100 != 0 { packed |= 1u64 << 0 ; } // S -> dim 0 (Agency)
210+ if pearl & 0b010 != 0 { packed |= 1u64 << 1 ; } // P -> dim 1 (Activity)
211+ if pearl & 0b001 != 0 { packed |= 1u64 << 2 ; } // O -> dim 2 (Affection)
212+ vec ! [ packed]
144213}
145214
146215/// Normalized Hamming distance between two `[u64]` registers.
@@ -164,19 +233,23 @@ fn hamming_normalized(a: &[u64], b: &[u64]) -> f32 {
164233#[ cfg( test) ]
165234mod tests {
166235 use super :: * ;
167- use crate :: parser:: SentenceStructure ;
168236 use crate :: spo:: SpoTriple ;
169237
170238 fn fixture_structure ( ) -> SentenceStructure {
171- // Build a minimal SentenceStructure via the public parse() entry.
172- // Use parser::parse on an empty token slice to get a default
173- // structure shape, then push one synthetic triple in.
174239 let empty: Vec < crate :: vocabulary:: Token > = Vec :: new ( ) ;
175240 let mut s = crate :: parser:: parse ( & empty) ;
176241 s. triples . push ( SpoTriple :: new ( 1 , 2 , 3 ) ) ;
177242 s
178243 }
179244
245+ /// Build a structure with a specific SPO triple.
246+ fn fixture_structure_with ( subject : u16 , predicate : u16 , object : u16 ) -> SentenceStructure {
247+ let empty: Vec < crate :: vocabulary:: Token > = Vec :: new ( ) ;
248+ let mut s = crate :: parser:: parse ( & empty) ;
249+ s. triples . push ( SpoTriple :: new ( subject, predicate, object) ) ;
250+ s
251+ }
252+
180253 #[ test]
181254 fn analyze_without_triangle_yields_zero_distance ( ) {
182255 let s = fixture_structure ( ) ;
@@ -185,16 +258,115 @@ mod tests {
185258 assert_eq ! ( out. triples. triples. len( ) , 1 ) ;
186259 }
187260
261+ #[ test]
262+ fn pearl_mask_transitive_is_spo ( ) {
263+ // "dog bites man" -- all three planes active -> 0b111 (Counterfactual)
264+ let triple = SpoTriple :: new ( 671 , 2943 , 95 ) ;
265+ assert_eq ! ( compute_pearl_mask( & triple) , 0b111 ) ;
266+ }
267+
268+ #[ test]
269+ fn pearl_mask_intransitive_is_sp ( ) {
270+ // "dog runs" -- no object -> 0b110 (Confounder Detection / intransitive)
271+ let triple = SpoTriple :: intransitive ( 671 , 100 ) ;
272+ assert_eq ! ( compute_pearl_mask( & triple) , 0b110 ) ;
273+ }
274+
275+ #[ test]
276+ fn causality_footprint_transitive_sentence ( ) {
277+ // Transitive sentence: S+P+O all present -> 0b111
278+ let s = fixture_structure_with ( 671 , 2943 , 95 ) ;
279+ let out = analyze_without_triangle ( s) ;
280+ assert_eq ! ( out. causality_footprint, 0b111 ) ;
281+ }
282+
283+ #[ test]
284+ fn causality_footprint_intransitive_sentence ( ) {
285+ // Intransitive sentence: S+P, no O -> 0b110
286+ let empty: Vec < crate :: vocabulary:: Token > = Vec :: new ( ) ;
287+ let mut s = crate :: parser:: parse ( & empty) ;
288+ s. triples . push ( SpoTriple :: intransitive ( 671 , 100 ) ) ;
289+ let out = analyze_without_triangle ( s) ;
290+ assert_eq ! ( out. causality_footprint, 0b110 ) ;
291+ }
292+
293+ #[ test]
294+ fn causality_footprint_empty_structure ( ) {
295+ // No triples -> 0b000 (aggregate prior)
296+ let empty: Vec < crate :: vocabulary:: Token > = Vec :: new ( ) ;
297+ let s = crate :: parser:: parse ( & empty) ;
298+ let out = analyze_without_triangle ( s) ;
299+ assert_eq ! ( out. causality_footprint, 0b000 ) ;
300+ }
301+
302+ /// PR-G1 spec test: two sentences with the SAME subject but DIFFERENT
303+ /// Pearl masks must produce different triangle outputs.
304+ ///
305+ /// Sentence A: "dog bites man" -> transitive -> SPO = 0b111 (Counterfactual)
306+ /// Sentence B: "dog runs" -> intransitive -> SP_ = 0b110 (Confounder)
307+ ///
308+ /// Same subject (671), different Pearl masks -> different causality_footprint.
309+ #[ test]
310+ fn same_subject_different_pearl_masks_yield_different_outputs ( ) {
311+ let s_transitive = fixture_structure_with ( 671 , 2943 , 95 ) ;
312+ let s_intransitive = {
313+ let empty: Vec < crate :: vocabulary:: Token > = Vec :: new ( ) ;
314+ let mut s = crate :: parser:: parse ( & empty) ;
315+ s. triples . push ( SpoTriple :: intransitive ( 671 , 100 ) ) ;
316+ s
317+ } ;
318+
319+ let out_a = analyze_without_triangle ( s_transitive) ;
320+ let out_b = analyze_without_triangle ( s_intransitive) ;
321+
322+ // Same subject
323+ assert_eq ! (
324+ out_a. triples. triples[ 0 ] . subject( ) ,
325+ out_b. triples. triples[ 0 ] . subject( ) ,
326+ "both sentences must share the same subject"
327+ ) ;
328+
329+ // Different Pearl masks -> different causality_footprint
330+ assert_ne ! (
331+ out_a. causality_footprint, out_b. causality_footprint,
332+ "transitive (0b{:03b}) vs intransitive (0b{:03b}) must differ" ,
333+ out_a. causality_footprint, out_b. causality_footprint
334+ ) ;
335+
336+ // Verify exact values
337+ assert_eq ! ( out_a. causality_footprint, 0b111 , "transitive = SPO" ) ;
338+ assert_eq ! ( out_b. causality_footprint, 0b110 , "intransitive = SP_" ) ;
339+ }
340+
341+ /// PR-G1 spec test: two sentences with same Subject, different Predicates
342+ /// producing the same Pearl mask structure (both transitive) but encoding
343+ /// different causal content.
344+ #[ test]
345+ fn same_subject_same_mask_different_predicates_distinguishable ( ) {
346+ // "dog bites man" vs "dog loves man" -- same mask (0b111), different predicate
347+ let s_a = fixture_structure_with ( 671 , 2943 , 95 ) ; // bites
348+ let s_b = fixture_structure_with ( 671 , 500 , 95 ) ; // loves
349+
350+ let out_a = analyze_without_triangle ( s_a) ;
351+ let out_b = analyze_without_triangle ( s_b) ;
352+
353+ // Same Pearl mask (both SPO)
354+ assert_eq ! ( out_a. causality_footprint, out_b. causality_footprint) ;
355+ assert_eq ! ( out_a. causality_footprint, 0b111 ) ;
356+
357+ // But different predicates -> different triples
358+ assert_ne ! (
359+ out_a. triples. triples[ 0 ] . predicate( ) ,
360+ out_b. triples. triples[ 0 ] . predicate( ) ,
361+ ) ;
362+ }
363+
188364 #[ cfg( feature = "grammar-triangle" ) ]
189365 #[ test]
190366 fn classification_distance_in_unit_interval ( ) {
191- // Identical fingerprints → 0.0; orthogonal (all bits flipped)
192- // within the 18 used bits → 18/64 = 0.28125; full-register
193- // orthogonality (every bit flipped) → 1.0.
194367 assert_eq ! ( hamming_normalized( & [ 0u64 ] , & [ 0u64 ] ) , 0.0 ) ;
195368 assert_eq ! ( hamming_normalized( & [ u64 :: MAX ] , & [ u64 :: MAX ] ) , 0.0 ) ;
196369 assert ! ( ( hamming_normalized( & [ 0u64 ] , & [ u64 :: MAX ] ) - 1.0 ) . abs( ) < 1e-6 ) ;
197- // 18 bits set vs. 0 bits → 18/64.
198370 let eighteen_bits = ( 1u64 << 18 ) - 1 ;
199371 let d = hamming_normalized ( & [ eighteen_bits] , & [ 0u64 ] ) ;
200372 assert ! ( ( d - ( 18.0 / 64.0 ) ) . abs( ) < 1e-6 ) ;
@@ -205,20 +377,15 @@ mod tests {
205377 fn analyze_with_triangle_stamps_lenses ( ) {
206378 let s = fixture_structure ( ) ;
207379 let out = analyze_with_triangle ( "the dog runs" , s) ;
208- // Real Hamming over 18-bit qualia footprint vs. zero expectation;
209- // result must be in [0, 1] and not a hardcoded 0.0.
210380 assert ! ( out. classification_distance >= 0.0 ) ;
211381 assert ! ( out. classification_distance <= 1.0 ) ;
212382 assert_eq ! ( out. triples. triples. len( ) , 1 ) ;
383+ assert_ne ! ( out. causality_footprint, 0 ) ;
213384 }
214385
215386 #[ cfg( feature = "grammar-triangle" ) ]
216387 #[ test]
217388 fn novel_domain_qualia_yields_high_distance ( ) {
218- // High-activation, high-novelty, high-urgency text should pull
219- // multiple qualia dimensions above the 0.5 threshold, producing
220- // a non-zero Hamming distance against the all-zero expected
221- // footprint (placeholder = neutral expectation).
222389 let s = fixture_structure ( ) ;
223390 let out = analyze_with_triangle (
224391 "Suddenly an unprecedented intense urgent novel surprising explosion!" ,
@@ -234,13 +401,8 @@ mod tests {
234401 #[ cfg( feature = "grammar-triangle" ) ]
235402 #[ test]
236403 fn qualia_fingerprint_thresholds_at_half ( ) {
237- // Build a triangle whose qualia coordinates straddle 0.5; the
238- // packed fingerprint must have exactly the bits at-or-above 0.5
239- // set.
240- let triangle = GrammarTriangle :: default ( ) ; // all coords = 0.5
404+ let triangle = GrammarTriangle :: default ( ) ;
241405 let fp = qualia_to_binary_fingerprint ( & triangle) ;
242- // Default = 0.5 on every dim → every bit set (>= 0.5 threshold),
243- // so packed register == 18 lowest bits set.
244406 let expected = ( 1u64 << 18 ) - 1 ;
245407 assert_eq ! ( fp[ 0 ] , expected) ;
246408 }
0 commit comments