Files
claude 5b0b29dfad Make locative recall prove identity, not overlap (V-719)
The spare-key note scored 0.832 to 0.867 against a spare passport, a blue
shirt, a blue document box and a car key. Score and margin cannot separate
those: the right note runs 0.817 to 0.892 and the silent cases 0.787 to
0.874, so the ranges overlap and structure has to decide.

RecallAllowed now takes two structural facts from the router. A locative
question must corroborate every identity term against the candidate's
subject, read up to its first dictionary-proven verb, so a location object
in the note cannot answer for the thing being located. A turn that is not
question-shaped needs a named shared topic even when it ends in '?', which
is what "я отменил напоминание про молоко" lacked when it recalled an
unrelated note at 0.825 with no runner-up to fail the margin.

query_min_score moves 0.55 to 0.80 for tokenizer rev 2. The held-out
fixture answers 14/27 real recalls and 0/14 false ones.

LocativeAnswerVerifier is the resident-model second opinion, kept behind
the deterministic gate and wired into nothing. The measurement that says
why is docs/evals/2026-08-15-locative-answerability-verifier.md.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:01 +04:00

259 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"strings"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
var recallCaptureVerbs = func() map[string]bool {
out := map[string]bool{}
for _, w := range lexicon.CaptureVerbs() {
out[w] = true
}
return out
}()
// stopwords — words that carry no topic. A question and a note that share only
// these share nothing: "почему небо синее" and "сеть какая-то медленная" both
// contain "какая"-shaped filler and are about different worlds.
var stopwords = map[string]bool{
// interrogatives and demonstratives
"что": true, "чего": true, "какой": true, "какая": true, "какое": true,
"какие": true, "каких": true, "кто": true, "кого": true, "кому": true,
"почему": true, "зачем": true, "где": true, "куда": true, "откуда": true,
"когда": true, "сколько": true, "как": true, "то": true, "это": true,
"этот": true, "тот": true, "там": true, "тут": true, "такой": true,
// pronouns — every sentence he says is about him, so "я" is not a topic
"я": true, "меня": true, "мне": true, "мой": true, "моя": true, "мои": true,
"ты": true, "тебя": true, "тебе": true, "твой": true, "он": true, "она": true,
"они": true, "мы": true, "себя": true, "свой": true,
// prepositions, conjunctions, particles, copulas
"в": true, "во": true, "на": true, "с": true, "со": true, "у": true,
"о": true, "об": true, "про": true, "за": true, "из": true, "по": true,
"до": true, "от": true, "для": true, "над": true, "под": true, "при": true,
"и": true, "а": true, "но": true, "или": true, "же": true, "ли": true,
"не": true, "ни": true, "бы": true, "был": true, "была": true, "было": true,
"быть": true, "есть": true, "был-ли": true, "уже": true, "ещё": true,
"еще": true, "так": true, "вот": true, "там-же": true,
// English filler, for the mixed utterances he does say
"the": true, "a": true, "an": true, "is": true, "are": true, "was": true,
"were": true, "be": true, "of": true, "in": true, "on": true, "at": true,
"to": true, "for": true, "about": true, "and": true, "or": true, "not": true,
"what": true, "who": true, "why": true, "when": true, "where": true,
"which": true, "how": true, "i": true, "my": true, "me": true, "it": true,
"this": true, "that": true,
}
// firstPerson — the words that make an utterance a question about his own
// life. Not possession only: "как я восстановил конфиги" owns nothing and is
// still about him.
var firstPerson = map[string]bool{
"я": true, "меня": true, "мне": true, "мной": true, "мой": true,
"моя": true, "моё": true, "мое": true, "мои": true, "моего": true,
"моей": true, "моих": true, "моим": true, "себя": true, "свой": true,
"своя": true, "свои": true, "своего": true, "мною": true,
"i": true, "me": true, "my": true, "mine": true, "myself": true,
}
// RecallAllowed is the second half of the recall gate (#470). A hit that
// cleared the score and margin gate may still be about something else
// entirely: the held-out fixture puts the right note at 0.817-0.892 and the
// must-be-silent cases at 0.787-0.874, so no threshold sits between them, and
// a note about his slow network answered "почему небо синее?".
//
// The broad veto applies only to a question that mentions nothing of his. That
// restriction is what keeps the fix from costing more than it saves: recall
// exists to find the note whose words he no longer remembers, and demanding a
// shared word of every recall silences several right-note paraphrases on the
// fixture. A question about his own life keeps the embedder alone
// as its judge. A question about the world has to name something the memory
// actually mentions.
//
// openQuestion is the caller's structural evidence that the words ask for
// information: an interrogative or narrative request, not punctuation alone.
// It closes a different hole: a model can mis-route an ordinary first-person
// report as a query. With only one stored note there is no runner-up for the
// margin gate, so "я отменил напоминание про молоко" recalled an unrelated note
// at cosine 0.825. A report or polar proposition therefore needs a named topic
// shared with the hit, even when it ends in '?'. Nominal requests still work —
// "адрес домашнего сервера" shares its topic — while a statistical route alone
// cannot make an unrelated personal statement into a recall request.
//
// requireNamedTopic is the stricter locative frame: an answer to "where is X"
// must corroborate every identity term in X. One overlapping modifier or
// predicate is not enough: the spare-key note scores 0.832-0.867 for a spare
// passport, blue shirt, blue document box, and car key, while sharing words
// such as "spare", "blue", "box", or "key" (V-719). Verbs are grammar, not
// identity, and are excluded using the embedded morphology dictionary.
//
// The veto's price was re-measured on 2026-08-03 (#496,
// docs/evals/2026-08-03-recall-topic-veto.md). It costs one true recall and
// buys one false one, and the fixture pass count is the same either way. The
// lost case is an English paraphrase, not the cross-language loss it was
// reported as, and the fixture has no cross-language case at all. Do not add a
// script test or a bilingual stem map for it — both are no-ops here. The
// separating signal is semantic and belongs in a reranker, not in this file.
func RecallAllowed(query, text string, openQuestion, requireNamedTopic bool) bool {
shared := sharesNamedTopic(query, text)
if requireNamedTopic && !corroboratesNamedIdentity(query, text) {
return false
}
if !openQuestion && !shared {
return false
}
if mentionsHim(query) {
return true
}
return shared || len(contentWords(query)) == 0
}
// corroboratesNamedIdentity requires every non-frame, non-verb query term to
// occur in the candidate proposition's subject as the same dictionary word.
// Searching the whole candidate is unsafe: in "the spare key lies in the blue
// box", the box is a location, not the thing being located, and cannot answer
// "where is the blue box?". Locative predicates are deliberately excluded, so
// two memories do not become the same target merely because both things "lie"
// somewhere. Requiring all remaining terms preserves qualifiers too: a key is
// not a car key and a box is not a box of documents.
func corroboratesNamedIdentity(query, text string) bool {
q := identityWords(query)
if len(q) == 0 {
return false
}
t := propositionSubjectIdentity(text)
if len(t) == 0 {
return false
}
for _, want := range q {
found := false
for _, got := range t {
if want == got || morph.SameWord(want, got) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// propositionSubjectIdentity returns the identity phrase before the first
// dictionary-proven main verb. A leading capture imperative is storage frame,
// not the proposition: "remember: the spare key lies ..." has "spare key" as
// its subject. If no subject/predicate boundary can be proved, it returns nil;
// strict locative recall then abstains instead of treating a location object or
// incidental modifier anywhere in the note as the requested entity.
func propositionSubjectIdentity(s string) []string {
toks := wordTokens(s)
start := 0
for start < len(toks) && recallCaptureVerbs[toks[start]] {
start++
}
predicate := -1
for i := start; i < len(toks); i++ {
if morph.IsVerbForm(toks[i]) {
predicate = i
break
}
}
if predicate <= start {
return nil
}
return identityTokens(toks[start:predicate])
}
func identityWords(s string) []string {
return identityTokens(contentWords(s))
}
func identityTokens(words []string) []string {
out := make([]string, 0, len(words))
for _, w := range words {
if !stopwords[w] && !morph.IsVerbForm(w) {
out = append(out, w)
}
}
return out
}
func mentionsHim(query string) bool {
for _, w := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if firstPerson[w] {
return true
}
}
return false
}
// SharesContentWord reports whether query and text have at least one topic
// word in common, after dropping the words that carry no topic. Stems are
// compared, so the note and the question do not have to inflect alike.
func SharesContentWord(query, text string) bool {
q := contentWords(query)
if len(q) == 0 {
// Nothing to compare — a question made entirely of filler. The score
// gate is then the only judge it can have.
return true
}
return sharesContentWords(q, contentWords(text))
}
// sharesNamedTopic is the stricter form used for a non-question-shaped turn:
// an utterance made entirely of frame words names no topic, so it cannot use
// the score gate as its only evidence that a stored note should be spoken.
func sharesNamedTopic(query, text string) bool {
q := contentWords(query)
if len(q) == 0 {
return false
}
return sharesContentWords(q, contentWords(text))
}
func sharesContentWords(q, t []string) bool {
for _, a := range q {
for _, b := range t {
if a == b || sameStem(a, b) {
return true
}
}
}
return false
}
func contentWords(s string) []string {
var out []string
for _, w := range wordTokens(s) {
if !stopwords[w] {
out = append(out, w)
}
}
return out
}
func wordTokens(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// sameStem is inflection and derivation tolerance: Russian marks case and
// tense on the ending, and the note and the question rarely use the same form.
// "воду" and "вода" are the same water, "кормить" and "корм" the same feeding.
// All but the last rune of the shorter word must match, and never fewer than
// three, which is what keeps "сеть" clear of "сеанс".
func sameStem(a, b string) bool {
ar, br := []rune(a), []rune(b)
n := min(len(ar), len(br)) - 1
if n < 3 {
return false
}
return string(ar[:n]) == string(br[:n])
}