Bug: a question writes invented knowledge into memory as a self fact, and recall then serves it back for unrelated questions #99

Merged
kami merged 5 commits from task/470-bug-a-question-writes-invented-knowledge into master 2026-08-03 20:11:13 +02:00
3 changed files with 224 additions and 6 deletions
Showing only changes of commit 4dfe106fe3 - Show all commits
+33 -6
View File
@@ -15,14 +15,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе."
}
// A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the
// model invented for it, at confidence 1.00, indexed for recall under the
// question's own text. Two such rows then claimed seven unrelated world
// questions through recall and silently disabled world answering.
//
// The routing error itself is not fixed here — the answer is to answer.
// Sending the turn down the query chain is what he asked for anyway, and
// it costs a mis-routed capture nothing: an explicit "запиши ..." is not
// question-shaped, so it never takes this branch.
if router.IsQuestionShaped(dec.Utterance) {
log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query",
dec.Utterance, dec.Slots.Key)
q := dec
q.Intent = router.IntentQuery
// The key the model extracted is its guess at what to store, not a
// fact he has. Left in place, queryFactByKey would read it back and
// claim the turn before any real source ran.
q.Slots.Key, q.Slots.HasKey = "", false
q.Slots.Value = ""
return h.actionQuery(ctx, q)
}
now := h.now()
req := ipc.WriteFactReq{
Ts: now,
Kind: "self",
Key: dec.Slots.Key,
Value: dec.Slots.Value,
Source: "tap:voice",
Confidence: 1.0,
Ts: now,
Kind: "self",
Key: dec.Slots.Key,
Value: dec.Slots.Value,
Source: "tap:voice",
// Not 1.00 unconditionally any more (#470). A value he said is
// evidence; a value the model supplied for words he never said is a
// guess, and writing a guess at full confidence is the same mistake
// the act path already refuses under "LLM output is not
// authorization".
Confidence: factConfidence(dec.Utterance, dec.Slots.Value),
// Subject: the key doubles as the entity-resolution candidate —
// a voice-tapped fact's key is usually the thing/person it's
// about ("espresso_machine", "kate"), so queueing it for Nexus
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"log"
"strings"
"unicode"
)
// ungroundedConfidence — what a self fact is worth when its value appears
// nowhere in what he said. Below `query_min_score` is not the point (recall
// gates on vector distance, not on this number); the point is that
// `/history` and every future reader can tell a value he said from a value
// the model supplied.
const ungroundedConfidence = 0.6
// factConfidence scores a self fact by whether its value is grounded in the
// utterance it came from. Grounded stays 1.00, which is what a tapped fact
// has always been worth. Ungrounded drops, and says so in the log.
//
// An empty value is grounded by definition: the key alone carries the fact
// ("поужинал"), and there is nothing for the model to have invented.
func factConfidence(utterance, value string) float64 {
if strings.TrimSpace(value) == "" {
return 1.0
}
if valueGrounded(utterance, value) {
return 1.0
}
log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f",
value, utterance, ungroundedConfidence)
return ungroundedConfidence
}
// valueGrounded reports whether every word of value traces back to a word he
// actually said. The comparison is on a 4-rune prefix, so the model's
// normalization survives ("пил воду" → "вода") while an invented value
// ("1.20" for a question about Go) does not.
func valueGrounded(utterance, value string) bool {
said := factTokens(utterance)
words := factTokens(value)
if len(words) == 0 {
return true
}
for _, w := range words {
if !anyTokenMatches(said, w) {
return false
}
}
return true
}
func anyTokenMatches(said []string, w string) bool {
for _, s := range said {
if s == w || sameStem(s, w) {
return true
}
}
return false
}
// sameStem is inflection tolerance and nothing more: it compares all but the
// last rune of the shorter word, and never fewer than three. Russian marks
// case on the ending, so "пил воду" and the stored "вода" are the same word he
// said, while "1.20" and "версия" are not. A word of three runes or fewer must
// match outright, where a shorter prefix would match half the language.
func sameStem(a, b string) bool {
ar, br := []rune(a), []rune(b)
shorter := min(len(ar), len(br))
n := shorter - 1
if n < 3 || len(ar) < n || len(br) < n {
return false
}
return string(ar[:n]) == string(br[:n])
}
// factTokens lowercases and splits on everything that is not a letter or a
// digit, the same shape planTokens uses in the router.
func factTokens(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
emb := router.NewHashEmbedder(1024)
h := &reactiveHandler{
api: api,
embedder: emb,
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
return h, api
}
// The write half of #470: a question routed to IntentFact must not become a
// fact about him, and must not leave a vector behind for recall to serve.
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
ctx := context.Background()
h, api := newFactGateHandler(t, time.Now())
reply := h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "какая последняя версия языка Go?",
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
})
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
t.Fatal("a question was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
if len(hits) != 0 {
t.Fatalf("the question was indexed for recall: %+v", hits)
}
// It went down the query chain instead. Nothing is configured to answer a
// world question in this harness, so "не знаю." is the honest outcome —
// what matters is that the turn was answered, not stored.
if reply == "" {
t.Fatal("the turn was neither stored nor answered")
}
}
// The capture that must survive the gate: an explicit instruction to record,
// even though it contains an interrogative.
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
ctx := context.Background()
h, api := newFactGateHandler(t, time.Now())
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я пил воду",
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
})
f, err := api.LatestFact(ctx, "water")
if err != nil {
t.Fatalf("an explicit capture was refused: %v", err)
}
if f.Confidence != 1.0 {
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
}
}
func TestFactConfidence(t *testing.T) {
cases := []struct {
utterance, value string
want float64
}{
{"запиши что я пил воду", `"вода"`, 1.0},
{"я выпил кофе", `"кофе"`, 1.0},
{"поужинал", "", 1.0},
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
}
for _, c := range cases {
if got := factConfidence(c.utterance, c.value); got != c.want {
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
}
}
}
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
t.Helper()
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
if err != nil {
t.Fatalf("embed %q: %v", text, err)
}
return vec
}