Skip to content

Commit 7013116

Browse files
committed
PR-G1: replace neutral 0.5 causality placeholder with real Pearl 2³ mask
Add `causality_footprint: u8` field to `SpoWithGrammar` carrying a 3-bit Pearl mask (S=bit2, P=bit1, O=bit0) derived from the primary SPO triple. Replace the zero-fingerprint `expected_qualia_footprint` placeholder with Pearl-seeded qualia dimension bits. Remove the `#[cfg(grammar-triangle)]` gate on the module so Pearl mask tests run without the optional dep. 8 tests pass (84 total in deepnsm --lib). https://claude.ai/code/session_01NYGrxVopyszZYgLBxe4hgj
1 parent 77c6292 commit 7013116

2 files changed

Lines changed: 203 additions & 39 deletions

File tree

crates/deepnsm/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ pub mod quantum_mode;
7777
#[cfg(feature = "contract-ticket")]
7878
pub mod ticket_emit;
7979

80-
#[cfg(feature = "grammar-triangle")]
80+
// PR-G1: module always compiled — Pearl mask computation and
81+
// analyze_without_triangle are core. Only GrammarTriangle-dependent
82+
// code inside is gated behind #[cfg(feature = "grammar-triangle")].
8183
pub mod triangle_bridge;
8284

8385
// ─── Re-exports ──────────────────────────────────────────────────────────────

crates/deepnsm/src/triangle_bridge.rs

Lines changed: 200 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
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
1413
use crate::parser::SentenceStructure;
14+
use crate::spo::{SpoTriple, NO_ROLE};
1515

1616
#[cfg(feature = "grammar-triangle")]
1717
use 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 {
57106
pub 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.
73125
pub 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)]
165234
mod 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

Comments
 (0)