@@ -440,6 +440,167 @@ pub fn parse_with_secondary(tokens: &[Token]) -> SentenceStructure {
440440 result
441441}
442442
443+ // ────────────────────────────────────────────────────────────────────────
444+ // Coverage-branch hook for D2 FailureTicket emission.
445+ //
446+ // Wraps the existing free-function parser in a thin newtype that owns the
447+ // coverage threshold (default 0.85; configurable later from D7
448+ // `GrammarStyleConfig`). When a parse falls below threshold, the hook
449+ // hands the partial off to `ticket_emit::emit_ticket` so the LLM-tail
450+ // router can route the failure-mode itself as the inference signal.
451+ //
452+ // `Parser::parse` is preserved verbatim against the free `parse()` so no
453+ // existing call sites break.
454+ // ────────────────────────────────────────────────────────────────────────
455+
456+ /// Default coverage threshold below which a parse triggers a FailureTicket.
457+ /// Mirrors `lance_graph_contract::grammar::LOCAL_COVERAGE_THRESHOLD` (0.9)
458+ /// minus a small slack so DeepNSM's looser FSM gets a chance.
459+ pub const DEFAULT_COVERAGE_THRESHOLD : f32 = 0.85 ;
460+
461+ /// A parse outcome plus the metrics needed to decide whether it should
462+ /// escalate to the LLM router.
463+ #[ derive( Clone , Debug ) ]
464+ pub struct ParseResult {
465+ /// Token-derived semantic structure.
466+ pub structure : SentenceStructure ,
467+ /// Coverage ∈ [0, 1]: classified-tokens / total-tokens.
468+ pub coverage : f32 ,
469+ /// Tokens the FSM successfully classified (rank-encoded).
470+ pub resolved_tokens : Vec < u16 > ,
471+ /// Tokens the FSM could not place (rank-encoded; OOV / unknown PoS).
472+ pub unresolved_tokens : Vec < u16 > ,
473+ /// NSM-prime count found in the resolved set. Drives Abduction routing.
474+ pub primes_found : u8 ,
475+ /// Distance vs. the SPO's expected qualia footprint (0.0 = identical).
476+ /// Filled by `triangle_bridge::compute_classification_distance` once
477+ /// the Triangle is wired; stays 0.0 in the bare-DeepNSM path.
478+ pub classification_distance : f32 ,
479+ }
480+
481+ /// Newtype around the FSM parser. Owns the coverage threshold so the
482+ /// LLM-tail policy is colocated with the parse decision instead of
483+ /// scattered across call sites.
484+ #[ derive( Clone , Debug ) ]
485+ pub struct Parser {
486+ coverage_threshold : f32 ,
487+ }
488+
489+ impl Default for Parser {
490+ fn default ( ) -> Self {
491+ Self {
492+ coverage_threshold : DEFAULT_COVERAGE_THRESHOLD ,
493+ }
494+ }
495+ }
496+
497+ impl Parser {
498+ /// Construct with the default 0.85 threshold.
499+ pub fn new ( ) -> Self {
500+ Self :: default ( )
501+ }
502+
503+ /// Override the coverage threshold (D7 `GrammarStyleConfig` will feed
504+ /// this once style-aware routing lands).
505+ pub fn with_threshold ( mut self , threshold : f32 ) -> Self {
506+ self . coverage_threshold = threshold. clamp ( 0.0 , 1.0 ) ;
507+ self
508+ }
509+
510+ /// Current coverage threshold ∈ [0, 1].
511+ pub fn coverage_threshold ( & self ) -> f32 {
512+ self . coverage_threshold
513+ }
514+
515+ /// Run the FSM and return the structure unchanged (preserves the
516+ /// existing public `parse()` shape for callers that don't need
517+ /// coverage metrics).
518+ pub fn parse ( & self , tokens : & [ crate :: vocabulary:: Token ] ) -> SentenceStructure {
519+ parse ( tokens)
520+ }
521+
522+ /// Coverage-aware parse: returns the structure plus the metrics
523+ /// `maybe_emit_ticket` needs.
524+ pub fn parse_with_coverage ( & self , tokens : & [ crate :: vocabulary:: Token ] ) -> ParseResult {
525+ let structure = parse ( tokens) ;
526+
527+ let mut resolved = Vec :: new ( ) ;
528+ let mut unresolved = Vec :: new ( ) ;
529+ let mut primes = 0u8 ;
530+ for t in tokens {
531+ match t. rank {
532+ Some ( r) => {
533+ 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 {
538+ primes = primes. saturating_add ( 1 ) ;
539+ }
540+ }
541+ None => unresolved. push ( 0u16 ) ,
542+ }
543+ }
544+
545+ let total = ( resolved. len ( ) + unresolved. len ( ) ) as f32 ;
546+ let coverage = if total == 0.0 {
547+ 0.0
548+ } else {
549+ resolved. len ( ) as f32 / total
550+ } ;
551+
552+ ParseResult {
553+ structure,
554+ coverage,
555+ resolved_tokens : resolved,
556+ unresolved_tokens : unresolved,
557+ primes_found : primes,
558+ classification_distance : 0.0 ,
559+ }
560+ }
561+
562+ /// Whether the result fell below the configured threshold.
563+ pub fn coverage_failed ( & self , parse_result : & ParseResult ) -> bool {
564+ parse_result. coverage < self . coverage_threshold
565+ }
566+
567+ /// D2 hook: if coverage falls below threshold, hand the partial off
568+ /// to `ticket_emit::emit_ticket` and return the FailureTicket. Above
569+ /// threshold returns `None` — the caller commits to AriGraph instead.
570+ ///
571+ /// Gated behind `contract-ticket` because the FailureTicket type
572+ /// lives in `lance_graph_contract`. With the feature off, the hook
573+ /// becomes a no-op `()` returner so the parser still compiles in
574+ /// minimal builds.
575+ #[ cfg( feature = "contract-ticket" ) ]
576+ pub fn maybe_emit_ticket (
577+ & self ,
578+ parse_result : & ParseResult ,
579+ ) -> Option < lance_graph_contract:: grammar:: FailureTicket > {
580+ if !self . coverage_failed ( parse_result) {
581+ return None ;
582+ }
583+ use lance_graph_contract:: grammar:: { PartialParse , TekamoloSlots } ;
584+ let partial = PartialParse {
585+ resolved_tokens : parse_result. resolved_tokens . clone ( ) ,
586+ unresolved_tokens : parse_result. unresolved_tokens . clone ( ) ,
587+ coverage : parse_result. coverage ,
588+ } ;
589+ // TekamoloSlots / Wechsel / CausalAmbiguity stay empty until D3
590+ // wires the Grammar Triangle; the ticket already routes correctly
591+ // on `primes_found` + `classification_distance`.
592+ Some ( crate :: ticket_emit:: emit_ticket (
593+ partial,
594+ parse_result. coverage ,
595+ parse_result. classification_distance ,
596+ parse_result. primes_found ,
597+ TekamoloSlots :: default ( ) ,
598+ Vec :: new ( ) ,
599+ None ,
600+ ) )
601+ }
602+ }
603+
443604#[ cfg( test) ]
444605mod tests {
445606 use super :: * ;
0 commit comments