@@ -527,18 +527,20 @@ impl Parser {
527527 let mut resolved = Vec :: new ( ) ;
528528 let mut unresolved = Vec :: new ( ) ;
529529 let mut primes = 0u8 ;
530- for t in tokens {
530+ for ( idx , t ) in tokens. iter ( ) . enumerate ( ) {
531531 match t. rank {
532532 Some ( r) => {
533533 resolved. push ( r) ;
534- // NSM primes occupy fixed low ranks in the COCA
535- // vocabulary (62/63 of them per lib.rs header).
536- // Treat r < 64 as a primes-found heuristic.
537- if r < 64 {
534+ // Use the curated NSM-prime ID set rather than the
535+ // earlier `r < 64` heuristic. See nsm_primes.rs.
536+ if crate :: nsm_primes:: is_nsm_prime ( r as u16 ) {
538537 primes = primes. saturating_add ( 1 ) ;
539538 }
540539 }
541- None => unresolved. push ( 0u16 ) ,
540+ // Preserve token identity: push the original token's
541+ // sentence index so the failure-ticket can name which
542+ // position was OOV instead of degenerating to all-zeros.
543+ None => unresolved. push ( idx as u16 ) ,
542544 }
543545 }
544546
@@ -580,7 +582,7 @@ impl Parser {
580582 if !self . coverage_failed ( parse_result) {
581583 return None ;
582584 }
583- use lance_graph_contract:: grammar:: { PartialParse , TekamoloSlots } ;
585+ use lance_graph_contract:: grammar:: { NarsInference , PartialParse , TekamoloSlots } ;
584586 let partial = PartialParse {
585587 resolved_tokens : parse_result. resolved_tokens . clone ( ) ,
586588 unresolved_tokens : parse_result. unresolved_tokens . clone ( ) ,
@@ -589,6 +591,8 @@ impl Parser {
589591 // TekamoloSlots / Wechsel / CausalAmbiguity stay empty until D3
590592 // wires the Grammar Triangle; the ticket already routes correctly
591593 // on `primes_found` + `classification_distance`.
594+ // The local pipeline default-attempted Deduction; downstream
595+ // callers can plumb a different mode via a future config hook.
592596 Some ( crate :: ticket_emit:: emit_ticket (
593597 partial,
594598 parse_result. coverage ,
@@ -597,6 +601,7 @@ impl Parser {
597601 TekamoloSlots :: default ( ) ,
598602 Vec :: new ( ) ,
599603 None ,
604+ NarsInference :: Deduction ,
600605 ) )
601606 }
602607}
@@ -705,3 +710,104 @@ mod tests {
705710 assert ! ( !result. negations. is_empty( ) ) ;
706711 }
707712}
713+
714+ #[ cfg( test) ]
715+ mod parser_coverage_tests {
716+ //! HIGH-priority coverage for the public `Parser` surface.
717+ //!
718+ //! These exercise `parse_with_coverage` + `coverage_failed` +
719+ //! `maybe_emit_ticket` so the LLM-tail policy is regression-tested
720+ //! against the new `is_nsm_prime` heuristic and the per-position
721+ //! `unresolved_tokens` identity preservation.
722+ use super :: * ;
723+ use crate :: pos:: PoS ;
724+ use crate :: vocabulary:: Token ;
725+
726+ fn tok ( rank : Option < u16 > , pos : PoS , surface : & str ) -> Token {
727+ Token {
728+ rank,
729+ pos,
730+ position : 0 ,
731+ is_negated : false ,
732+ surface : surface. to_string ( ) ,
733+ }
734+ }
735+
736+ fn nsm_prime_rank ( ) -> u16 {
737+ // Borrow a known prime-rank from the curated set so the test
738+ // remains correct even if the seed list shifts.
739+ * crate :: nsm_primes:: NSM_PRIME_IDS
740+ . iter ( )
741+ . next ( )
742+ . expect ( "NSM prime set must be non-empty" )
743+ }
744+
745+ #[ test]
746+ fn coverage_threshold_default_is_0_85 ( ) {
747+ assert_eq ! ( DEFAULT_COVERAGE_THRESHOLD , 0.85 ) ;
748+ let p = Parser :: new ( ) ;
749+ assert ! ( ( p. coverage_threshold( ) - 0.85 ) . abs( ) < f32 :: EPSILON ) ;
750+ }
751+
752+ #[ test]
753+ fn parse_with_coverage_above_threshold_no_ticket ( ) {
754+ // All tokens resolve → coverage == 1.0 → no ticket.
755+ let prime = nsm_prime_rank ( ) ;
756+ let tokens = vec ! [
757+ tok( Some ( prime) , PoS :: Pronoun , "i" ) ,
758+ tok( Some ( 100 ) , PoS :: Verb , "see" ) ,
759+ tok( Some ( 200 ) , PoS :: Noun , "thing" ) ,
760+ ] ;
761+ let parser = Parser :: new ( ) ;
762+ let result = parser. parse_with_coverage ( & tokens) ;
763+ assert ! ( result. coverage >= parser. coverage_threshold( ) ) ;
764+ assert ! ( !parser. coverage_failed( & result) ) ;
765+ #[ cfg( feature = "contract-ticket" ) ]
766+ {
767+ assert ! ( parser. maybe_emit_ticket( & result) . is_none( ) ) ;
768+ }
769+ }
770+
771+ #[ test]
772+ fn parse_with_coverage_below_threshold_emits_ticket ( ) {
773+ // Mostly OOV (rank: None) → coverage drops far below 0.85.
774+ let tokens = vec ! [
775+ tok( None , PoS :: Noun , "xyzzy" ) ,
776+ tok( None , PoS :: Noun , "plugh" ) ,
777+ tok( None , PoS :: Verb , "fnord" ) ,
778+ tok( Some ( 2943 ) , PoS :: Verb , "bites" ) ,
779+ ] ;
780+ let parser = Parser :: new ( ) ;
781+ let result = parser. parse_with_coverage ( & tokens) ;
782+ assert ! ( parser. coverage_failed( & result) ) ;
783+ // 1/4 resolved → coverage == 0.25.
784+ assert ! ( result. coverage < 0.5 ) ;
785+ // Token-identity preservation: unresolved_tokens carry the
786+ // original sentence positions, not zeros.
787+ assert_eq ! ( result. unresolved_tokens, vec![ 0u16 , 1u16 , 2u16 ] ) ;
788+
789+ #[ cfg( feature = "contract-ticket" ) ]
790+ {
791+ let ticket = parser. maybe_emit_ticket ( & result) ;
792+ assert ! ( ticket. is_some( ) ) ;
793+ let t = ticket. unwrap ( ) ;
794+ // No NSM primes in the resolved set → primes_found low,
795+ // routing should land on Abduction.
796+ assert ! ( t. partial_parse. coverage < 0.5 ) ;
797+ }
798+ }
799+
800+ #[ test]
801+ fn unresolved_tokens_preserve_position_identity ( ) {
802+ // Mixed resolved/unresolved: positions of OOV tokens are 0 and 2.
803+ let tokens = vec ! [
804+ tok( None , PoS :: Noun , "blarf" ) ,
805+ tok( Some ( 100 ) , PoS :: Verb , "is" ) ,
806+ tok( None , PoS :: Noun , "wibble" ) ,
807+ ] ;
808+ let parser = Parser :: new ( ) ;
809+ let result = parser. parse_with_coverage ( & tokens) ;
810+ assert_eq ! ( result. unresolved_tokens, vec![ 0u16 , 2u16 ] ) ;
811+ assert_eq ! ( result. resolved_tokens, vec![ 100u16 ] ) ;
812+ }
813+ }
0 commit comments