Files
Cyrene_v2/internal/runtime/memory/preinsert_gate.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

341 lines
15 KiB
Go
Raw 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 (
"context"
"fmt"
"sort"
"time"
)
// 本文件是 D3「入库前审查」的**同步入库闸**(主人 2026-10-07 裁定:「把记忆整理
// 功能接到昔涟和记忆库中间做审查,由整理功能进行入库」)。
//
// 与 preinsert.go 的**异步**审查(落库之后由后台队列补审)是**两层**,不是替代:
//
// - **闸(本文件)**:`Store.Add` 的写路径上当场判定。确定性判据命中 ⇒ 那一行
// 直接落 `status='merged'`(**照常插入、一个字不丢、可 revert**),因此
// `active` 集合**当场就是干净的**,面板/召回不再「先脏后清」;
// - **异步队列(preinsert.go)**:闸没拦下的(真新 / 拿不准)照旧投入既有队列,
// 由后台用**语义召回**再判一次(闸只用零依赖词项余弦,见下)。
//
// 三条硬规格逐条落在这里:
//
// 1. **有界**:总墙钟预算 `defaultReviewGateBudget`(代码默认值)+ 候选扫描上限
// `defaultReviewGateScanLimit`(比异步侧更紧)。**超预算 / 候选源不可用 ⇒
// fail-open:照常插入 + 留痕**(绝不静默丢记忆)。🔴 **绝不把远端 embedding
// 摆在同步路径上做决定**:远端 embedder 一次 2.3~3.0 s,且对同一文本返回
// 不可复现的向量(TIDY-2 实测)⇒ 闸的候选召回只用零依赖的词项余弦。
// 2. **判定以规则层确定性为主**:复用 `judgeIngest`(→ `tidy_rules.go` /
// `tidy_containment.go`),**不另写第二套规则**;相似度只用于召回候选与
// 排序选存活行,绝不单凭一个分数改变落库形态。
// 3. **三态落库**:确定性判据命中 ⇒ 落 `merged`(不是「不插入」);真新 ⇒ 正常
// insert;拿不准 ⇒ 正常 insert + 投既有队列。落 `merged` 保住 `revert`
// (`design/23 §8`:行永不物理删除 + 可回滚)。
const (
// defaultReviewGateBudget 是同步入库闸的**总墙钟预算**(任务书规格 1 的
// 「量级 100~300ms」)。取值依据见本文件末尾的实测登记:闸的全部工作量是
// 「一条有界 SELECT + O(n) 词项余弦 + O(n) 包含判定」,实测 p95 在**个位数
// 毫秒**量级;150 ms 给它留了约两个数量级的余量,同时远小于「一次远端
// embedding 的 2.3~3.0 s」—— 因此「超预算」只可能来自候选源本身异常,
// 那时 fail-open 才是正确解。
defaultReviewGateBudget = 150 * time.Millisecond
// defaultReviewGateScanLimit 是同步入库闸的候选扫描上限。它比异步队列的
// `ScanLimit`(默认 200)**更紧**:闸在写入路径上,宁可少比几条也不让
// 一次写入多花几十毫秒;且最终会与 cfg.ScanLimit 取小(见 gateScanLimit)。
defaultReviewGateScanLimit = 100
// gateAuditVerb 是同步入库闸的审计动作名(任务书「沿用既有 `memory.review.*`
// 族或新增 `memory.ingest.gate`」⇒ 取后者):单独一个动作名,使「闸当场拦下
// 的」与「异步审查事后合并的」在审计链上可分。只带计数与原因码,不带正文。
gateAuditVerb = "memory.ingest.gate"
// gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。
gateTraceAbsorbed = "gate:absorbed"
// gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。
gateTraceBudgetExceeded = "gate:budget_exceeded"
// gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。
gateTraceUnavailable = "gate:candidates_unavailable"
)
// ingestPlanAction 是入库闸对一条待落库记忆给出的落法(任务书规格 3)。
type ingestPlanAction string
const (
// ingestPlanInsert 是真新 / 拿不准:正常 insert(active),并投既有异步队列。
ingestPlanInsert ingestPlanAction = "insert"
// ingestPlanAbsorbNew 是确定性判据命中且**新行是子集**(skip / merge):
// 照常落库,但直接落 `status='merged' + merged_into=<存活行>`,因此它**从不
// 进 active 集合**(active 计数不变)。
ingestPlanAbsorbNew ingestPlanAction = "absorb_new"
// ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常
// insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。
ingestPlanAbsorbOld ingestPlanAction = "absorb_old"
)
// ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest
// 总会给出一个显式动作。
type ingestGatePlan struct {
Action ingestPlanAction
Decision IngestDecision
// Survivor 是存活方(absorb_new / absorb_old 时非零)。
Survivor MemoryItem
// Absorbed 是被吸收的那一行(仅 absorb_old 用;absorb_new 时被吸收的是
// **刚插进去的那一行**,此处为零值)。
Absorbed MemoryItem
Reason string
Veto TidyVetoReason
Score float64
Mode string
Degraded bool
// Async 报告这条落库后是否仍投既有异步审查队列。只有「真新 / 拿不准 /
// 闸关闭」为 true;闸挡下的那条**绝不再投**(否则同一件事两条路径)。
Async bool
// Trace 是闸自己留下的原因码(空 = 闸没做决定,不留痕)。
Trace string
// Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定**
// (否则同一批数据会有两个结论,幂等性就没了)。
Elapsed time.Duration
}
// absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
func (p ingestGatePlan) absorbedID() string {
if p.Action == ingestPlanAbsorbNew {
return p.Survivor.ID
}
return ""
}
// insertStatus 返回新行落库时的 status。
func (p ingestGatePlan) insertStatus() string {
if p.Action == ingestPlanAbsorbNew {
return ingestStatusMerged
}
return tidyStatusActive
}
// planIngest 在写入路径上跑同步入库闸。未接线审查(store 上没有 reviewer)、
// 总开关关闭、或闸被显式关掉时,它返回「只插不审」的 passthrough 计划 ——
// 写入路径因此逐字回到本次改动之前。
func (s *Store) planIngest(ctx context.Context, item MemoryItem) ingestGatePlan {
r := s.ingestReviewer()
if !r.gateEnabled() {
return ingestGatePlan{Action: ingestPlanInsert, Decision: IngestCreate, Async: true}
}
return r.gate(ctx, item)
}
// gate 是闸本体:在**有界**预算内召回同分区候选、用确定性规则判定,并给出
// 三态落库计划。任何异常(超预算 / 候选源不可用)都走 fail-open。
func (r *PreInsertReviewer) gate(ctx context.Context, item MemoryItem) ingestGatePlan {
start := time.Now()
if nonOwnerSource(item.SourceConversation) {
// 群聊 / 陌生人来源:只读不写(design §6 / §15 D3-7 + AGENTS §9)。
// 既不合并、也不留痕、也不投队列(异步侧同样会拒它)—— 照常 insert。
return ingestGatePlan{Action: ingestPlanInsert, Decision: IngestCreate,
Reason: "restricted_source", Elapsed: time.Since(start)}
}
gctx, cancel := context.WithTimeout(ctx, r.cfg.GateBudget)
defer cancel()
cands, err := r.gateCandidates(gctx, item)
if err != nil {
reason := gateTraceUnavailable
if gctx.Err() != nil {
reason = gateTraceBudgetExceeded
}
r.log.Warn("memory ingest gate failed open, item inserted unreviewed",
"item_id", item.ID, "scope", string(item.Scope), "reason", reason, "error", err)
return r.failOpenPlan(reason, start)
}
plan := r.planFromVerdict(item, judgeIngest(item, cands))
plan.Elapsed = time.Since(start)
if plan.Elapsed > r.cfg.GateBudget {
// 预算超了但结果已经算出来(判定在读完候选之后是纯 CPU):如实记一条
// WARN,但**不丢结论** —— 少算的只是「没来得及比的那些候选」,而那条
// 风险由既有异步队列兜住(真新照旧投队列)。
r.log.Warn("memory ingest gate exceeded its wall-clock budget",
"item_id", item.ID, "elapsed_ms", plan.Elapsed.Milliseconds(),
"budget_ms", r.cfg.GateBudget.Milliseconds())
}
return plan
}
// planFromVerdict 把确定性判定映射成三态落库计划(任务书规格 3 的表格)。
func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) ingestGatePlan {
plan := ingestGatePlan{
Decision: v.Decision, Reason: v.Reason, Veto: v.Veto, Score: v.Score, Mode: v.Mode,
}
switch v.Decision {
case IngestSkip, IngestMerge:
// 被吸收的是**新行**,存活方是既有行(design §15 D3-2 T0-1/T0-2)。
plan.Action = ingestPlanAbsorbNew
plan.Survivor = v.Existing
plan.Absorbed = item
plan.Trace = gateTraceAbsorbed
case IngestModify:
// 既有行是新条目的子集 ⇒ 新行承载更全的内容、旧行置为被合并
// (design §15 D3-6「改写必须是新行 + 旧行置为被合并」)。
plan.Action = ingestPlanAbsorbOld
plan.Survivor = item
plan.Absorbed = v.Existing
plan.Trace = gateTraceAbsorbed
default:
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
plan.Action = ingestPlanInsert
plan.Async = true
}
return plan
}
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
// 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。
func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan {
r.bump(func(s *ReviewStats) { s.GateFailed++ })
return ingestGatePlan{
Action: ingestPlanInsert,
Decision: IngestCreate,
Reason: reason,
Async: true,
Trace: reason,
Elapsed: time.Since(start),
}
}
// gateCandidates 是同步闸的候选召回。它与异步侧(recallCandidates)**共用**
// 阈值、相似度实现(lexicalSimilarity)与包含判定,只有两点不同,且都是规格
// 要求的:
//
// - **绝不调用 embedder**:远端 embedding 在同步写路径上不可接受(规格 1),
// 因此闸恒用零依赖的词项余弦;它不是「降级」,而是同步路径的既定形态;
// - **候选收集是 O(n) 而不是 O(n²)**:异步侧复用 collectTidyPairs 会对**全部**
// 两两对算相似度(含既有-既有),在写路径上白烧;闸只需要「新条目 vs 既有」
// 这 n 对(见 gatePairs,输出与 reviewPairs 逐字等价)。
func (r *PreInsertReviewer) gateCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, error) {
partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID}
lister := r.deps.GateCandidates
if lister == nil {
lister = r.deps.Store.listReviewCandidates
}
existing, err := lister(ctx, partition, item.ID, r.gateScanLimit())
if err != nil {
return nil, fmt.Errorf("memory/review: gate candidates: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
r.bump(func(s *ReviewStats) { s.GateChecked++ })
return gatePairs(item, existing, r.cfg.Threshold), nil
}
// gatePairs 收集「新条目 vs 既有条目」的候选对:相似度 ≥ 阈值的留下,按
// 分数降序(再按候选 id)确定性排序 —— 与 collectTidyPairs 对**中心对**的
// 输出逐字等价(preinsert_gate_measure_test.go 有一条等价性断言钉住它),只是不做
// 那些与判定无关的既有-既有比较。
func gatePairs(item MemoryItem, existing []MemoryItem, threshold float64) []ingestCandidate {
out := make([]ingestCandidate, 0, len(existing))
for _, ex := range existing {
score := lexicalSimilarity(item.Content, ex.Content)
if score < threshold {
continue
}
out = append(out, ingestCandidate{item: ex, score: score, mode: tidyRecallLexical})
}
sort.Slice(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
return out[i].item.ID < out[j].item.ID
})
return out
}
// recordGate 为闸做过的一次决定落一条结构化留痕(design/23 §15 D3-8),
// 并把它写进审计链(动作名 gateAuditVerb,**只带决定与 id,不带正文**)。
// Trace 为空表示闸没做决定(真新 / 拿不准 / 闸关闭):那时留痕由既有异步
// 队列负责,这里绝不重复落一条(否则同一件事两条路径)。
func (r *PreInsertReviewer) recordGate(ctx context.Context, item MemoryItem, plan ingestGatePlan, now time.Time) {
if plan.Trace == "" {
return
}
rev := IngestReview{
ID: mustNewTidyID(),
ItemID: item.ID,
PersonaID: item.PersonaID,
Scope: item.Scope,
ProjectID: item.ProjectID,
Decision: plan.Decision,
Reason: plan.Reason,
Veto: plan.Veto,
TargetID: plan.Survivor.ID,
MergedIDs: []string{},
Score: plan.Score,
RecallMode: plan.Mode,
Degraded: plan.Degraded,
RuleVer: IngestRuleVersion,
SessionID: TidySessionID,
CreatedAt: now,
}
if plan.Absorbed.ID != "" {
rev.MergedIDs = []string{plan.Absorbed.ID}
}
if err := r.deps.Store.insertIngestReview(ctx, rev); err != nil {
// 留痕失败**不改判决定**:改写已经生效,这里只如实记 WARN,绝不回滚
// 一次已经生效的合并。但失败原因必须带 trace,便于运维定位。
r.log.Warn("memory ingest gate record insert failed",
"item_id", item.ID, "trace", plan.Trace, "error", err)
}
r.auditWith(gateAuditVerb, rev)
}
// finishIngest 落定闸给出的三态里「需要写第二笔」的部分:新行已经在 Add 里
// 插好了(absorb_new 时它已带 merged/merged_into),这里只补存活行的合并登记
// (absorb_new)或把既有行置为被合并(absorb_old)。失败**不让 Add 失败** ——
// 记忆的正文早已在表里,回滚会把「已插入」变成「调用方以为没插入」而诱发重复
// 写入;这里只如实记 WARN 并把该条交给既有异步队列兜底。
func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGatePlan) {
if plan.Action == ingestPlanInsert {
return
}
now := time.Now().UTC()
var err error
switch plan.Action {
case ingestPlanAbsorbNew:
err = s.bumpIngestSurvivor(ctx, plan.Survivor, item, now)
default:
err = s.absorbIngestItem(ctx, plan.Absorbed, plan.Survivor, now)
}
if err != nil {
s.log.Warn("memory ingest gate apply failed, item left as inserted",
"id", item.ID, "action", string(plan.Action), "error", err)
s.enqueueReview(item)
return
}
if r := s.ingestReviewer(); r != nil && plan.Trace == gateTraceAbsorbed {
r.bump(func(st *ReviewStats) { st.GateAbsorbed++ })
}
}
// bumpIngestSurvivor 只刷新存活行的合并登记:merged_from 追加被吸收的 id、
// merge_count +1、confidence 取较大者、updated_at 刷新(design §8 的软动作)。
// 它是 absorbIngestItem 的「被吸收行已经落好 merged」那一半 —— 闸在插入时就
// 把新行写成了 merged,因此这里不能再 UPDATE 它一次。
func (s *Store) bumpIngestSurvivor(ctx context.Context, survivor, absorbed MemoryItem, now time.Time) error {
if survivor.ID == "" || absorbed.ID == "" || survivor.ID == absorbed.ID {
return fmt.Errorf("memory: bump ingest survivor: invalid pair %q -> %q", absorbed.ID, survivor.ID)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: bump ingest survivor: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := bumpSurvivor(ctx, tx, survivor, absorbed, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory: bump ingest survivor: commit: %w", err)
}
return nil
}