@@ -53,10 +53,14 @@ pub struct ContextChain {
5353/// existing consumers).
5454///
5555/// Empty-candidates contract: returns a sentinel result with
56- /// `candidate_count = 0`, `winner_index = usize::MAX`, a zero
57- /// `Binary16K` placeholder fingerprint, and `escalate_to_llm = true`.
58- /// Callers should check `candidate_count == 0` (or `escalate_to_llm`)
59- /// before reading `winner` / `chosen`.
56+ /// `candidate_count = 0`, `winner_index = usize::MAX`,
57+ /// `escalate_to_llm = true`, and either the caller-supplied
58+ /// `chosen_fingerprint` (when provided via
59+ /// `disambiguate_with_fingerprint` or
60+ /// `disambiguate_with_kernel_and_fingerprint`) or a zero `Binary16K`
61+ /// placeholder (the backwards-compatible default). Callers should
62+ /// check `candidate_count == 0` (or `escalate_to_llm`) before
63+ /// reading `winner` / `chosen`.
6064#[ derive( Debug , Clone ) ]
6165pub struct DisambiguationResult {
6266 pub chosen : CrystalFingerprint ,
@@ -310,6 +314,33 @@ impl ContextChain {
310314 self . disambiguate_with_kernel ( i, candidates, WeightingKernel :: default ( ) )
311315 }
312316
317+ /// Disambiguate with an externally-supplied fingerprint for the
318+ /// empty-candidates sentinel path. When `chosen_fingerprint` is
319+ /// `Some(fp)`, that fingerprint replaces the zero `Binary16K`
320+ /// placeholder in the sentinel result — allowing callers that have
321+ /// access to `MarkovBundler::role_bundle()` (in `deepnsm`) to
322+ /// inject the real bundled trajectory fingerprint without the
323+ /// contract crate taking a dependency on `deepnsm`.
324+ ///
325+ /// When `chosen_fingerprint` is `None`, falls back to the original
326+ /// zero-sentinel behaviour (backwards compatible).
327+ pub fn disambiguate_with_fingerprint < I > (
328+ & self ,
329+ i : usize ,
330+ candidates : I ,
331+ chosen_fingerprint : Option < CrystalFingerprint > ,
332+ ) -> DisambiguationResult
333+ where
334+ I : IntoIterator < Item = CrystalFingerprint > ,
335+ {
336+ self . disambiguate_with_kernel_and_fingerprint (
337+ i,
338+ candidates,
339+ WeightingKernel :: default ( ) ,
340+ chosen_fingerprint,
341+ )
342+ }
343+
313344 /// Kernel-aware variant of `disambiguate`. Identical contract; the
314345 /// supplied `kernel` is used when scoring each candidate replay via
315346 /// `total_coherence_with_kernel`.
@@ -319,6 +350,32 @@ impl ContextChain {
319350 candidates : I ,
320351 kernel : WeightingKernel ,
321352 ) -> DisambiguationResult
353+ where
354+ I : IntoIterator < Item = CrystalFingerprint > ,
355+ {
356+ self . disambiguate_with_kernel_and_fingerprint (
357+ i, candidates, kernel, None ,
358+ )
359+ }
360+
361+ /// Full variant: kernel-aware disambiguation with an optional
362+ /// externally-supplied fingerprint for the empty-candidates sentinel.
363+ ///
364+ /// When `chosen_fingerprint` is `Some(fp)`, the sentinel result uses
365+ /// `fp` instead of the zero `Binary16K` placeholder. This allows
366+ /// callers in `deepnsm` (which has access to `MarkovBundler`) to
367+ /// inject the real role-bundled trajectory fingerprint.
368+ ///
369+ /// When `chosen_fingerprint` is `None`, the sentinel falls back to
370+ /// the zero `Binary16K` — preserving backwards compatibility with
371+ /// all existing callers.
372+ pub fn disambiguate_with_kernel_and_fingerprint < I > (
373+ & self ,
374+ i : usize ,
375+ candidates : I ,
376+ kernel : WeightingKernel ,
377+ chosen_fingerprint : Option < CrystalFingerprint > ,
378+ ) -> DisambiguationResult
322379 where
323380 I : IntoIterator < Item = CrystalFingerprint > ,
324381 {
@@ -342,8 +399,15 @@ impl ContextChain {
342399 if scored. is_empty ( ) {
343400 // Documented sentinel — never panic; callers gate on
344401 // `escalate_to_llm` or `candidate_count == 0`.
345- let placeholder =
346- CrystalFingerprint :: Binary16K ( Box :: new ( [ 0u64 ; 256 ] ) ) ;
402+ //
403+ // When a `chosen_fingerprint` is supplied (e.g. from
404+ // `MarkovBundler::role_bundle()` in deepnsm), use it
405+ // instead of the zero placeholder. This is the PR-G3
406+ // bridge: the contract crate stays zero-dep while the
407+ // caller injects the real bundled trajectory fingerprint.
408+ let placeholder = chosen_fingerprint. unwrap_or_else ( || {
409+ CrystalFingerprint :: Binary16K ( Box :: new ( [ 0u64 ; 256 ] ) )
410+ } ) ;
347411 return DisambiguationResult {
348412 chosen : placeholder. clone ( ) ,
349413 coherence : 0.0 ,
@@ -804,6 +868,106 @@ mod tests {
804868 }
805869 }
806870
871+ // ── PR-G3 tests: real fingerprint in sentinel path ────────────────
872+
873+ /// `disambiguate_with_fingerprint` with empty candidates and a
874+ /// `Some(fp)` chosen_fingerprint propagates that fingerprint into
875+ /// the sentinel result instead of the zero placeholder.
876+ #[ test]
877+ fn g3_sentinel_uses_provided_fingerprint ( ) {
878+ let chain = fill_chain_with ( & mk_fp ( 0x1 ) ) ;
879+ let real_fp = mk_fp ( 0xBEEF_CAFE_DEAD_F00D ) ;
880+ let res = chain. disambiguate_with_fingerprint (
881+ 0 ,
882+ Vec :: < CrystalFingerprint > :: new ( ) ,
883+ Some ( real_fp. clone ( ) ) ,
884+ ) ;
885+ // Sentinel metadata is unchanged.
886+ assert_eq ! ( res. candidate_count, 0 ) ;
887+ assert_eq ! ( res. winner_index, usize :: MAX ) ;
888+ assert ! ( res. escalate_to_llm, "empty must still escalate" ) ;
889+ assert ! ( res. alternatives. is_empty( ) ) ;
890+ assert_eq ! ( res. coherence, 0.0 ) ;
891+ assert_eq ! ( res. margin, 0.0 ) ;
892+ assert_eq ! ( res. dispersion, 0.0 ) ;
893+ // Winner and chosen carry the provided fingerprint, NOT zeros.
894+ match ( & res. winner , & real_fp) {
895+ ( CrystalFingerprint :: Binary16K ( a) ,
896+ CrystalFingerprint :: Binary16K ( b) ) => {
897+ assert_eq ! ( * * a, * * b,
898+ "sentinel winner must be the provided fingerprint" ) ;
899+ }
900+ _ => panic ! ( "expected Binary16K variant" ) ,
901+ }
902+ match ( & res. chosen , & real_fp) {
903+ ( CrystalFingerprint :: Binary16K ( a) ,
904+ CrystalFingerprint :: Binary16K ( b) ) => {
905+ assert_eq ! ( * * a, * * b,
906+ "sentinel chosen must be the provided fingerprint" ) ;
907+ }
908+ _ => panic ! ( "expected Binary16K variant" ) ,
909+ }
910+ // Verify it's NOT all zeros.
911+ match & res. winner {
912+ CrystalFingerprint :: Binary16K ( bits) => {
913+ assert ! ( !bits. iter( ) . all( |& w| w == 0 ) ,
914+ "provided fingerprint must NOT be all-zero" ) ;
915+ }
916+ _ => unreachable ! ( ) ,
917+ }
918+ }
919+
920+ /// `disambiguate_with_fingerprint` with `None` falls back to
921+ /// the zero sentinel — same as the original `disambiguate`.
922+ #[ test]
923+ fn g3_sentinel_none_falls_back_to_zero ( ) {
924+ let chain = fill_chain_with ( & mk_fp ( 0x1 ) ) ;
925+ let res = chain. disambiguate_with_fingerprint (
926+ 0 ,
927+ Vec :: < CrystalFingerprint > :: new ( ) ,
928+ None ,
929+ ) ;
930+ assert_eq ! ( res. candidate_count, 0 ) ;
931+ assert_eq ! ( res. winner_index, usize :: MAX ) ;
932+ assert ! ( res. escalate_to_llm) ;
933+ match & res. winner {
934+ CrystalFingerprint :: Binary16K ( bits) => {
935+ assert ! ( bits. iter( ) . all( |& w| w == 0 ) ,
936+ "None should produce zero sentinel" ) ;
937+ }
938+ _ => panic ! ( "sentinel must be Binary16K" ) ,
939+ }
940+ }
941+
942+ /// `disambiguate_with_kernel_and_fingerprint` with non-empty
943+ /// candidates ignores the chosen_fingerprint (it only applies
944+ /// to the empty-candidates sentinel path).
945+ #[ test]
946+ fn g3_nonempty_candidates_ignore_chosen_fingerprint ( ) {
947+ let base = mk_fp ( 0x1111_2222_3333_4444 ) ;
948+ let mut chain = fill_chain_with ( & base) ;
949+ chain. fingerprints [ 3 ] = None ;
950+
951+ let injected = mk_fp ( 0xFFFF_FFFF_FFFF_FFFF ) ;
952+ let res = chain. disambiguate_with_kernel_and_fingerprint (
953+ 3 ,
954+ vec ! [ base. clone( ) ] ,
955+ WeightingKernel :: default ( ) ,
956+ Some ( injected) ,
957+ ) ;
958+ // With one candidate, the winner is that candidate, not the
959+ // injected fingerprint.
960+ assert_eq ! ( res. candidate_count, 1 ) ;
961+ match ( & res. winner , & base) {
962+ ( CrystalFingerprint :: Binary16K ( a) ,
963+ CrystalFingerprint :: Binary16K ( b) ) => {
964+ assert_eq ! ( * * a, * * b,
965+ "winner must be the actual candidate, not the injected fp" ) ;
966+ }
967+ _ => panic ! ( "expected Binary16K variant" ) ,
968+ }
969+ }
970+
807971 /// `WeightingKernel::default()` is `MexicanHat` (D4 chose this as
808972 /// the canonical kernel — focal-emphasizing with anticipation tail).
809973 #[ test]
0 commit comments