Skip to content

Commit c10f849

Browse files
committed
B5: D2+D3 ticket emission + Grammar Triangle bridge
1 parent c3681c6 commit c10f849

3 files changed

Lines changed: 480 additions & 0 deletions

File tree

crates/deepnsm/src/parser.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
444605
mod tests {
445606
use super::*;

crates/deepnsm/src/ticket_emit.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
//! META-AGENT: add `pub mod ticket_emit;` to lib.rs. Gate behind feature
2+
//! `contract-ticket`. Also requires adding to Cargo.toml:
3+
//! lance-graph-contract = { path = "../lance-graph-contract", optional = true }
4+
//! [features] contract-ticket = ["dep:lance-graph-contract"]
5+
//!
6+
//! Emit FailureTicket from a partial DeepNSM parse for the LLM-tail router.
7+
//!
8+
//! See plan §D2 + grammar-tiered-routing.md "Combined failure ticket".
9+
//!
10+
//! Adapted to the actual `lance_graph_contract::grammar` surface:
11+
//! - `PartialParse { resolved_tokens, unresolved_tokens, coverage }`
12+
//! - `FailureTicket { partial_parse, attempted_inference, recommended_next,
13+
//! causal_ambiguity, tekamolo, wechsel, coverage, missing_required }`
14+
//! - `TekamoloSlots { temporal, kausal, modal, lokal }` (no `has_unfillable`).
15+
//!
16+
//! `recommended_next` decision rules — the failure-mode IS the routing
17+
//! signal:
18+
//!
19+
//! - `primes_found < 4` → `Abduction` (NSM-thin → LLM names primes)
20+
//! - any TEKAMOLO slot unfillable → `CounterfactualSynthesis` (slot must be hypothesised)
21+
//! - `classification_distance > 0.7` → `Extrapolation` (novel domain marker)
22+
//! - else → `Revision` (default refinement)
23+
24+
#![cfg(feature = "contract-ticket")]
25+
26+
use lance_graph_contract::grammar::{
27+
CausalAmbiguity, FailureTicket, NarsInference, PartialParse, TekamoloSlots,
28+
WechselAmbiguity,
29+
};
30+
31+
/// Threshold above which `classification_distance` flags a novel domain.
32+
pub const NOVEL_DOMAIN_THRESHOLD: f32 = 0.7;
33+
34+
/// Minimum NSM primes required before a parse is considered semantically
35+
/// thick enough to NOT need LLM abduction.
36+
pub const PRIMES_NEEDED: u8 = 4;
37+
38+
/// Decompose a parse coverage failure into the SPO × 2³ × TEKAMOLO ×
39+
/// Wechsel fields the LLM router needs.
40+
///
41+
/// The caller checks `coverage_score >= LOCAL_COVERAGE_THRESHOLD` first —
42+
/// if so, no ticket is needed. Once we are here, we already know the
43+
/// parse failed coverage; we only need to choose the routing inference
44+
/// and stash the partial fields.
45+
pub fn emit_ticket(
46+
partial: PartialParse,
47+
coverage_score: f32,
48+
classification_distance: f32,
49+
primes_found: u8,
50+
tekamolo: TekamoloSlots,
51+
wechsel: Vec<WechselAmbiguity>,
52+
causal_ambiguity: Option<CausalAmbiguity>,
53+
) -> FailureTicket {
54+
let recommended = if primes_found < PRIMES_NEEDED {
55+
NarsInference::Abduction
56+
} else if has_unfillable(&tekamolo) {
57+
NarsInference::CounterfactualSynthesis
58+
} else if classification_distance > NOVEL_DOMAIN_THRESHOLD {
59+
NarsInference::Extrapolation
60+
} else {
61+
NarsInference::Revision
62+
};
63+
64+
FailureTicket {
65+
partial_parse: partial,
66+
attempted_inference: NarsInference::Deduction,
67+
recommended_next: recommended,
68+
causal_ambiguity,
69+
tekamolo,
70+
wechsel,
71+
coverage: coverage_score,
72+
missing_required: Vec::new(),
73+
}
74+
}
75+
76+
/// A TEKAMOLO slot is "unfillable" when the parser has none of the four
77+
/// adverbials filled. Local copy of the rule until the contract surfaces
78+
/// a more granular per-slot resolution flag.
79+
fn has_unfillable(slots: &TekamoloSlots) -> bool {
80+
slots.is_empty()
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use lance_graph_contract::grammar::wechsel::WechselRole;
87+
88+
fn empty_partial() -> PartialParse {
89+
PartialParse {
90+
resolved_tokens: vec![1, 2],
91+
unresolved_tokens: vec![3, 4],
92+
coverage: 0.5,
93+
}
94+
}
95+
96+
fn filled_tekamolo() -> TekamoloSlots {
97+
TekamoloSlots {
98+
temporal: Some((0, 1)),
99+
kausal: Some((2, 3)),
100+
modal: Some((4, 5)),
101+
lokal: Some((6, 7)),
102+
}
103+
}
104+
105+
#[test]
106+
fn low_primes_routes_to_abduction() {
107+
let t = emit_ticket(
108+
empty_partial(),
109+
0.6,
110+
0.1,
111+
2,
112+
filled_tekamolo(),
113+
Vec::new(),
114+
None,
115+
);
116+
assert_eq!(t.recommended_next, NarsInference::Abduction);
117+
assert_eq!(t.coverage, 0.6);
118+
}
119+
120+
#[test]
121+
fn unfillable_tekamolo_routes_to_counterfactual_synthesis() {
122+
let t = emit_ticket(
123+
empty_partial(),
124+
0.6,
125+
0.1,
126+
5,
127+
TekamoloSlots::default(),
128+
Vec::new(),
129+
None,
130+
);
131+
assert_eq!(t.recommended_next, NarsInference::CounterfactualSynthesis);
132+
}
133+
134+
#[test]
135+
fn high_classification_distance_routes_to_extrapolation() {
136+
let t = emit_ticket(
137+
empty_partial(),
138+
0.7,
139+
0.85,
140+
5,
141+
filled_tekamolo(),
142+
Vec::new(),
143+
None,
144+
);
145+
assert_eq!(t.recommended_next, NarsInference::Extrapolation);
146+
}
147+
148+
#[test]
149+
fn default_path_is_revision() {
150+
let t = emit_ticket(
151+
empty_partial(),
152+
0.7,
153+
0.1,
154+
5,
155+
filled_tekamolo(),
156+
Vec::new(),
157+
None,
158+
);
159+
assert_eq!(t.recommended_next, NarsInference::Revision);
160+
}
161+
162+
#[test]
163+
fn wechsel_payload_passes_through() {
164+
let amb = WechselAmbiguity {
165+
token_index: 3,
166+
candidates: vec![WechselRole::PrepTemporal, WechselRole::PrepSpatial],
167+
local_ambiguity: 0.85,
168+
};
169+
let t = emit_ticket(
170+
empty_partial(),
171+
0.6,
172+
0.1,
173+
2,
174+
filled_tekamolo(),
175+
vec![amb],
176+
None,
177+
);
178+
assert_eq!(t.wechsel.len(), 1);
179+
assert_eq!(t.wechsel[0].token_index, 3);
180+
}
181+
}

0 commit comments

Comments
 (0)