fix(graph): Prune 孤点删除加 14d 宽限期 + 删除前预告日志(t_31bb2992)
门关(ZHIYI_GRAPH_AUTO_SYNTH=0)后 §1 仍建节点、§2 不建边,新点皆孤点; 旧 Prune 无条件零日志删孤点(生产曾静默 41,371→2,630)。 - ZHIYI_GRAPH_PRUNE_GRACE_DAYS 默认 14:只删出宽限期孤点,0=逃生阀回旧行为 - 删除前 log will-delete 计数 + 5 样本(节点/边各一条) - 单测:宽限期存活/出窗删除/逃生阀/有边端点保护 + 日志预告断言 实验:副本两轮 churn 修前 0/51 存活 → 修后 51/51(报告 t_31bb2992 artifacts)
This commit is contained in:
@@ -611,11 +611,53 @@ func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, las
|
||||
return
|
||||
}
|
||||
|
||||
// pruneGraceDays 孤点删除宽限期(天)。t_31bb2992(2026-11-02)背景:
|
||||
// ZHIYI_GRAPH_AUTO_SYNTH 门关后 §1 仍建实体节点、§2 不建边 ⇒ 新建节点天然孤点;
|
||||
// 旧版 Prune 无条件删孤点且零日志,曾一次性静默删 93.6% 节点(41,371→2,630)。
|
||||
// AddNode 是 INSERT OR REPLACE(created_at 每次刷新),故宽限期实际语义 =
|
||||
// 「连续 N 天没被蒸馏引用过的孤点才删」。ZHIYI_GRAPH_PRUNE_GRACE_DAYS=0 回到旧行为。
|
||||
func pruneGraceDays() int {
|
||||
if v := strings.TrimSpace(os.Getenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 14
|
||||
}
|
||||
|
||||
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
|
||||
gs.mu.Lock()
|
||||
defer gs.mu.Unlock()
|
||||
|
||||
// 删除前预告(t_31bb2992):把「会删多少、删的是谁」先写进日志,
|
||||
// 杜绝再次出现「静默删 93.6%」事后才从行数差发现的事故。
|
||||
grace := pruneGraceDays()
|
||||
// created_at 为空的行按最老处理(可删);id IS NULL 的行因 SQL 三值逻辑
|
||||
// 对 NOT IN 天然免疫,维持既有行为(pattern_stat 等 85 行不受影响)。
|
||||
// grace<=0 = 逃生阀:完全不加时间条件,逐位回到旧行为(同秒新建也照删)。
|
||||
orphanWhere := `id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`
|
||||
if grace > 0 {
|
||||
orphanWhere += fmt.Sprintf(
|
||||
" AND COALESCE(NULLIF(created_at,''),'0000-00-00 00:00:00') < datetime('now','-%d day')", grace)
|
||||
}
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_edges WHERE weight < "+fmt.Sprintf("%f", minWeight)); n > 0 {
|
||||
log.Printf("[graph] prune will delete %d edges (weight < %.2f)", n, minWeight)
|
||||
}
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE "+orphanWhere); n > 0 {
|
||||
sample := ""
|
||||
if rows := queryRows(gs.db, "SELECT id, name FROM graph_nodes WHERE "+orphanWhere+" LIMIT 5"); len(rows) > 0 {
|
||||
names := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
names = append(names, fmt.Sprintf("%v(%v)", r["name"], r["id"]))
|
||||
}
|
||||
sample = strings.Join(names, ", ")
|
||||
}
|
||||
log.Printf("[graph] prune will delete %d orphan nodes (grace=%dd, minWeight=%.2f), sample=[%s]",
|
||||
n, grace, minWeight, sample)
|
||||
}
|
||||
|
||||
execSQL(gs.db, fmt.Sprintf("DELETE FROM graph_edges WHERE weight < %f", minWeight))
|
||||
execSQL(gs.db, `DELETE FROM graph_nodes WHERE id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`)
|
||||
execSQL(gs.db, "DELETE FROM graph_nodes WHERE "+orphanWhere)
|
||||
}
|
||||
|
||||
// CleanupNoiseNodes 删除名称含编码噪音的节点(如 "n_fts=517," "n_fts(sqlite)+")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package governance
|
||||
|
||||
// t_31bb2992 · Prune 孤点宽限期 + 删除前日志 单测
|
||||
// 背景:门关(ZHIYI_GRAPH_AUTO_SYNTH=0)后 §1 建点、§2 不建边 ⇒ 新点皆孤点;
|
||||
// 旧版 Prune 无条件删孤点且零日志(生产曾静默删 41,371→2,630)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTempGraphStore(t *testing.T) *SQLiteGraphStore {
|
||||
t.Helper()
|
||||
gs, err := NewSQLiteGraphStore(filepath.Join(t.TempDir(), "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteGraphStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { gs.Close() })
|
||||
return gs
|
||||
}
|
||||
|
||||
// 宽限期:门关时新建的孤点,宽限期内 Prune 不得删;超过宽限期才删;
|
||||
// grace=0 是逃生阀 = 旧行为(立即删)。
|
||||
func TestPruneGracePeriod_OrphanSurvival(t *testing.T) {
|
||||
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
|
||||
gs := newTempGraphStore(t)
|
||||
up := NewAutoGraphUpdater(gs)
|
||||
up.UpdateFromDistill(&DistillInput{
|
||||
EpisodeID: "ep_grace_1",
|
||||
Content: "宽限期 单测",
|
||||
Entities: []string{"孤点甲", "孤点乙"},
|
||||
Namespace: "ns_grace",
|
||||
Source: "wiki:vault-ingest",
|
||||
})
|
||||
|
||||
// ① 新孤点在 14d 宽限期内 ⇒ Prune 后仍在
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 2 {
|
||||
t.Fatalf("宽限期内新孤点应保留 2 个,实际 %d", n)
|
||||
}
|
||||
|
||||
// ② 把 created_at 推到 15 天前 ⇒ 出宽限期,Prune 删除
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_grace'")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 0 {
|
||||
t.Fatalf("超宽限期孤点应被删除,实际剩 %d", n)
|
||||
}
|
||||
|
||||
// ③ 逃生阀 grace=0 ⇒ 新孤点也被删(回旧行为)
|
||||
if err := gs.AddNode("n_grace_zero", "逃生阀点", "entity", "ns_grace0"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
t.Setenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS", "0")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE id='n_grace_zero'"); n != 0 {
|
||||
t.Fatalf("grace=0 应回到旧行为(即删新孤点),实际仍在")
|
||||
}
|
||||
|
||||
// ④ 有边引用的节点任何情况下都不被孤点删除误伤
|
||||
if err := gs.AddNode("n_anchor_a", "甲", "entity", "ns_edge"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
if err := gs.AddNode("n_anchor_b", "乙", "entity", "ns_edge"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
if err := gs.AddEdge("e_anchor", "n_anchor_a", "n_anchor_b", "CONFLICTS_WITH", "ns_edge", 0.9); err != nil {
|
||||
t.Fatalf("AddEdge: %v", err)
|
||||
}
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_edge'")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_edge'"); n != 2 {
|
||||
t.Fatalf("有边端点不应被删,实际剩 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除前日志:Prune 删孤点前必须打「will delete N orphan nodes + 抽样」,
|
||||
// 让静默大规模删点以后在日志里看得见。
|
||||
func TestPruneLogsOrphanDeletionPreview(t *testing.T) {
|
||||
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
|
||||
gs := newTempGraphStore(t)
|
||||
for _, e := range []struct{ id, name string }{
|
||||
{"n_log_1", "日志点一"}, {"n_log_2", "日志点二"},
|
||||
} {
|
||||
if err := gs.AddNode(e.id, e.name, "concept", "ns_log"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
}
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_log'")
|
||||
|
||||
var buf bytes.Buffer
|
||||
old := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(old)
|
||||
|
||||
gs.Prune(0.15)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "[graph] prune will delete 2 orphan nodes") {
|
||||
t.Fatalf("日志缺少 will-delete 计数行,实际输出:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"sample=", "日志点一", "n_log_1", "grace=14d"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("日志缺少 %q,实际输出:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 无可删对象时不得虚报
|
||||
buf.Reset()
|
||||
gs.Prune(0.15)
|
||||
if strings.Contains(buf.String(), "prune will delete") {
|
||||
t.Fatalf("无可删对象时不应打删除预告,实际:\n%s", buf.String())
|
||||
}
|
||||
_ = os.Getenv // keep imports stable if env helpers change
|
||||
}
|
||||
Reference in New Issue
Block a user