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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user