// Package claim is the common unit for the many claimants that compete for one // utterance (V-565, umbrella V-558, design in // docs/plans/19-dialogue-arbitration.md). // // Maven's cascade has twenty-two stage-0 grammars, seven router intents, // twenty-two query sources and seven stateful pre-emptors, and every one of them // answers "is this mine?" alone. None can answer "is this more mine than // yours?", because their scores are not comparable: stage 0 asserts 1.0 by // fiat, the classifier reports a cosine, the LLM router derives one from // structure. So list order is the whole arbitration. // // A Claim carries evidence rather than a verdict. Two things read that evidence // and neither needs a float: // // - specificity — a claim explaining more of the utterance is preferred, and // that is Consumed against Unexplained; // - negative constraint — a claimant may veto itself and say why, and that is // Veto. // // Where a number is unavoidable, it is an ordinal Band and not a probability. // The band set is argued from measurement in the plan doc: the classifier's // cosine is flat against correctness (62% at both ends of a spread 0.083 wide) // and its top-two margin is p50 0.009, so no claimant Maven has today can // produce a graded confidence. // // This package deliberately imports nothing from the rest of Maven. // internal/dialogue must not import internal/router, so a shared unit that // pulled in router.Intent would smuggle that edge back in. Intent is a plain // string and the conversion happens at each edge. package claim import "strings" // Band — the kind of evidence behind a claim, ordinal and comparable. Higher // wins a tie. Four values, because four is what the claimants can report. type Band int const ( // BandUnknown — the zero value. A claim that never set a band is a bug in // its builder, not a weak claim, so it must not silently rank as one. BandUnknown Band = iota // BandVetoed — the claimant will take the turn only if nobody else will, // and Veto says why it should not. The three arms of gateLLMDecision (a // fact with no key, an act with no allowlisted fn, a reminder with no // subject) land here. Still a claim: asking "о чём напомнить?" beats // silence. BandVetoed // BandNearest — the claim rests only on resemblance to something else, // with no anchor in the utterance and no structural check behind it. The // nearest-centroid classifier. One band and not a graded scale, because // the cosine measured flat against correctness. BandNearest // BandStructural — the claimant read the whole sentence and produced a // complete route, every slot its intent requires filled. The LLM router at // full confidence, and a stateful claimant holding a pending question. // Below BandAnchored on purpose: the stateful claimants pre-empt // unconditionally today, and that is the V-558 defect. There are seven of // them and preRouteLadder in cmd/mavend/decisiontrace.go is the roster. BandStructural // BandAnchored — a literal pattern anchored in the utterance matched, and // the matched span is what decides the intent. Stage 0 grammars and // query-source matchers. Certainty about the shape of the sentence, which // is not certainty about the answer. BandAnchored ) // String — the band's name, for a trace line and for a test failure that has to // say which band it got. func (b Band) String() string { switch b { case BandVetoed: return "vetoed" case BandNearest: return "nearest" case BandStructural: return "structural" case BandAnchored: return "anchored" default: return "unknown" } } // Claim — one claimant's bid for one utterance. type Claim struct { // Claimant — who wants the turn. A grammar name, a query source name, a // stage label. Read by the trace and by a test naming a loser. Claimant string // Intent — the route this claim would take. A plain string and not // router.Intent: see the package comment. Intent string // Filled — the slot names this claim would fill ("time", "fn", "key", // "text"). Names and not values, because arbitration compares shape. Filled []string // Consumed — the utterance tokens this claim explains, in the order they // appear. The numerator of specificity. Consumed []string // Unexplained — the tokens this claim does not explain, in order. Carried // rather than derived, so a claimant may decline a span it did match. Unexplained []string // Band — the kind of evidence. The tie-break, after coverage. Band Band // Veto — why this claim should NOT win, empty when there is none. A // non-empty Veto and a Band above BandVetoed is legal: a claim can be // well-evidenced and still name a reason to prefer somebody else. Veto string } // Coverage — the fraction of the utterance this claim explains, in [0,1]. A // claim with no tokens either way covers nothing; it is not division by zero // and it is not a full claim. func (c Claim) Coverage() float64 { total := len(c.Consumed) + len(c.Unexplained) if total == 0 { return 0 } return float64(len(c.Consumed)) / float64(total) } // Vetoed reports whether the claimant named a reason against itself. func (c Claim) Vetoed() bool { return c.Veto != "" } // MoreSpecificThan — the ordering V-560's arbiter will read. Coverage first, // because that is what fixes the failure this program opened with: a pending // reminder ate "какая сейчас погода в Риме?" as a time answer while explaining // none of it. Band only breaks a coverage tie. // // Deliberately NOT wired into the cascade by V-565. It is here so the ordering // is one function with tests on it, rather than a rule restated at each of the // sites that will eventually call it. func (c Claim) MoreSpecificThan(other Claim) bool { cc, oc := c.Coverage(), other.Coverage() if cc != oc { return cc > oc } return c.Band > other.Band } // Tokens — the utterance split for coverage accounting. Whitespace, then // trailing and leading punctuation, then lowercased. // // This is tokenization over the raw string and not a Russian pattern: it // contains no word list, and its output is a count rather than a fact or a // route (CLAUDE.md § Russian patterns). Lowercasing is Unicode-aware, so // Cyrillic folds the same way Latin does. func Tokens(utterance string) []string { fields := strings.FieldsFunc(utterance, func(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }) out := make([]string, 0, len(fields)) for _, f := range fields { t := strings.Trim(strings.ToLower(f), ".,!?;:()\"'«»…-–—") if t == "" { continue } out = append(out, t) } return out } // Split partitions the utterance's tokens into the ones a claim explains and // the rest, preserving order in both. A token is explained when it appears in // one of the spans the claimant filled (a slot value, a matched substring). // // Duplicates are handled by membership and not by count: "напомни напомни // позвонить" with span "напомни" explains both copies. The alternative is a // multiset, and no claimant Maven has can say which copy it meant. func Split(utterance string, spans ...string) (consumed, unexplained []string) { explained := map[string]bool{} for _, s := range spans { for _, t := range Tokens(s) { explained[t] = true } } for _, t := range Tokens(utterance) { if explained[t] { consumed = append(consumed, t) } else { unexplained = append(unexplained, t) } } return consumed, unexplained }