5b0b29dfad
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>
173 lines
7.9 KiB
Go
173 lines
7.9 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/kami/maven/internal/llm"
|
||
)
|
||
|
||
// LocativeCompleter is the narrow resident-model seam the verifier needs.
|
||
// *llm.Client satisfies it. Keeping the interface here lets the safety and
|
||
// malformed-output paths run without a server in unit tests.
|
||
type LocativeCompleter interface {
|
||
Complete(context.Context, llm.Req) (string, error)
|
||
}
|
||
|
||
// LocativeAnswerVerifier is the model-backed second opinion for candidates the
|
||
// deterministic locative identity gate rejected. It never replaces that gate:
|
||
// exact structural accepts do not call it, and a missing model, timeout, error,
|
||
// or malformed verdict remains an abstention in the daemon.
|
||
type LocativeAnswerVerifier struct {
|
||
c LocativeCompleter
|
||
}
|
||
|
||
// LocativeVerdict keeps the two extracted referents as auditable evidence.
|
||
// The daemon consumes only Answerable; the live eval records all three fields
|
||
// so a yes/no score cannot hide what the model thought it was comparing.
|
||
type LocativeVerdict struct {
|
||
Target string `json:"target"`
|
||
MemorySubject string `json:"memory_subject"`
|
||
Answerable bool `json:"-"`
|
||
Raw string `json:"-"`
|
||
}
|
||
|
||
// NewLocativeAnswerVerifier returns nil when no resident completion seam exists.
|
||
// That is the ordinary no-model deployment and deliberately means abstain.
|
||
func NewLocativeAnswerVerifier(c LocativeCompleter) *LocativeAnswerVerifier {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
return &LocativeAnswerVerifier{c: c}
|
||
}
|
||
|
||
// locativeVerifierGrammar fixes both the shape and every variable-width field.
|
||
// target and memory_subject come before answer deliberately: the small resident
|
||
// model must identify the two referents before choosing the verdict instead of
|
||
// emitting an unconstrained first-token hunch.
|
||
const locativeVerifierGrammar = `
|
||
root ::= "{" ws "\"target\"" ws ":" ws string "," ws "\"memory_subject\"" ws ":" ws string "," ws "\"answer\"" ws ":" ws answer ws "}"
|
||
answer ::= "\"no\"" | "\"yes\""
|
||
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){1,80} "\""
|
||
ws ::= [ \t\n]{0,2}
|
||
`
|
||
|
||
// locativeVerifierSystem teaches a relation, not the held-out fixture. The
|
||
// examples use different entities from the measured nginx/token/disk/key and
|
||
// adversarial passport/shirt/box cases. Two negative examples pin the dangerous
|
||
// distinctions: a location object is not the proposition subject, and a shared
|
||
// generic noun with a conflicting complement is not the same referent.
|
||
const locativeVerifierSystem = `Ты — строгий классификатор логического следования для личной памяти. Вход — JSON с одним question и одним memory.
|
||
Сначала выдели target: конкретный предмет/событие, чьё место или источник спрашивают. Затем memory_subject: предмет/событие, МЕСТО КОТОРОГО сообщает память. Предмет после слов места (в, на, под, рядом с, inside, at, under) — это место/контейнер, а НЕ memory_subject.
|
||
answer=yes только если target и memory_subject — один и тот же конкретный референт и память прямо сообщает запрошенное место/источник. Настоящие синонимы и контекстные названия допустимы. Совпадение цвета, свойства, общего слова, контейнера, места или действия недостаточно. Уточнения принадлежности/состава не должны конфликтовать. Не используй внешние знания. Сомнение => no.
|
||
|
||
Примеры:
|
||
input: {"question":"где красная тетрадь?","memory":"зарядка лежит на красной тетради"}
|
||
output: {"target":"красная тетрадь","memory_subject":"зарядка","answer":"no"}
|
||
input: {"question":"где ключ от гаража?","memory":"ключ от офиса лежит под ковриком"}
|
||
output: {"target":"ключ от гаража","memory_subject":"ключ от офиса","answer":"no"}
|
||
input: {"question":"где дубликат ключа от мастерской?","memory":"запасной ключ мастерской лежит в ящике"}
|
||
output: {"target":"дубликат ключа от мастерской","memory_subject":"запасной ключ мастерской","answer":"yes"}
|
||
input: {"question":"where are the database settings?","memory":"the database configuration is in /etc/db"}
|
||
output: {"target":"database settings","memory_subject":"database configuration","answer":"yes"}
|
||
|
||
Верни только JSON требуемой формы.`
|
||
|
||
const (
|
||
locativeVerifierTimeout = 8 * time.Second
|
||
locativeVerifierMaxTokens = 128
|
||
locativeVerifierMaxField = 80
|
||
)
|
||
|
||
// Answerable implements the daemon's deliberately tiny verifier interface.
|
||
func (v *LocativeAnswerVerifier) Answerable(ctx context.Context, question, candidate string) (bool, error) {
|
||
verdict, err := v.Evaluate(ctx, question, candidate)
|
||
return verdict.Answerable, err
|
||
}
|
||
|
||
// Evaluate returns the bounded model verdict and its extracted referents.
|
||
// Callers must treat every error as false; it never manufactures a fallback.
|
||
func (v *LocativeAnswerVerifier) Evaluate(ctx context.Context, question, candidate string) (LocativeVerdict, error) {
|
||
if v == nil || v.c == nil {
|
||
return LocativeVerdict{}, errors.New("locative verifier: resident model unavailable")
|
||
}
|
||
input, err := json.Marshal(struct {
|
||
Question string `json:"question"`
|
||
Memory string `json:"memory"`
|
||
}{Question: question, Memory: candidate})
|
||
if err != nil {
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: encode input: %w", err)
|
||
}
|
||
|
||
callCtx, cancel := context.WithTimeout(ctx, locativeVerifierTimeout)
|
||
defer cancel()
|
||
raw, err := v.c.Complete(callCtx, llm.Req{
|
||
System: locativeVerifierSystem,
|
||
User: string(input),
|
||
Grammar: locativeVerifierGrammar,
|
||
MaxTokens: locativeVerifierMaxTokens,
|
||
RepeatPenalty: 1.1,
|
||
})
|
||
if err != nil {
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: complete: %w", err)
|
||
}
|
||
return parseLocativeVerdict(raw)
|
||
}
|
||
|
||
func parseLocativeVerdict(raw string) (LocativeVerdict, error) {
|
||
trimmed := strings.TrimSpace(raw)
|
||
var wire struct {
|
||
Target string `json:"target"`
|
||
MemorySubject string `json:"memory_subject"`
|
||
Answer string `json:"answer"`
|
||
}
|
||
dec := json.NewDecoder(strings.NewReader(trimmed))
|
||
dec.DisallowUnknownFields()
|
||
if err := dec.Decode(&wire); err != nil {
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: parse %q: %w", boundedRaw(trimmed), err)
|
||
}
|
||
var trailing any
|
||
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||
if err == nil {
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: trailing JSON in %q", boundedRaw(trimmed))
|
||
}
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: trailing data in %q: %w", boundedRaw(trimmed), err)
|
||
}
|
||
wire.Target = strings.TrimSpace(wire.Target)
|
||
wire.MemorySubject = strings.TrimSpace(wire.MemorySubject)
|
||
if wire.Target == "" || wire.MemorySubject == "" ||
|
||
utf8.RuneCountInString(wire.Target) > locativeVerifierMaxField ||
|
||
utf8.RuneCountInString(wire.MemorySubject) > locativeVerifierMaxField {
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: empty or oversized referent in %q", boundedRaw(trimmed))
|
||
}
|
||
var answerable bool
|
||
switch wire.Answer {
|
||
case "yes":
|
||
answerable = true
|
||
case "no":
|
||
answerable = false
|
||
default:
|
||
return LocativeVerdict{}, fmt.Errorf("locative verifier: invalid answer %q", wire.Answer)
|
||
}
|
||
return LocativeVerdict{
|
||
Target: wire.Target,
|
||
MemorySubject: wire.MemorySubject,
|
||
Answerable: answerable,
|
||
Raw: trimmed,
|
||
}, nil
|
||
}
|
||
|
||
func boundedRaw(s string) string {
|
||
const max = 240
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
return s[:max] + "…"
|
||
}
|