fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引

- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
This commit is contained in:
JianFeeeee
2026-09-15 10:20:40 +08:00
parent 49695c38f3
commit acc94723fd
6 changed files with 248 additions and 67 deletions

View File

@ -12,6 +12,15 @@ import (
_ "github.com/mattn/go-sqlite3"
)
// maxKeywordEntities 是单个关键词能取回的实体上限。
//
// 无上限时,一个宽关键词("QQ")会命中上百个实体并逐个参与深度扩展,
// 把一次召回变成一次全表扫描。
const maxKeywordEntities = 50
// maxAdjacentRelations 是深度扩展里**每层**读取的关系上限。
const maxAdjacentRelations = 200
// maxFullRecallEntities 是「无关键词全量读取」路径的实体上限。
// 该路径只服务于内部整备(Indexer.Sync / 实体合并检测),并非用户检索;
// 无上限时一张大图会被整表 read 进内存。超限时 GraphDB.Recall 会记日志。
@ -236,6 +245,9 @@ func (g *GraphDB) initSchema() error {
-- 数值型节点(relation/entity)为空串。
ref_text TEXT NOT NULL DEFAULT '',
weight REAL DEFAULT 1.0,
-- decayed_at 是半衰期衰减的计时起点:每个引用至多每 halfLife
-- 衰减一次(见 DecaySceneRefs)。重复写入/强化会把它刷成当前时刻。
decayed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scene_id, kind, ref_id, ref_text)
)`,
@ -245,14 +257,12 @@ func (g *GraphDB) initSchema() error {
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`,
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
`CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`,
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
`CREATE INDEX IF NOT EXISTS idx_sentences_text ON sentences(text)`,
}
for _, s := range schemas {
@ -307,6 +317,17 @@ func (g *GraphDB) initSchema() error {
}
}
}
// 迁移8:scene_refs 加 decayed_at(半衰期衰减的计时起点)。
// ALTER 不接受非常量默认值,先加可空列再用 created_at 回填,
// 于是既有引用的「上一次衰减」就定在它被写入的时刻,不会被立即清掉。
if !columnExists(tx, "scene_refs", "decayed_at") {
tx.Exec(`ALTER TABLE scene_refs ADD COLUMN decayed_at TIMESTAMP`)
tx.Exec(`UPDATE scene_refs SET decayed_at = created_at WHERE decayed_at IS NULL`)
}
// 冗余索引清理:entities.name 与 sentences.text 上的 UNIQUE 已隐含等价索引
// (sqlite_autoindex_*),再建一个同列索引只增加写放大,查询不会用到。
tx.Exec(`DROP INDEX IF EXISTS idx_entity_name`)
tx.Exec(`DROP INDEX IF EXISTS idx_sentences_text`)
// 迁移3:将现有 sentence_ref 数据迁移到 sentences 表
tx.Exec(`INSERT OR IGNORE INTO sentences (text) SELECT DISTINCT sentence_ref FROM relations WHERE sentence_ref != ''`)
tx.Exec(`UPDATE relations SET sentence_id = (SELECT id FROM sentences WHERE text = relations.sentence_ref) WHERE sentence_ref != ''`)
@ -639,10 +660,25 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
entityIDs := make(map[int64]bool)
for _, kw := range keywords {
// 相关度排序 + 限额。
//
// 此前这里既没有 ORDER BY 也没有 LIMIT:拿回来的顺序就是建表顺序
// (rowid 升序),于是注入进 prompt 的"前 5 个实体"是**最早创建的**,
// 越新越准的记忆越排后面被截掉(实测:输入「QQ回复格式」命中 148 个,
// 规则实体排第 32,前 5 里根本没有它)。
//
// 相关度分三层:完全相等 > 前缀命中 > 包含命中;同层按提及次数、
// 再按名字长度(短名更可能是实体本身而不是长描述)。
rows, err := g.db.Query(
`SELECT id, name, type, mention_count, created_at, updated_at
FROM entities WHERE LOWER(name) LIKE ?`,
"%"+kw+"%",
FROM entities WHERE LOWER(name) LIKE ?
ORDER BY CASE
WHEN LOWER(name) = LOWER(?) THEN 0
WHEN LOWER(name) LIKE LOWER(?) || '%' THEN 1
ELSE 2 END,
mention_count DESC, LENGTH(name) ASC
LIMIT ?`,
"%"+kw+"%", kw, kw, maxKeywordEntities,
)
if err != nil {
return nil, err
@ -717,6 +753,11 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
query += " AND r.session_id = ?"
allIDs = append(allIDs, sessionFilter)
}
// 每层限额:热实体("文档"这类)的邻接可能是上千条,无上限时每层都
// 整片读进内存,而调用方(memory_recall 注入 10 条、自动注入只要实体名)
// 根本用不到。按置信度取最相关的一批。
query += " ORDER BY r.confidence DESC, r.updated_at DESC LIMIT ?"
allIDs = append(allIDs, maxAdjacentRelations)
relRows, err := g.db.Query(query, allIDs...)
if err != nil {
@ -901,10 +942,13 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
}
n, _ := result.RowsAffected()
g.db.Exec(`DELETE FROM entities WHERE id NOT IN (
SELECT DISTINCT source_id FROM relations
UNION SELECT DISTINCT target_id FROM relations)`)
// 这里**不再**顺手全局删孤儿实体。
//
// 原来那句 `DELETE FROM entities WHERE id NOT IN (relations 两端)` 是与
// 调用方意图无关的全局副作用:memory_edit 只想去掉一条关系,却可能把
// 图里其它孤零零的实体一并清掉。孤儿清理交给 PurgeOrphans
// (显式、可 dry-run、有计数与审计),一次改动只做一件事。
//
// 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、
// 召回却是空的(SceneStats 也跟着说谎)。
g.purgeStaleSceneRefsLocked()

View File

@ -3,8 +3,10 @@ package memory
import (
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
@ -19,8 +21,21 @@ type Indexer struct {
// recalledOrder 记录 recalled 的插入顺序,用于超限时按 FIFO 淘汰。
recalledOrder []string
// 增量同步状态:运行中新增的实体此前要等下一个归档心跳(Indexer.Sync)
// 才进向量索引,在那之前只能靠关键词路命中——"刚记住的东西过一会儿才想得
// 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。
lastSyncCount int
lastSyncAt time.Time
}
// retrainInterval 是增量重训的最小间隔。
//
// 为什么不每次都重训:TF-IDF 向量器是**全局**重训(词表与 idf 都变),
// 一次是 O(实体数)。没有下限时,密集写入的场景下每轮召回都触发一次全量重训,
// 把一个读操作变成写放大的热点。心跳(30min)+ 这个下限,够快也够稳。
const retrainInterval = 30 * time.Second
// maxRecalledEntities 是「已召回实体」去重集的上限。
//
// 无上限时它只增不减:进程活得越久,被永久跳过的实体越多,自动注入
@ -76,6 +91,10 @@ func (idx *Indexer) Sync() error {
}
if len(names) == 0 {
// 没有实体也是一次成功的同步:记下基线,否则 syncIfStale 会在
// 「基线还停在旧值」与「本轮无实体」之间反复误判、每 30s 重跑一次。
idx.lastSyncCount = 0
idx.lastSyncAt = time.Now()
return nil
}
@ -93,10 +112,49 @@ func (idx *Indexer) Sync() error {
}
idx.trained = true
idx.lastSyncCount = len(names)
idx.lastSyncAt = time.Now()
log.Printf("[indexer] synced %d entities to vector index", len(names))
return nil
}
// syncIfStale 在实体数发生变化时补一次重建(调用方必须**不持锁**)。
//
// 返回是否发生了重建。计数查询走 idx_count 索引,成本可忽略;
// 只有真的变了、且离上次同步超过 retrainInterval 才重训。
func (idx *Indexer) syncIfStale() bool {
if idx.db == nil {
return false
}
var count int
if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil {
return false
}
// 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被
// maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上
// 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。
expected := count
if expected > maxFullRecallEntities {
expected = maxFullRecallEntities
}
idx.mu.RLock()
known, last := idx.lastSyncCount, idx.lastSyncAt
idx.mu.RUnlock()
if expected == known {
return false
}
if !last.IsZero() && time.Since(last) < retrainInterval {
return false
}
if err := idx.Sync(); err != nil {
log.Printf("[indexer] 增量同步失败(沿用旧索引): %v", err)
return false
}
log.Printf("[indexer] 实体数 %d→%d,已增量重建实体名向量索引", known, count)
return true
}
type InjectedContext struct {
Entities []Entity `json:"entities"`
Relations []Relation `json:"relations"`
@ -130,6 +188,9 @@ func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *Inje
input := CleanText(userInput)
// 0. 索引保鲜:运行中新增的实体不该等到下一个心跳才可被召回
idx.syncIfStale()
// 1. 向量搜索:从实体名向量索引中找到相关实体
vectorEntities := idx.vectorSearchEntities(input)
@ -444,16 +505,30 @@ func buildIndexSummary(entities []Entity) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities)))
// 「高频」必须真的按提及次数排。
//
// 此前取的是 entities[0..2],也就是 Recall 的返回顺序(旧实现下等于建表
// 顺序),却写着"高频"两个字:给模型的暗示是"这几条最重要",实际是
// "这几条建得最早"。名不副实的标签比没有标签更坏——它会稳定地误导。
byMentions := make([]Entity, len(entities))
copy(byMentions, entities)
sort.SliceStable(byMentions, func(i, j int) bool {
if byMentions[i].MentionCount != byMentions[j].MentionCount {
return byMentions[i].MentionCount > byMentions[j].MentionCount
}
return byMentions[i].Name < byMentions[j].Name
})
topN := 3
if len(entities) < topN {
topN = len(entities)
if len(byMentions) < topN {
topN = len(byMentions)
}
b.WriteString(",高频:")
for i := 0; i < topN; i++ {
if i > 0 {
b.WriteString("、")
}
b.WriteString(entities[i].Name)
b.WriteString(byMentions[i].Name)
}
return b.String()

View File

@ -3,6 +3,7 @@ package memory
import (
"path/filepath"
"testing"
"time"
)
func TestNewIndexer(t *testing.T) {
@ -48,6 +49,52 @@ func TestIndexerSync(t *testing.T) {
}
}
// TestSyncIfStaleBaseline 钉住增量同步的基线口径:
// - 实体数未变 → 不重训
// - 实体数变了但不足 retrainInterval → 先不重训(避免密集写入时写放大)
// - 实体数变了且间隔已过 → 重训,并把基线追到新值
func TestSyncIfStaleBaseline(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, _, err := db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
}, "test", 0); err != nil {
t.Fatal(err)
}
idx := NewIndexer(db)
if !idx.syncIfStale() {
t.Fatal("首次应建立索引")
}
if idx.syncIfStale() {
t.Error("实体数未变不该重训")
}
if _, _, err := db.Commit([]Triple{
{Subject: "李四", Relation: "喜欢", Object: "足球"},
}, "test", 0); err != nil {
t.Fatal(err)
}
if idx.syncIfStale() {
t.Error("retrainInterval 内不该重训(避免写放大)")
}
// 把上次同步时刻推老,计数变化才该触发重训
idx.mu.Lock()
idx.lastSyncAt = time.Now().Add(-2 * retrainInterval)
idx.mu.Unlock()
if !idx.syncIfStale() {
t.Error("计数变化且间隔已过应重训")
}
if idx.syncIfStale() {
t.Error("重训后基线应追上,不该再重训")
}
}
func TestIndexerBuildContext(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {

View File

@ -198,7 +198,7 @@ func tagSceneRefTx(tx *sql.Tx, sceneKey, kind string, id int64, textID string, w
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scene_id, kind, ref_id, ref_text)
DO UPDATE SET weight = MAX(weight, excluded.weight)`,
DO UPDATE SET weight = MAX(weight, excluded.weight), decayed_at = CURRENT_TIMESTAMP`,
sceneID, kind, id, textID, weight); err != nil {
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
}

View File

@ -80,7 +80,10 @@ func (f SituationFeature) Weight() float64 {
switch NormalizeSceneKey(f.Kind) {
case "chan":
return wFeatChan
case "peer":
case "peer", "peer_group":
// 群与私聊都是「对话对象」这一维:都是最强的同一性信号。
// 分开 kind 是为了让 `peer:group_1` 与 `peer:user_1` 不互相命中,
// 不是让群身份降级成软信号(漏掉这里它就只剩 topic 权重 0.4)。
return wFeatPeer
case "tool":
return wFeatTool
@ -387,7 +390,9 @@ func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
//
// 命中多个场景时按相似度 × 权重合并,跨场景去重(同一关系只出现一次)。
// 命中多个场景时取并集,跨场景去重(同一关系只出现一次);命中的场景先按
// 相似度排序再交给 RecallByScene。最终顺序以**场景内引用权重**(写入时的
// 置信度)为准,相似度只决定哪些场景参与、不参与每条关系的排序。
// 这正是「类似的场景自动唤起对应的记忆」那一下。
func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) {
if sig.Empty() {
@ -427,7 +432,7 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
for _, h := range hits {
keys = append(keys, h.key)
}
// 复用按场景键的取回逻辑(前缀语义 + weight 排序),再按相似度加权重排
// 复用按场景键的取回逻辑(前缀语义 + weight 排序)
out, err := g.RecallByScene(keys, limit)
if err != nil {
return nil, err
@ -440,6 +445,11 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会
// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。
// 权重按半衰期折半;低于 floor 的引用直接删除(关联已无信息量)。
//
// 关键在「按半衰期」:每个引用**至多每 halfLife 衰减一次**,计时起点记在
// scene_refs.decayed_at 上。只按 created_at 判龄会在每次心跳都把老引用对半
// 砍——archive 心跳默认 60 分钟、halfLife 传 30 天,于是 30 天前的关联会在
// 几小时内被砍到 floor 以下清空。那不是半衰期,是骤死。
func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, error) {
if halfLife <= 0 {
return 0, nil
@ -449,12 +459,15 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
}
g.mu.Lock()
defer g.mu.Unlock()
cut := time.Now().Add(-halfLife).Format("2006-01-02 15:04:05")
// 时间基准必须与 CURRENT_TIMESTAMP 一致(SQLite 用 UTC):如果在 Go 侧用
// 本地时间拼字符串比较,东八区会凭空多出 8 小时的“年龄”,刚刷新的
// decayed_at 会被判定为还没到点。这里交给 SQLite 的 datetime('now', …)。
mod := fmt.Sprintf("-%d seconds", int(halfLife.Seconds()))
if _, err := g.db.Exec(
`UPDATE scene_refs SET weight = weight * 0.5
WHERE created_at < ? AND id NOT IN (
`UPDATE scene_refs SET weight = weight * 0.5, decayed_at = CURRENT_TIMESTAMP
WHERE decayed_at < datetime('now', ?) AND id NOT IN (
SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
WHERE s.updated_at >= ?)`, cut, cut); err != nil {
WHERE s.updated_at >= datetime('now', ?))`, mod, mod); err != nil {
return 0, err
}
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE weight < ?`, floor)
@ -465,31 +478,6 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
return int(n), nil
}
// EmergentScenes 列出当前长出来的场景(按强度降序),供观察「涌现」是否在发生。
func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(
`SELECT s.key, COALESCE(s.strength,1),
(SELECT COUNT(*) FROM scene_refs sr WHERE sr.scene_id = s.id),
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
COALESCE(s.origin, 'emergent'), s.updated_at
FROM scenes s ORDER BY COALESCE(s.strength,1) DESC, s.updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SceneStat
for rows.Next() {
var st SceneStat
if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.Origin, &st.UpdatedAt); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// TurnScene 是一轮交互解析出来的场景集合。
type TurnScene struct {
// Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
@ -505,10 +493,10 @@ type TurnScene struct {
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
//
// 主动路(declaredKeys 非空):确保这些场景存在,并把本轮的场面指纹喂给它,
// 强度 +1。这样声明出来的场景会**慢慢学会自己认自己**——同一个场面以后
// 即使没人声明,也能被 RecallBySituation 按相似度命中。
// 声明即建场景,不等第二次:人明确说了"这是哪个场面",就不该再等它自己涌现。
// 主动路(declaredKeys 非空):确保这些场景存在(不存在即建,不等第二次涌现
// ——人明确说了"这是哪个场面",就不该再等它自己涌现),强度 +1,并给它记下
// 从**键自身**解析出的特征。声明路刻意**不吸收本轮整场指纹**:一旦吸收,它
// 会在相似度上压过一切,被动聚类再也长不出更细的场面(见 EnsureScene)。
//
// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时
// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底——
@ -524,7 +512,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
continue
}
seen[key] = true
learned, err := g.EnsureScene(key, sig)
learned, err := g.EnsureScene(key)
if err != nil {
return out, err
}
@ -538,8 +526,11 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
}
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
//
// EnterScene 只在真的命中/新建时返回非空键;返回空键时 created 必为
// false(首次只登记足迹),所以这里无需再判 created。
if !sig.Empty() {
key, created, err := g.EnterScene(sig)
key, _, err := g.EnterScene(sig)
if err != nil {
return out, err
}
@ -549,8 +540,6 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
}
out.Primary = key
out.Emergent = true
} else if created {
out.Emergent = true
}
}
return out, nil
@ -562,7 +551,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
func (g *GraphDB) EnsureScene(key string, _ Situation) (bool, error) {
func (g *GraphDB) EnsureScene(key string) (bool, error) {
key = NormalizeSceneKey(key)
if key == "" {
return false, nil

View File

@ -65,9 +65,9 @@ func TestSceneEmergesFromRepetition(t *testing.T) {
t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2)
}
scenes, err := g.EmergentScenes()
scenes, err := g.SceneStats()
if err != nil {
t.Fatalf("EmergentScenes: %v", err)
t.Fatalf("SceneStats: %v", err)
}
if len(scenes) != 2 {
t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes)
@ -132,6 +132,9 @@ func TestSceneRecallsBySituationNotWording(t *testing.T) {
}
// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。
//
// 半衰期的语义是「每个引用至多每 halfLife 衰减一次」:衰减计时起点在
// scene_refs.decayed_at 上,同一半衰期内重复跑心跳不会再砍。
func TestSceneRefDecay(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
@ -147,13 +150,13 @@ func TestSceneRefDecay(t *testing.T) {
t.Fatal(err)
}
// 把时间推旧:模拟长期未重现
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
// 把时间推旧:模拟长期未重现(上次衰减也在同一时刻)。用 SQLite 的
// datetime('now') 与 CURRENT_TIMESTAMP 同一时间基准(UTC)。
if _, err := g.db.Exec(`UPDATE scene_refs
SET created_at = datetime('now','-48 hours'), decayed_at = datetime('now','-48 hours')`); err != nil {
t.Fatal(err)
}
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = datetime('now','-48 hours')`); err != nil {
t.Fatal(err)
}
@ -169,9 +172,21 @@ func TestSceneRefDecay(t *testing.T) {
t.Errorf("一个半衰期后权重应减半: %v → %v", before, after)
}
// 再推旧一次:第二个半衰期后低于 floor,关联已无信息量,清掉
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
// 同一半衰期内再跑:decayed_at 已刷新,不该再砍一次
// (否则心跳频率就成了实际半衰期,30 天的关联几小时就被清空)
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
t.Fatalf("DecaySceneRefs: %v", err)
}
var same float64
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&same); err != nil {
t.Fatal(err)
}
if same != after {
t.Errorf("同一半衰期内不该重复衰减: %v → %v", after, same)
}
// 进入第二个半衰期:再推旧 decayed_at,权重低于 floor,清掉
if _, err := g.db.Exec(`UPDATE scene_refs SET decayed_at = datetime('now','-48 hours')`); err != nil {
t.Fatal(err)
}
n, err := g.DecaySceneRefs(time.Hour, 0.4)
@ -204,9 +219,8 @@ func TestSceneRefDecay(t *testing.T) {
}
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
// - 主动:声明即建场景(不等第二次涌现),并把指纹喂给它
// - 被动:指纹聚类自己长出场景;声明场景学会特征后**即使没人再声明**
// 也能被相似度命中
// - 主动:声明即建场景(不等第二次涌现),特征只从键自身解析
// - 被动:指纹聚类自己长出场景;声明场景**不**进相似度空间,靠声明/前缀键取回
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
g := newTestGraph(t)
@ -298,7 +312,7 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
}
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
if _, err := g.EnsureScene("chan:qq", Situation{}); err != nil {
if _, err := g.EnsureScene("chan:qq"); err != nil {
t.Fatalf("EnsureScene: %v", err)
}
var n int
@ -310,6 +324,18 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
}
}
// TestPeerGroupWeight 钉住群身份是**强**同一性信号:peer_group 与 peer 同为
// wFeatPeer。漏掉 peer_group 会让它在 Weight 里落到 default(话题级 0.4),
// 群聊场面被降级成软信号。
func TestPeerGroupWeight(t *testing.T) {
if got := (SituationFeature{Kind: "peer_group", Value: "group_1"}).Weight(); got != wFeatPeer {
t.Errorf("peer_group 权重应为 %v,得到 %v", wFeatPeer, got)
}
if got := (SituationFeature{Kind: "peer", Value: "user_1"}).Weight(); got != wFeatPeer {
t.Errorf("peer 权重应为 %v,得到 %v", wFeatPeer, got)
}
}
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
func TestEffectiveScenes(t *testing.T) {
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})