First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
package agent
// cold-digest §2/§3: pure graph algorithms for cold-node compression.
//
// This file is deliberately free of any DB or LLM dependency so the hot/cold
// judgment, connectivity grouping (§3) and the same-parent singleton rescue
// (§3.1) can be unit-tested in isolation. Callers translate db.Node/db.Edge into
// the light cgNode/cgEdge structs and feed the per-node bookkeeping (cold_since
// stamps, content versions) alongside.
//
// Edge direction convention (matches db + agent/tools.go graphOverviewData):
// every edge From→To means From is the parent/upstream and To the child/
// downstream, for ALL relations (yields: intent→fact, derived_from/spawns:
// parent→child). "Downstream" therefore follows From→To.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"github.com/Autumn-27/artex/db"
)
// cgNode is the minimal node view the cold-graph algorithms need.
type cgNode struct {
ID int64
Kind string
State string
}
// cgEdge is one exploration edge (From = parent/upstream, To = child/downstream).
type cgEdge struct {
From int64
Rel string
To int64
}
// coldParams are the tunable thresholds (cold-digest §7).
type coldParams struct {
R int // debounce: a node must be continuously inactive ≥R planner rounds (§7 R=6)
K int // min block size to fold; K=2 skips only degenerate singletons (§7 K=2)
}
func defaultColdParams() coldParams { return coldParams{R: 6, K: 2} }
// coldGraph is an in-memory adjacency view over real exploration edges. The
// derived view layer (kind=digest nodes, rel=covers edges) is filtered out at
// construction so it can never distort causal reachability or grouping (§2/§3).
type coldGraph struct {
nodes map[int64]cgNode
children map[int64][]int64 // From → [To] (downstream)
parents map[int64][]int64 // To → [From] (upstream)
}
func newColdGraph(nodes []cgNode, edges []cgEdge) *coldGraph {
g := &coldGraph{
nodes: make(map[int64]cgNode, len(nodes)),
children: map[int64][]int64{},
parents: map[int64][]int64{},
}
for _, n := range nodes {
g.nodes[n.ID] = n
}
for _, e := range edges {
if e.Rel == db.RelCovers { // derived view layer, not exploration causality (§2/§3)
continue
}
if _, ok := g.nodes[e.From]; !ok {
continue
}
if _, ok := g.nodes[e.To]; !ok {
continue
}
g.children[e.From] = append(g.children[e.From], e.To)
g.parents[e.To] = append(g.parents[e.To], e.From)
}
return g
}
// isLiveIntent reports whether a node is a not-yet-settled intent — the frontier
// that keeps its ancestors hot. paused counts as live (it may still resume);
// settled = done/blocked/exhausted/stopped.
func isLiveIntent(n cgNode) bool {
if n.Kind != db.KindIntent {
return false
}
switch n.State {
case "open", "running", "paused":
return true
}
return false
}
// foldableKind reports whether a node kind is eligible for folding at all (§2:
// only fact and settled intent; finding/goal/hint/begin/digest never fold).
func foldableKind(k string) bool { return k == db.KindIntent || k == db.KindFact }
// hotSet computes the hot nodes (§2 rule 1+2): a node is hot iff it can reach a
// live intent by going downstream (it is an ancestor of a live intent), OR it is
// a live intent, OR it is a direct child of a live intent (rule 1: an open/
// running intent's freshly produced facts stay hot). Everything else is cold-
// eligible. "Any live branch keeps the whole chain hot" falls out of ancestor
// marking. Iterative (no recursion) to tolerate deep chains and cycles.
func (g *coldGraph) hotSet() map[int64]bool {
hot := map[int64]bool{}
var stack []int64
for _, n := range g.nodes {
if isLiveIntent(n) {
stack = append(stack, n.ID)
}
}
// Walk upstream from every live intent, marking all ancestors hot.
for len(stack) > 0 {
id := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if hot[id] {
continue
}
hot[id] = true
stack = append(stack, g.parents[id]...)
}
// A live intent's direct children (its fresh facts) stay hot (rule 1).
for _, n := range g.nodes {
if isLiveIntent(n) {
for _, c := range g.children[n.ID] {
hot[c] = true
}
}
}
return hot
}
// structuralCold is the set of foldable nodes that are currently not hot — i.e.
// settled + blood-inactive (§2 rules 1+2), before the ≥R debounce is applied.
func (g *coldGraph) structuralCold(hot map[int64]bool) map[int64]bool {
cold := map[int64]bool{}
for id, n := range g.nodes {
if foldableKind(n.Kind) && !hot[id] {
cold[id] = true
}
}
return cold
}
// stampOp is one cold_since_round bookkeeping change (§2.3): Set=true stamps the
// round a node went cold; Set=false clears the stamp (the node revived / turned
// hot again).
type stampOp struct {
ID int64
Set bool
Round int64
}
// computeStampOps derives the cold_since_round updates for this round. It stamps
// a node the round it FIRST goes cold (empty→round_no) and clears the stamp when
// it is no longer cold. It never re-stamps an already-stamped cold node — that is
// what preserves "how long it has been cold" (§2.3: measure the round it turned
// cold, not the round it last turned hot). coldSince maps node id → stamp (nil =
// unstamped / hot).
func computeStampOps(structCold map[int64]bool, coldSince map[int64]*int64, roundNo int64) []stampOp {
var ops []stampOp
seen := map[int64]bool{}
for id := range structCold {
seen[id] = true
if coldSince[id] == nil {
ops = append(ops, stampOp{ID: id, Set: true, Round: roundNo})
}
}
// Clear stamps on nodes that are stamped but no longer cold (revived/hot).
for id, cs := range coldSince {
if cs != nil && !seen[id] {
ops = append(ops, stampOp{ID: id, Set: false})
}
}
sort.Slice(ops, func(i, j int) bool { return ops[i].ID < ops[j].ID })
return ops
}
// eligibleCold narrows structuralCold to nodes that have been continuously cold
// for ≥R rounds (§2.3 / §3②). A node with no stamp, or one that has not yet
// aged R rounds, is held in the hot region a while longer (bias to conservative).
func (g *coldGraph) eligibleCold(structCold map[int64]bool, coldSince map[int64]*int64, roundNo int64, p coldParams) map[int64]bool {
out := map[int64]bool{}
for id := range structCold {
if cs := coldSince[id]; cs != nil && roundNo-*cs >= int64(p.R) {
out[id] = true
}
}
return out
}
// block is a group of cold nodes to fold into one digest, plus the external
// parent nodes that anchor them (§3.1 "父作锚不作成员"): anchors are fed to the
// compressor as context but never become members / never get a covers edge.
type block struct {
Members []int64 // sorted; the nodes this digest covers
Anchors []int64 // sorted; external (non-member) parents, context only
}
// group partitions `set` into foldable blocks (§3 + §3.1). Two passes of
// union-find:
//
// rule ① connect cold nodes joined by a real exploration edge (§3);
// rule ② connect the LEFTOVER singletons that share a common direct parent
// (§3.1 — rescues the "hot hub + flat dead leaves" fan-out), without
// disturbing any already-formed ≥2 block.
//
// Only components of size ≥K survive (§3① skips degenerate singletons).
func (g *coldGraph) group(set map[int64]bool, p coldParams) []block {
uf := newUnionFind(set)
// rule ①: real cold↔cold edges.
for from := range set {
for _, to := range g.children[from] {
if set[to] {
uf.union(from, to)
}
}
}
// rule ②: leftover singletons sharing a common parent.
comps := uf.components()
byParent := map[int64][]int64{}
for _, ids := range comps {
if len(ids) != 1 {
continue // only rescue singletons; never re-shuffle ≥2 blocks
}
s := ids[0]
for _, par := range g.parents[s] {
if g.nodes[par].Kind == db.KindDigest { // anchor must be a real node, not a digest
continue
}
byParent[par] = append(byParent[par], s)
}
}
for _, sibs := range byParent {
if len(sibs) < 2 {
continue // a lone cold child under a parent stays a true singleton (§3①)
}
for i := 1; i < len(sibs); i++ {
uf.union(sibs[0], sibs[i])
}
}
// Emit surviving components as blocks, each with its external-parent anchors.
comps = uf.components()
var blocks []block
for _, ids := range comps {
if len(ids) < p.K {
continue
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
memberSet := make(map[int64]bool, len(ids))
for _, m := range ids {
memberSet[m] = true
}
anchorSet := map[int64]bool{}
for _, m := range ids {
for _, par := range g.parents[m] {
if memberSet[par] {
continue
}
pn, ok := g.nodes[par]
if !ok || pn.Kind == db.KindDigest {
continue
}
anchorSet[par] = true
}
}
anchors := make([]int64, 0, len(anchorSet))
for a := range anchorSet {
anchors = append(anchors, a)
}
sort.Slice(anchors, func(i, j int) bool { return anchors[i] < anchors[j] })
blocks = append(blocks, block{Members: ids, Anchors: anchors})
}
// Deterministic order: by smallest member id.
sort.Slice(blocks, func(i, j int) bool { return blocks[i].Members[0] < blocks[j].Members[0] })
return blocks
}
// blockSignature is the change-detection key (§5.3): a hash over the sorted
// member ids + each member's content_version, plus the anchor ids + versions
// (so an anchor's summary/state change also invalidates the cached body). A
// re-compaction whose block matches an existing active digest's signature
// reuses the stored body and skips the LLM entirely.
func blockSignature(b block, contentVer map[int64]int) string {
h := sha256.New()
for _, m := range b.Members {
fmt.Fprintf(h, "m:%d:%d;", m, contentVer[m])
}
for _, a := range b.Anchors {
fmt.Fprintf(h, "a:%d:%d;", a, contentVer[a])
}
return hex.EncodeToString(h.Sum(nil))
}
// --- union-find ---
type unionFind struct{ parent map[int64]int64 }
func newUnionFind(set map[int64]bool) *unionFind {
uf := &unionFind{parent: make(map[int64]int64, len(set))}
for id := range set {
uf.parent[id] = id
}
return uf
}
func (u *unionFind) find(x int64) int64 {
for u.parent[x] != x {
u.parent[x] = u.parent[u.parent[x]]
x = u.parent[x]
}
return x
}
func (u *unionFind) union(a, b int64) {
ra, rb := u.find(a), u.find(b)
if ra != rb {
u.parent[ra] = rb
}
}
func (u *unionFind) components() map[int64][]int64 {
out := map[int64][]int64{}
for id := range u.parent {
r := u.find(id)
out[r] = append(out[r], id)
}
return out
}