a claim carries evidence, not a verdict (V-565)

internal/claim holds the common unit: who wants the turn, the intent, the
slots it would fill, the tokens it explains, the tokens it declines, and why
it should not win. Specificity reads Consumed against Unexplained and
negative constraint reads Veto, so neither needs a float.

Four ordinal bands where a number is unavoidable, argued from measurement in
docs/plans/19-dialogue-arbitration.md. Anchored, structural, nearest,
vetoed. Nearest is one band and not a scale because the classifier's cosine
measured flat against correctness.

MoreSpecificThan puts coverage first and the band second. That is the fix
for the failure V-558 opened with: a pending reminder ate the Rome weather
question while explaining none of it.

The package imports nothing from the rest of Maven. internal/dialogue must
not import internal/router, so Intent is a plain string.
This commit is contained in:
2026-08-06 00:54:34 +04:00
parent 934a28d67c
commit e031f8f5f5
+192
View File
@@ -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
}