@@ -225,6 +225,37 @@ impl CausalEdge64 {
225225 | ( ( ( m as u8 as u64 ) & BITS3_MASK ) << CAUSAL_SHIFT ) ;
226226 }
227227
228+ /// Match if this edge's causal mask contains AT LEAST the bits in `query_mask`.
229+ ///
230+ /// `query_mask` is interpreted as the low 3 bits of the Pearl 2³ packing
231+ /// (S=0b100, P=0b010, O=0b001 — see [`CausalMask`]). Higher bits are ignored.
232+ ///
233+ /// This is the query-side predicate used by graph WHERE clauses to filter
234+ /// edges by causal type. For example, `query_mask = CausalMask::PO as u8`
235+ /// (`0b011`) matches every edge whose causal mask has at least the P and O
236+ /// planes active — i.e. interventional edges (`PO`) and counterfactual
237+ /// edges (`SPO`), but not pure association (`SO`).
238+ ///
239+ /// Semantics:
240+ /// - `query_mask == edge_mask`: full match
241+ /// - `query_mask` is a subset of `edge_mask`: subset match
242+ /// - `query_mask` and `edge_mask` are disjoint (sharing no required bits):
243+ /// no match
244+ /// - `query_mask == 0` (`CausalMask::None`): matches every edge — there
245+ /// are no required bits, so the predicate is vacuously satisfied.
246+ #[ inline( always) ]
247+ pub const fn matches_causal ( & self , query_mask : u8 ) -> bool {
248+ let q = query_mask & 0b111 ;
249+ let edge_mask = ( ( self . 0 >> CAUSAL_SHIFT ) & BITS3_MASK ) as u8 ;
250+ ( edge_mask & q) == q
251+ }
252+
253+ /// Type-safe variant of [`Self::matches_causal`] taking a [`CausalMask`].
254+ #[ inline( always) ]
255+ pub fn matches_causal_mask ( & self , query_mask : CausalMask ) -> bool {
256+ self . matches_causal ( query_mask as u8 )
257+ }
258+
228259 /// Is the S-plane active in the current causal projection?
229260 #[ inline( always) ]
230261 pub fn s_active ( self ) -> bool { ( self . 0 >> CAUSAL_SHIFT ) & 0b100 != 0 }
@@ -635,4 +666,91 @@ mod tests {
635666 assert_eq ! ( std:: mem:: size_of:: <CausalEdge64 >( ) , 8 ,
636667 "CausalEdge64 must be exactly 8 bytes" ) ;
637668 }
669+
670+ // ─── matches_causal: query-side Pearl 2³ predicate (TD-INT-7) ────
671+
672+ fn make_edge ( mask : CausalMask ) -> CausalEdge64 {
673+ CausalEdge64 :: pack (
674+ 10 , 20 , 30 , 200 , 200 ,
675+ mask, 0 , InferenceType :: Deduction ,
676+ PlasticityState :: ALL_FROZEN , 0 ,
677+ )
678+ }
679+
680+ #[ test]
681+ fn test_matches_causal_full_match ( ) {
682+ // query_mask == edge_mask: must match.
683+ let edge = make_edge ( CausalMask :: PO ) ;
684+ assert ! ( edge. matches_causal( CausalMask :: PO as u8 ) ) ;
685+ assert ! ( edge. matches_causal_mask( CausalMask :: PO ) ) ;
686+
687+ let edge_spo = make_edge ( CausalMask :: SPO ) ;
688+ assert ! ( edge_spo. matches_causal( CausalMask :: SPO as u8 ) ) ;
689+ }
690+
691+ #[ test]
692+ fn test_matches_causal_subset_match ( ) {
693+ // query_mask is a strict subset of edge_mask: must match.
694+ // SPO (0b111) contains PO (0b011), SO (0b101), SP (0b110), S, P, O.
695+ let edge = make_edge ( CausalMask :: SPO ) ;
696+ assert ! ( edge. matches_causal( CausalMask :: PO as u8 ) ,
697+ "SPO edge should match PO query (PO bits are subset of SPO)" ) ;
698+ assert ! ( edge. matches_causal( CausalMask :: SO as u8 ) ,
699+ "SPO edge should match SO query" ) ;
700+ assert ! ( edge. matches_causal( CausalMask :: P as u8 ) ,
701+ "SPO edge should match single-plane P query" ) ;
702+ assert ! ( edge. matches_causal_mask( CausalMask :: S ) ) ;
703+
704+ // PO (0b011) contains O (0b001) and P (0b010), but NOT S (0b100).
705+ let edge_po = make_edge ( CausalMask :: PO ) ;
706+ assert ! ( edge_po. matches_causal( CausalMask :: O as u8 ) ) ;
707+ assert ! ( edge_po. matches_causal( CausalMask :: P as u8 ) ) ;
708+ }
709+
710+ #[ test]
711+ fn test_matches_causal_non_match ( ) {
712+ // query_mask requires bits the edge does not have: must NOT match.
713+ // SO edge (0b101) does NOT have the P plane (0b010).
714+ let edge_so = make_edge ( CausalMask :: SO ) ;
715+ assert ! ( !edge_so. matches_causal( CausalMask :: P as u8 ) ) ;
716+ assert ! ( !edge_so. matches_causal( CausalMask :: PO as u8 ) ,
717+ "SO edge must not match PO query — P bit is missing" ) ;
718+ assert ! ( !edge_so. matches_causal_mask( CausalMask :: SPO ) ,
719+ "SO edge must not match SPO query — P bit is missing" ) ;
720+
721+ // P-only edge (0b010) does NOT match SO query (0b101).
722+ let edge_p = make_edge ( CausalMask :: P ) ;
723+ assert ! ( !edge_p. matches_causal( CausalMask :: SO as u8 ) ) ;
724+ assert ! ( !edge_p. matches_causal( CausalMask :: S as u8 ) ) ;
725+ assert ! ( !edge_p. matches_causal( CausalMask :: O as u8 ) ) ;
726+ }
727+
728+ #[ test]
729+ fn test_matches_causal_zero_mask_matches_anything ( ) {
730+ // query_mask == 0 has no required bits → vacuously matches every edge.
731+ // This is the documented semantics: zero is the predicate-true element
732+ // of the bit lattice (no requirements means nothing to fail).
733+ for variant in [
734+ CausalMask :: None , CausalMask :: O , CausalMask :: P , CausalMask :: PO ,
735+ CausalMask :: S , CausalMask :: SO , CausalMask :: SP , CausalMask :: SPO ,
736+ ] {
737+ let edge = make_edge ( variant) ;
738+ assert ! ( edge. matches_causal( 0 ) ,
739+ "zero query_mask must match edge with mask {variant:?}" ) ;
740+ assert ! ( edge. matches_causal_mask( CausalMask :: None ) ,
741+ "CausalMask::None query must match edge with mask {variant:?}" ) ;
742+ }
743+ }
744+
745+ #[ test]
746+ fn test_matches_causal_high_bits_ignored ( ) {
747+ // matches_causal must mask query down to the low 3 bits, so callers
748+ // passing a u8 with stray high bits get the same result as passing
749+ // the cleaned value.
750+ let edge = make_edge ( CausalMask :: PO ) ;
751+ // 0b1111_0011 → low 3 bits = 0b011 = PO.
752+ assert ! ( edge. matches_causal( 0b1111_0011 ) ) ;
753+ // 0b1111_0100 → low 3 bits = 0b100 = S — not present in PO edge.
754+ assert ! ( !edge. matches_causal( 0b1111_0100 ) ) ;
755+ }
638756}
0 commit comments