diff --git a/internal/claim/claim.go b/internal/claim/claim.go new file mode 100644 index 0000000..c704b4f --- /dev/null +++ b/internal/claim/claim.go @@ -0,0 +1,192 @@ +// 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 roughly ten stage-0 grammars, seven router intents, +// twenty-two query sources and four 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 four stateful claimants pre-empt + // unconditionally today, and that is the V-558 defect. + 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 +}