feat(digest): 探索图冷节点压缩(cold-digest)

把老且长期不活跃的意图/事实折叠成 digest 节点进 graph_overview 展示,
原始节点永久保留、按 id 可完整还原(存储无损、只压呈现、折叠可逆)。

- 数据模型:KindDigest 节点 + RelCovers 边(真相源);新列 content_version、
  cold_since_round(节点级)、round_no(任务级 planner 轮次);均带旧库迁移。
- 纯算法(coldgraph.go):冷热判定(逆向可达 + 任一活分支即热) + R=6 轮防抖 +
  连通分量分组(§3) + §3.1 同父兄弟归组(救"热 hub+扁平死叶") + §5.3 签名。
- 后台压缩(compaction.go):minor 折未覆盖冷块(tiered)、major 回源重压合并碎片;
  签名幂等复用 body;提交时活跃度复核(绝不覆盖已复活节点);60s 冷却 + 互斥。
- 概览折叠(§6):recent_* 剔除已折成员 + 渲染时复活检查;cold_digests + 按资产
  索引 cold_index(§6.2);expand_digest/expand_index 还原;covered_members 反查
  悬空血缘 id;60 兜底截断均衡保 live。
- planner 每轮 OnPlannerRound 维护戳记并触发后台压缩,绝不上热路径。

纯算法单测覆盖 §附/§3.1/§2.3/§5.3;已对 437 节点真实图验证:热 86、冷候选 324、
§3.1 分组 47 块 + 14 孤立冷点(对齐设计文档实测校验)。
This commit is contained in:
Autumn-27 committed 2026-09-09 11:52:15 -04:00
1 parent 801160930b
commit 4f088e519b
13 files changed
+1720 -15

No files matched your search

+75 -5
View File
@@ -12,6 +12,28 @@ import (
actool "github.com/Autumn-27/norma/tool"
)
// balancedCap decides how many done-intents and facts to keep when the combined
// unfolded settled/cold region exceeds cap (§6.3). The smaller side is kept whole;
// the larger side takes the remaining budget; neither is starved below cap/2.
// Returns the keep counts and whether truncation applied. Newest-first slices, so
// callers keep the head.
func balancedCap(done, facts, cap int) (keepDone, keepFacts int, truncated bool) {
if done+facts <= cap {
return done, facts, false
}
half := cap / 2
keepDone, keepFacts = done, facts
switch {
case done > half && facts > half:
keepDone, keepFacts = half, cap-half
case done > half:
keepDone = cap - facts // facts fit in ≤half; intents take the rest
default:
keepFacts = cap - done // intents fit in ≤half; facts take the rest
}
return keepDone, keepFacts, true
}
// compactIntents distills intents to {id, summary, state, asset_ids, parents,
// yields} so the planner sees both the direction and its LINEAGE — parents (the
// upstream nodes it derived from: facts/intents/findings) and yields (the facts/
@@ -396,6 +418,20 @@ func (t *ToolSet) graphOverviewData() map[string]any {
factFrom[e.To] = e.From
}
}
// cold-digest §6: members folded into an active digest are shown via cold_digests
// (below), not the flat recent_* lists. `covered` maps member id → its digest id.
// §6 render-time revival check: a covered member that has become hot again (a new
// intent derived from it) must reappear this round — so `hidden` folds a member out
// only when it is covered AND still cold. covered_members (built from hidden) lets a
// parents/yields id pointing into a live fold stay resolvable (§6.5 dangling lineage).
covered, _ := t.ts.CoveredMembers()
var hotAtRender map[int64]bool
if len(covered) > 0 {
if cg, _, err := loadColdGraph(t.ts); err == nil {
hotAtRender = cg.hotSet()
}
}
hidden := func(id int64) bool { _, c := covered[id]; return c && !hotAtRender[id] }
fr, _ := t.ts.Frontier(100)
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
all, _ := t.ts.ListByKind(db.KindIntent, 300)
@@ -405,9 +441,10 @@ func (t *ToolSet) graphOverviewData() map[string]any {
case "running":
running = append(running, n)
case "done", "blocked", "exhausted":
if len(recentDone) < 15 {
recentDone = append(recentDone, n)
if hidden(n.ID) {
continue // in a cold_digest and still cold — shown via cold_digests (§6.2)
}
recentDone = append(recentDone, n) // §6: no 15-cap; folded ones are gone, cap applied below
}
}
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
@@ -484,10 +521,10 @@ func (t *ToolSet) graphOverviewData() map[string]any {
findingList = append(findingList, m)
}
out["finding_list"] = findingList
recentFacts := make([]map[string]any, 0, 20)
recentFacts := make([]map[string]any, 0, len(factNodes))
for _, n := range factNodes {
if len(recentFacts) >= 20 {
break
if hidden(n.ID) {
continue // in a cold_digest and still cold — surfaced via cold_digests (§6.2)
}
m := compactNode(n)
if from := factFrom[n.ID]; from > 0 {
@@ -503,7 +540,38 @@ func (t *ToolSet) graphOverviewData() map[string]any {
}
recentFacts = append(recentFacts, m)
}
// §6.3 兜底截断:未折的 settled/cold 区(recent_done_intents + recent_facts)总量 >
// cap 时按最新优先截——live(open/running) 在独立字段、永不受影响。两边【均衡】保留:
// 较少的一侧全留,较多的一侧填剩余额度,各自至少保底 cap/2,绝不把某一侧饿到 0
// (深探索链下 hot 的 settled 意图很多,天真的"意图优先"会把 facts 挤没)。正常由压缩
// 阈值约束、不触发;触发即标注,说明压缩滞后。
const unfoldedCap = 60
if doneList, ok := out["recent_done_intents"].([]map[string]any); ok {
if keepDone, keepFacts, truncated := balancedCap(len(doneList), len(recentFacts), unfoldedCap); truncated {
out["recent_done_intents"] = doneList[:keepDone]
recentFacts = recentFacts[:keepFacts]
out["unfolded_truncated"] = true // 正常不触发;触发说明压缩滞后
}
}
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
// cold-digest §6.1/§6.2: the folded cold region + the per-asset directory that
// collapses independent directions, plus the dangling-lineage resolver map.
if cds, cidx := t.coldDigestOverview(); len(cds) > 0 {
out["cold_digests"] = cds // [{id, body, member_count}] —— 直接读 body (§6.1)
out["cold_index"] = cidx // [{asset, asset_id, digest_ids}] —— 按资产收敛方向 (§6.2)
}
if len(covered) > 0 {
cm := make(map[string]int64, len(covered))
for member, dig := range covered {
if hotAtRender[member] {
continue // revived → shown live this round, not a dangling folded id
}
cm[strconv.FormatInt(member, 10)] = dig
}
if len(cm) > 0 {
out["covered_members"] = cm // 悬空血缘 id → 它属于哪个 digest;用 expand_digest 展开 (§6.5)
}
}
// the original task (root) so the planner always has it, not just the
// decomposed goals.
if description, goal, err := t.ts.Root(); err == nil {
@@ -2026,6 +2094,8 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
func (t *ToolSet) PlannerTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
// cold-digest §6.1: restore folded cold nodes (digest body → members → detail).
t.expandDigest(), t.expandIndex(),
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.listGoals(), t.addIntent(), t.proveGoal(), t.goalMet(),
t.killWorkTool(), t.steerWorkTool(),
// report_finding:规划态势研判时若自身已确证漏洞,可直接登记(与 worker 同工具)。