diff --git a/src/campaign/index.ts b/src/campaign/index.ts index 0cb99327..85280c42 100644 --- a/src/campaign/index.ts +++ b/src/campaign/index.ts @@ -1,3 +1,25 @@ +export { + callbackGovernor, + fsLineageStore, + type Governor, + type GovernorContext, + type GovernorOp, + type HeuristicGovernorOptions, + heuristicGovernor, + Lineage, + type LineageEdge, + type LineageGraph, + type LineageNode, + type LineageNodeInput, + type LineageStore, + lineageNodeId, + memLineageStore, + type RunLineageOptions, + type RunLineageResult, + type RunLineageSeed, + type RunLineageStepResult, + runLineage, +} from './lineage' /** * `@tangle-network/agent-eval/campaign` — measurement + improvement loop. * diff --git a/src/campaign/lineage.test.ts b/src/campaign/lineage.test.ts new file mode 100644 index 00000000..4b4a428e --- /dev/null +++ b/src/campaign/lineage.test.ts @@ -0,0 +1,471 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + callbackGovernor, + fsLineageStore, + type GovernorContext, + type GovernorOp, + heuristicGovernor, + Lineage, + lineageNodeId, + memLineageStore, + runLineage, +} from './lineage' + +describe('lineageNodeId', () => { + it('is deterministic and order-insensitive on parents', () => { + const a = lineageNodeId({ parentIds: ['x', 'y'], track: 't', surface: 's', proposer: 'gepa' }) + const b = lineageNodeId({ parentIds: ['y', 'x'], track: 't', surface: 's', proposer: 'gepa' }) + expect(a).toBe(b) + }) + + it('changes when parents or surface change', () => { + const base = lineageNodeId({ parentIds: ['x'], track: 't', surface: 's', proposer: 'gepa' }) + expect(base).not.toBe( + lineageNodeId({ parentIds: ['z'], track: 't', surface: 's', proposer: 'gepa' }), + ) + expect(base).not.toBe( + lineageNodeId({ parentIds: ['x'], track: 't', surface: 's2', proposer: 'gepa' }), + ) + }) +}) + +describe('Lineage.addNode', () => { + it('assigns monotone seq and derives generation from parents', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'seed' }) + const child = l.addNode({ + parentIds: [root.id], + track: 'a', + surface: 'c', + score: 1, + proposer: 'gepa', + }) + expect(root.seq).toBe(0) + expect(child.seq).toBe(1) + expect(root.generation).toBe(0) + expect(child.generation).toBe(1) + }) + + it('throws on an unknown parent', () => { + const l = new Lineage() + expect(() => + l.addNode({ parentIds: ['nope'], track: 'a', surface: 's', score: 0, proposer: 'gepa' }), + ).toThrow(/unknown parent/) + }) + + it('is idempotent: re-adding an identical node returns the original', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'seed' }) + const again = l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'seed' }) + expect(again.id).toBe(root.id) + expect(again.seq).toBe(root.seq) + expect(l.all()).toHaveLength(1) + }) + + it('is acyclic by construction: a child cannot point at a not-yet-added node', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'seed' }) + // The id a future child WOULD get — not yet in the graph. Trying to parent a + // node on it (a back-edge) fails with unknown-parent: this is exactly the + // structural guarantee that no cycle can form. + const futureChildId = lineageNodeId({ + parentIds: [root.id], + track: 'a', + surface: 'c', + proposer: 'gepa', + }) + expect(() => + l.addNode({ + parentIds: [futureChildId], + track: 'a', + surface: 'x', + score: 1, + proposer: 'gepa', + }), + ).toThrow(/unknown parent/) + }) + + it('no node has an ancestor that is also a descendant (acyclic invariant)', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + const a = l.addNode({ parentIds: [root.id], track: 't', surface: 'a', score: 1, proposer: 'g' }) + const b = l.addNode({ parentIds: [root.id], track: 't', surface: 'b', score: 1, proposer: 'g' }) + const m = l.merge({ parentIds: [a.id, b.id], track: 't', surface: 'm', score: 2 }) + for (const n of [root, a, b, m]) { + const anc = l.ancestors(n.id) + const desc = l.descendants(n.id) + expect([...anc].filter((x) => desc.has(x))).toEqual([]) + } + }) + + it('ancestors/descendants terminate on hand-corrupted cyclic input (visited-set safety)', () => { + // Deserialization of corrupt data could contain a cycle; traversal must not hang. + const cyclic = new Lineage([ + { + id: 'x', + parentIds: ['y'], + track: 't', + surface: 'x', + score: 0, + proposer: 'p', + generation: 0, + seq: 0, + }, + { + id: 'y', + parentIds: ['x'], + track: 't', + surface: 'y', + score: 0, + proposer: 'p', + generation: 0, + seq: 1, + }, + ]) + expect(cyclic.ancestors('x')).toEqual(new Set(['y', 'x'])) + expect(cyclic.descendants('x')).toEqual(new Set(['y', 'x'])) + }) +}) + +describe('Lineage.merge + diamond shape', () => { + it('a merge has all parents; generation = max(parent)+1; ancestors cover all parents', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + const a = l.addNode({ parentIds: [root.id], track: 't', surface: 'a', score: 1, proposer: 'g' }) + const b = l.addNode({ parentIds: [root.id], track: 't', surface: 'b', score: 1, proposer: 'g' }) + const m = l.merge({ parentIds: [a.id, b.id], track: 't', surface: 'm', score: 2 }) + + expect(m.parentIds.sort()).toEqual([a.id, b.id].sort()) + expect(m.proposer).toBe('merge') + expect(m.generation).toBe(2) + expect(l.ancestors(m.id)).toEqual(new Set([root.id, a.id, b.id])) + expect(l.tips().map((n) => n.id)).toEqual([m.id]) + expect(l.descendants(root.id)).toEqual(new Set([a.id, b.id, m.id])) + }) + + it('throws when a merge has fewer than 2 parents', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + expect(() => l.merge({ parentIds: [root.id], track: 't', surface: 'm', score: 1 })).toThrow( + />= 2 parents/, + ) + }) +}) + +describe('Lineage.frontier', () => { + it('scalar-score frontier keeps only the best tip', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + l.addNode({ parentIds: [root.id], track: 'a', surface: 'a', score: 0.4, proposer: 'g' }) + l.addNode({ parentIds: [root.id], track: 'b', surface: 'b', score: 0.9, proposer: 'g' }) + const frontier = l.frontier() + expect(frontier).toHaveLength(1) + expect(frontier[0]!.surface).toBe('b') + }) + + it('Pareto frontier by scoreVector excludes a dominated tip', () => { + const l = new Lineage() + const root = l.addNode({ + parentIds: [], + track: 't', + surface: 'r', + score: 0, + proposer: 'seed', + scoreVector: [0, 0], + }) + // Two non-dominated (trade off across the two scenarios) + one dominated. + const hi1 = l.addNode({ + parentIds: [root.id], + track: 'a', + surface: 'hi1', + score: 1, + proposer: 'g', + scoreVector: [1, 0.2], + }) + const hi2 = l.addNode({ + parentIds: [root.id], + track: 'b', + surface: 'hi2', + score: 1, + proposer: 'g', + scoreVector: [0.2, 1], + }) + const dominated = l.addNode({ + parentIds: [root.id], + track: 'c', + surface: 'dom', + score: 1, + proposer: 'g', + scoreVector: [0.1, 0.1], + }) + const ids = l + .frontier() + .map((n) => n.id) + .sort() + expect(ids).toEqual([hi1.id, hi2.id].sort()) + expect(ids).not.toContain(dominated.id) + }) +}) + +describe('persistence', () => { + it('toJSONL/fromJSONL round-trips the graph exactly', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + const a = l.addNode({ parentIds: [root.id], track: 't', surface: 'a', score: 1, proposer: 'g' }) + const b = l.addNode({ parentIds: [root.id], track: 'u', surface: 'b', score: 1, proposer: 'g' }) + l.merge({ parentIds: [a.id, b.id], track: 't', surface: 'm', score: 2 }) + const restored = Lineage.fromJSONL(l.toJSONL()) + expect(restored.toGraph()).toEqual(l.toGraph()) + }) + + it('fsLineageStore appends and reloads in a tmp dir', async () => { + const dir = await mkdtemp(join(tmpdir(), 'lineage-')) + const path = join(dir, 'nested', 'lineage.jsonl') + const store = fsLineageStore(path) + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + await store.append(root) + const child = l.addNode({ + parentIds: [root.id], + track: 't', + surface: 'c', + score: 1, + proposer: 'g', + }) + await store.append(child) + + const reloaded = await store.load() + expect(reloaded.all().map((n) => n.id)).toEqual([root.id, child.id]) + // The file is real JSONL. + const raw = await readFile(path, 'utf8') + expect(raw.trim().split('\n')).toHaveLength(2) + }) + + it('memLineageStore round-trips', async () => { + const store = memLineageStore() + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + await store.append(root) + const reloaded = await store.load() + expect(reloaded.all()).toHaveLength(1) + }) +}) + +describe('heuristicGovernor', () => { + function ctx( + lineage: Lineage, + prunedTracks: string[] = [], + budgetRemaining = 10, + ): GovernorContext { + return { lineage, step: 0, budgetRemaining, prunedTracks } + } + + it('extends a single climbing track', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'g' }) + l.addNode({ parentIds: [root.id], track: 'a', surface: 'c', score: 0.5, proposer: 'g' }) + const gov = heuristicGovernor() + expect(gov.decide(ctx(l))).toEqual({ op: 'extend', track: 'a' }) + }) + + it('proposes a merge when >= 2 distinct-track frontier tips exist', () => { + const l = new Lineage() + const root = l.addNode({ parentIds: [], track: 't', surface: 'r', score: 0, proposer: 'seed' }) + l.addNode({ + parentIds: [root.id], + track: 'a', + surface: 'a', + score: 1, + proposer: 'g', + scoreVector: [1, 0.2], + }) + l.addNode({ + parentIds: [root.id], + track: 'b', + surface: 'b', + score: 1, + proposer: 'g', + scoreVector: [0.2, 1], + }) + const op = heuristicGovernor().decide(ctx(l)) as Extract + expect(op.op).toBe('merge') + expect(op.parentIds).toHaveLength(2) + }) + + it('prunes a plateaued track when another track remains', () => { + const l = new Lineage() + // Track a plateaus (0.5, 0.5, 0.5); track b keeps a live tip so pruning a is safe. + const ra = l.addNode({ parentIds: [], track: 'a', surface: 'ra', score: 0.5, proposer: 'g' }) + const a1 = l.addNode({ + parentIds: [ra.id], + track: 'a', + surface: 'a1', + score: 0.5, + proposer: 'g', + }) + l.addNode({ parentIds: [a1.id], track: 'a', surface: 'a2', score: 0.5, proposer: 'g' }) + l.addNode({ parentIds: [], track: 'b', surface: 'rb', score: 0.9, proposer: 'g' }) + // Frontier has 2 tips (a2, rb) which would trigger merge first; give them equal + // vectors so only b is on the scalar frontier, isolating the prune path. + const gov = heuristicGovernor({ mergeFrontierAt: 99 }) + const op = gov.decide(ctx(l)) + expect(op).toEqual({ op: 'prune', track: 'a' }) + }) + + it('stops when budget is exhausted', () => { + const l = new Lineage() + l.addNode({ parentIds: [], track: 'a', surface: 'r', score: 0, proposer: 'g' }) + expect(heuristicGovernor().decide(ctx(l, [], 0))).toEqual({ op: 'stop' }) + }) + + it('is deterministic: identical inputs yield identical ops', () => { + const build = () => { + const l = new Lineage() + const root = l.addNode({ + parentIds: [], + track: 't', + surface: 'r', + score: 0, + proposer: 'seed', + }) + l.addNode({ + parentIds: [root.id], + track: 'a', + surface: 'a', + score: 1, + proposer: 'g', + scoreVector: [1, 0.2], + }) + l.addNode({ + parentIds: [root.id], + track: 'b', + surface: 'b', + score: 1, + proposer: 'g', + scoreVector: [0.2, 1], + }) + return l + } + const gov = heuristicGovernor() + expect(gov.decide(ctx(build()))).toEqual(gov.decide(ctx(build()))) + }) +}) + +describe('runLineage end-to-end', () => { + // A deterministic stub: each extend adds a fixed climb, then plateaus at a ceiling. + const climbingStep = + (ceiling = 0.9, climb = 0.2) => + async (args: { tip: { score: number } }) => { + const next = Math.min(ceiling, args.tip.score + climb) + return { surface: `s@${next.toFixed(3)}`, score: next, scoreVector: [next, next] } + } + const mergeStub = async (args: { parents: Array<{ score: number }> }) => { + const best = Math.max(...args.parents.map((p) => p.score)) + return { surface: `merged@${best.toFixed(3)}`, score: best + 0.01, scoreVector: [best, best] } + } + + it('runs 3 visioned tracks, produces a merge/diamond, and is deterministic on re-run', async () => { + const seeds = [ + { + surface: 's0', + track: 'solve', + vision: 'solve', + proposer: 'gepa', + score: 0.1, + scoreVector: [0.1, 0.1], + }, + { + surface: 'o0', + track: 'outside-the-box', + vision: 'outside-the-box', + proposer: 'gepa', + score: 0.1, + scoreVector: [0.1, 0.1], + }, + { + surface: 'c0', + track: 'contrarian', + vision: 'contrarian', + proposer: 'gepa', + score: 0.1, + scoreVector: [0.1, 0.1], + }, + ] + const run = () => + runLineage({ + seeds, + step: climbingStep(), + merge: mergeStub, + governor: heuristicGovernor(), + budget: { maxSteps: 20 }, + }) + + const r1 = await run() + expect(r1.lineage.tracks().length).toBeGreaterThan(1) + expect(r1.lineage.all().some((n) => n.parentIds.length >= 2)).toBe(true) // a merge exists + expect(r1.best!.score).toBe(Math.max(...r1.lineage.all().map((n) => n.score))) + expect(r1.steps).toBeLessThanOrEqual(20) + // three distinct visions seeded + expect(new Set(r1.lineage.roots().map((n) => n.vision))).toEqual( + new Set(['solve', 'outside-the-box', 'contrarian']), + ) + + const r2 = await run() + expect(r2.lineage.toGraph()).toEqual(r1.lineage.toGraph()) // end-to-end determinism + }) + + it('honors an explicit prune (a pruned track is never extended again)', async () => { + let decideCount = 0 + const gov = callbackGovernor(async (_c: GovernorContext): Promise => { + decideCount += 1 + if (decideCount === 1) return { op: 'prune', track: 'a' } + if (decideCount === 2) return { op: 'extend', track: 'a' } // must be skipped + return { op: 'stop' } + }) + const extended: string[] = [] + const result = await runLineage({ + seeds: [ + { surface: 'a0', track: 'a', proposer: 'g', score: 0.1 }, + { surface: 'b0', track: 'b', proposer: 'g', score: 0.1 }, + ], + step: async (args) => { + extended.push(args.track) + return { surface: 'x', score: 0.5 } + }, + merge: mergeStub, + governor: gov, + budget: { maxSteps: 10 }, + }) + expect(extended).not.toContain('a') // pruned track never extended + expect(result.lineage.trackNodes('a')).toHaveLength(1) // only its seed + }) + + it('honors op stop immediately', async () => { + const result = await runLineage({ + seeds: [{ surface: 'a0', track: 'a', proposer: 'g', score: 0.1 }], + step: climbingStep(), + merge: mergeStub, + governor: callbackGovernor(async () => ({ op: 'stop' })), + budget: { maxSteps: 10 }, + }) + expect(result.steps).toBe(0) + expect(result.lineage.all()).toHaveLength(1) // only the seed + }) + + it('persists every node to a provided store', async () => { + const store = memLineageStore() + await runLineage({ + seeds: [{ surface: 'a0', track: 'a', proposer: 'g', score: 0.1 }], + step: climbingStep(), + merge: mergeStub, + governor: heuristicGovernor(), + budget: { maxSteps: 5 }, + store, + }) + const reloaded = await store.load() + expect(reloaded.all().length).toBeGreaterThan(1) + }) +}) diff --git a/src/campaign/lineage.ts b/src/campaign/lineage.ts new file mode 100644 index 00000000..bd366540 --- /dev/null +++ b/src/campaign/lineage.ts @@ -0,0 +1,685 @@ +/** + * Lineage DAG — a git-graph of improvement candidates. + * + * The improvement loop (`run-optimization.ts`) records a LINEAR `GenerationRecord[]` + * with no parent pointers, computes a Pareto frontier within a run, then collapses + * to one gated winner and discards the rest. This module adds the missing + * first-class structure: a directed acyclic graph of candidate versions where + * + * - a node with ONE parent is a mutation, + * - a node with ZERO parents is a root/seed, + * - a node with 2+ parents is a MERGE (a "collapse") — nothing special-cased, + * just a multi-parent node, + * - a `track` groups a lineage into an island, and each track runs a distinct + * `vision` (a proposer strategy — e.g. a "solve" vision, an "outside-the-box" + * vision, and an adversarial "contrarian" vision that attacks the leader). + * + * An agent-managed {@link Governor} decides the next operation (extend / branch / + * merge / prune / stop), so branching AND collapsing are choices a supervisor + * makes by reading the graph, not hard-coded control flow. + * + * DETERMINISM is a hard invariant: no `Date.now`, `Math.random`, or `new Date`. + * Ids are content+lineage hashes; order is a monotone insertion counter; every + * query returns nodes in `seq` order. Same inputs ⇒ identical graph. + */ + +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +export interface LineageNode { + /** Deterministic content+lineage hash (see {@link lineageNodeId}). */ + id: string + /** `[]` = root/seed; `[x]` = mutation; `[x, y, ...]` = MERGE (collapse). */ + parentIds: string[] + /** Logical island/track this node belongs to. */ + track: string + /** Human label for the strategy driving this track (e.g. `solve`, `contrarian`). */ + vision?: string + /** Candidate content (prompt text, skill document, config JSON, ...). */ + surface: string + /** Scalar fitness (e.g. mean holdout composite); higher is better. */ + score: number + /** Optional per-scenario objective vector for Pareto dominance. */ + scoreVector?: number[] + /** Which proposer produced it (`gepa`, `skill-opt`, `merge`, `seed`, ...). */ + proposer: string + rationale?: string + /** Gate verdict, when this node was gated. */ + gate?: 'ship' | 'hold' + /** Step index within its track (root = 0). */ + generation: number + /** Monotone global insertion order (assigned by {@link Lineage.addNode}). */ + seq: number +} + +export interface LineageEdge { + /** Parent node id. */ + from: string + /** Child node id. */ + to: string +} + +export interface LineageGraph { + nodes: LineageNode[] + edges: LineageEdge[] +} + +/** Deterministic node id: a hash of the node's lineage + content + proposer. + * Pure — identical inputs always yield the same id (a re-derived node collapses + * onto its original rather than duplicating). */ +export function lineageNodeId(input: { + parentIds: string[] + track: string + surface: string + proposer: string +}): string { + const parents = [...input.parentIds].sort().join(',') + const payload = `${parents}|${input.track}|${input.surface}|${input.proposer}` + return createHash('sha256').update(payload).digest('hex').slice(0, 16) +} + +/** Input to {@link Lineage.addNode}: everything but the derived `id`/`seq` and the + * optional `generation` (derived from parents when omitted). */ +export type LineageNodeInput = Omit & { + generation?: number +} + +export class Lineage { + private readonly byId = new Map() + private readonly childIds = new Map() + private nextSeq = 0 + + constructor(nodes?: readonly LineageNode[]) { + if (!nodes) return + // Rehydrate in seq order so parents always precede children and the monotone + // counter resumes past the highest persisted seq. + for (const node of [...nodes].sort((a, b) => a.seq - b.seq)) { + this.index(node) + this.nextSeq = Math.max(this.nextSeq, node.seq + 1) + } + } + + /** Append a node. Derives `id` (via {@link lineageNodeId}), assigns the next + * `seq`, and derives `generation` as `max(parent.generation) + 1` (root = 0) + * when omitted. Throws on an unknown parent. Idempotent: re-adding an identical + * node returns the existing one. + * + * Acyclicity is guaranteed by construction, not by a runtime check: every + * parent must already exist (a child can never point at a not-yet-added node), + * ids are immutable content hashes (an existing node can never gain new + * parents), and the store is append-only — so no back-edge can form. (Traversal + * is still cycle-safe against hand-corrupted deserialized input via visited + * sets in {@link ancestors}/{@link descendants}.) */ + addNode(input: LineageNodeInput): LineageNode { + for (const parentId of input.parentIds) { + if (!this.byId.has(parentId)) { + throw new Error(`Lineage.addNode: unknown parent '${parentId}'`) + } + } + const id = lineageNodeId({ + parentIds: input.parentIds, + track: input.track, + surface: input.surface, + proposer: input.proposer, + }) + const existing = this.byId.get(id) + if (existing) return existing + + const generation = + input.generation ?? + (input.parentIds.length === 0 + ? 0 + : Math.max(...input.parentIds.map((p) => this.byId.get(p)!.generation)) + 1) + + const node: LineageNode = { + id, + parentIds: [...input.parentIds], + track: input.track, + surface: input.surface, + score: input.score, + proposer: input.proposer, + generation, + seq: this.nextSeq++, + ...(input.vision !== undefined ? { vision: input.vision } : {}), + ...(input.scoreVector !== undefined ? { scoreVector: [...input.scoreVector] } : {}), + ...(input.rationale !== undefined ? { rationale: input.rationale } : {}), + ...(input.gate !== undefined ? { gate: input.gate } : {}), + } + this.index(node) + return node + } + + /** Collapse 2+ parents into a single node (a merge/"collapse"). A merge is an + * ordinary multi-parent node; this is a guarded convenience. */ + merge(input: { + parentIds: string[] + track: string + surface: string + score: number + proposer?: string + vision?: string + scoreVector?: number[] + rationale?: string + }): LineageNode { + if (input.parentIds.length < 2) { + throw new Error(`Lineage.merge: a merge needs >= 2 parents, got ${input.parentIds.length}`) + } + return this.addNode({ ...input, proposer: input.proposer ?? 'merge' }) + } + + get(id: string): LineageNode | undefined { + return this.byId.get(id) + } + + has(id: string): boolean { + return this.byId.has(id) + } + + /** All nodes, in insertion (`seq`) order. */ + all(): LineageNode[] { + return [...this.byId.values()].sort(bySeq) + } + + roots(): LineageNode[] { + return this.all().filter((n) => n.parentIds.length === 0) + } + + parents(id: string): LineageNode[] { + const node = this.byId.get(id) + if (!node) return [] + return node.parentIds + .map((p) => this.byId.get(p)) + .filter((n): n is LineageNode => n !== undefined) + .sort(bySeq) + } + + children(id: string): LineageNode[] { + return (this.childIds.get(id) ?? []).map((c) => this.byId.get(c)!).sort(bySeq) + } + + /** Transitive ancestors (excludes `id`). */ + ancestors(id: string): Set { + const out = new Set() + const stack = [...(this.byId.get(id)?.parentIds ?? [])] + while (stack.length > 0) { + const cur = stack.pop()! + if (out.has(cur)) continue + out.add(cur) + const node = this.byId.get(cur) + if (node) stack.push(...node.parentIds) + } + return out + } + + /** Transitive descendants (excludes `id`). */ + descendants(id: string): Set { + const out = new Set() + const stack = [...(this.childIds.get(id) ?? [])] + while (stack.length > 0) { + const cur = stack.pop()! + if (out.has(cur)) continue + out.add(cur) + stack.push(...(this.childIds.get(cur) ?? [])) + } + return out + } + + /** Nodes with no children (leaf/branch tips). */ + tips(): LineageNode[] { + return this.all().filter((n) => (this.childIds.get(n.id) ?? []).length === 0) + } + + /** Distinct track ids, in first-seen (`seq`) order. */ + tracks(): string[] { + const seen = new Set() + const out: string[] = [] + for (const node of this.all()) { + if (!seen.has(node.track)) { + seen.add(node.track) + out.push(node.track) + } + } + return out + } + + trackNodes(track: string): LineageNode[] { + return this.all().filter((n) => n.track === track) + } + + /** The highest-`score` tip of a track (ties broken by lowest `seq`). */ + trackTip(track: string): LineageNode | undefined { + return pickBest(this.tips().filter((n) => n.track === track)) + } + + /** The highest-`score` node overall (ties broken by lowest `seq`). */ + best(): LineageNode | undefined { + return pickBest(this.all()) + } + + /** The Pareto-non-dominated set among TIPS. Uses `scoreVector` when every + * compared tip carries one, else the scalar `score`. A dominates B iff A is + * >= B on every component and > B on at least one. */ + frontier(): LineageNode[] { + const tips = this.tips() + const useVector = tips.length > 0 && tips.every((n) => n.scoreVector !== undefined) + const vecOf = (n: LineageNode): number[] => (useVector ? n.scoreVector! : [n.score]) + return tips + .filter((a) => !tips.some((b) => b.id !== a.id && dominates(vecOf(b), vecOf(a)))) + .sort(bySeq) + } + + toGraph(): LineageGraph { + const nodes = this.all() + const edges: LineageEdge[] = [] + for (const node of nodes) { + for (const parentId of node.parentIds) { + edges.push({ from: parentId, to: node.id }) + } + } + return { nodes, edges } + } + + /** One JSON node per line, in `seq` order. */ + toJSONL(): string { + return this.all() + .map((n) => JSON.stringify(n)) + .join('\n') + } + + static fromJSONL(text: string): Lineage { + const nodes = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as LineageNode) + return new Lineage(nodes) + } + + private index(node: LineageNode): void { + this.byId.set(node.id, node) + for (const parentId of node.parentIds) { + const list = this.childIds.get(parentId) + if (list) { + if (!list.includes(node.id)) list.push(node.id) + } else { + this.childIds.set(parentId, [node.id]) + } + } + } +} + +function bySeq(a: LineageNode, b: LineageNode): number { + return a.seq - b.seq +} + +/** Highest score, ties broken by lowest seq. */ +function pickBest(nodes: LineageNode[]): LineageNode | undefined { + let best: LineageNode | undefined + for (const node of nodes) { + if (!best || node.score > best.score || (node.score === best.score && node.seq < best.seq)) { + best = node + } + } + return best +} + +/** True iff `a` Pareto-dominates `b`: `a[i] >= b[i]` for all i and `a[i] > b[i]` + * for some i. Vectors of unequal length never dominate (incomparable). */ +function dominates(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false + let strictlyBetter = false + for (let i = 0; i < a.length; i += 1) { + if (a[i]! < b[i]!) return false + if (a[i]! > b[i]!) strictlyBetter = true + } + return strictlyBetter +} + +// ── Persistence ────────────────────────────────────────────────────────────── + +export interface LineageStore { + /** Load the persisted lineage (an empty `Lineage` when nothing is stored). */ + load(): Promise + /** Append one node durably. */ + append(node: LineageNode): Promise + /** Overwrite with a full snapshot. */ + save(lineage: Lineage): Promise +} + +/** JSONL-file store: append-only durability, snapshot via rewrite, `load` parses + * through {@link Lineage.fromJSONL}. */ +export function fsLineageStore(path: string): LineageStore { + const ensureDir = () => mkdir(dirname(path), { recursive: true }) + return { + async load() { + try { + return Lineage.fromJSONL(await readFile(path, 'utf8')) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return new Lineage() + throw err + } + }, + async append(node) { + await ensureDir() + await appendFile(path, `${JSON.stringify(node)}\n`, 'utf8') + }, + async save(lineage) { + await ensureDir() + const jsonl = lineage.toJSONL() + await writeFile(path, jsonl.length > 0 ? `${jsonl}\n` : '', 'utf8') + }, + } +} + +/** In-memory store (default; for tests and ephemeral runs). */ +export function memLineageStore(): LineageStore { + const nodes: LineageNode[] = [] + return { + async load() { + return new Lineage(nodes) + }, + async append(node) { + nodes.push(node) + }, + async save(lineage) { + nodes.length = 0 + nodes.push(...lineage.all()) + }, + } +} + +// ── Governor (agent-managed decision layer) ────────────────────────────────── + +export type GovernorOp = + | { op: 'extend'; track: string } + | { op: 'branch'; fromNodeId: string; track: string; proposer: string; vision?: string } + | { op: 'merge'; parentIds: string[]; track: string } + | { op: 'prune'; track: string } + | { op: 'stop' } + +export interface GovernorContext { + lineage: Lineage + /** Operations executed so far. */ + step: number + budgetRemaining: number + /** Tracks already pruned — the governor must not target these for extend/branch. */ + prunedTracks: string[] +} + +export interface Governor { + decide(ctx: GovernorContext): Promise | GovernorOp +} + +export interface HeuristicGovernorOptions { + /** Cap on live tracks before branching a new one. Default 3. */ + maxTracks?: number + /** Non-improving steps a track may take before it is pruned. Default 2. */ + plateauSteps?: number + /** Frontier-tip count (across distinct tracks) that triggers a merge. Default 2. */ + mergeFrontierAt?: number +} + +/** The reference deterministic policy an agent {@link Governor} can replace. + * Reads only the lineage + context — no LLM, no randomness. */ +export function heuristicGovernor(opts: HeuristicGovernorOptions = {}): Governor { + const maxTracks = opts.maxTracks ?? 3 + const plateauSteps = opts.plateauSteps ?? 2 + const mergeFrontierAt = opts.mergeFrontierAt ?? 2 + + return { + decide(ctx: GovernorContext): GovernorOp { + const { lineage } = ctx + if (ctx.budgetRemaining <= 0) return { op: 'stop' } + + const pruned = new Set(ctx.prunedTracks) + const liveTracks = lineage.tracks().filter((t) => !pruned.has(t)) + if (liveTracks.length === 0) return { op: 'stop' } + + // Merge first: when distinct-track frontier tips have accumulated, collapse + // them into the best-scoring track so parallel progress consolidates. + const frontierByTrack = new Map() + for (const tip of lineage.frontier()) { + if (pruned.has(tip.track)) continue + if (!frontierByTrack.has(tip.track)) frontierByTrack.set(tip.track, tip) + } + if (frontierByTrack.size >= mergeFrontierAt) { + const tips = [...frontierByTrack.values()].sort(bySeq) + const target = pickBest(tips)! + return { op: 'merge', parentIds: tips.map((t) => t.id), track: target.track } + } + + // Prune a plateaued track (best score unchanged over the last `plateauSteps` + // nodes of the track). + for (const track of liveTracks) { + if (isPlateaued(lineage.trackNodes(track), plateauSteps)) { + // Only prune if another live track remains OR we can branch — otherwise + // pruning the last track just stops the run prematurely. + if (liveTracks.length > 1 || liveTracks.length < maxTracks) { + return { op: 'prune', track } + } + } + } + + // Branch a fresh track when there is headroom and the leader has plateaued. + const leader = pickBest(liveTracks.map((t) => lineage.trackTip(t)!).filter(Boolean)) + if ( + leader && + liveTracks.length < maxTracks && + isPlateaued(lineage.trackNodes(leader.track), plateauSteps) + ) { + return { + op: 'branch', + fromNodeId: leader.id, + track: `${leader.track}+${lineage.tracks().length}`, + proposer: 'gepa', + } + } + + // Otherwise extend the best climbing track. + if (leader) return { op: 'extend', track: leader.track } + return { op: 'stop' } + }, + } +} + +/** The LLM-supervisor slot: a governor whose `decide` defers to a caller-supplied + * async function (which may read `ctx.lineage.toGraph()`). */ +export function callbackGovernor(decide: (ctx: GovernorContext) => Promise): Governor { + return { decide } +} + +/** A track is plateaued when its best score has not improved across its most + * recent `window` nodes (needs > `window` nodes to judge). */ +function isPlateaued(trackNodes: LineageNode[], window: number): boolean { + if (trackNodes.length <= window) return false + const ordered = [...trackNodes].sort(bySeq) + const head = ordered.slice(0, ordered.length - window) + const tail = ordered.slice(ordered.length - window) + const bestBefore = Math.max(...head.map((n) => n.score)) + const bestRecent = Math.max(...tail.map((n) => n.score)) + return bestRecent <= bestBefore +} + +// ── Orchestration ──────────────────────────────────────────────────────────── + +export interface RunLineageSeed { + surface: string + track: string + vision?: string + proposer: string + score: number + scoreVector?: number[] +} + +export interface RunLineageStepResult { + surface: string + score: number + scoreVector?: number[] + rationale?: string + gate?: 'ship' | 'hold' +} + +export interface RunLineageOptions { + seeds: RunLineageSeed[] + /** Produce one new candidate from a track's tip (propose + measure + gate in + * real use; a pure function in tests). */ + step: (args: { + track: string + proposer: string + tip: LineageNode + }) => Promise + /** Collapse 2+ parent surfaces into one (GEPA crossover / LLM merge in real use). */ + merge: (args: { + parents: LineageNode[] + track: string + }) => Promise> + governor: Governor + budget: { maxSteps: number } + store?: LineageStore + log?: (msg: string, fields?: Record) => void +} + +export interface RunLineageResult { + lineage: Lineage + best: LineageNode | undefined + steps: number +} + +/** Drive a multi-track improvement DAG under an agent-managed governor. Seeds each + * entry as a root, then repeatedly asks the governor for the next operation + * (extend / branch / merge / prune / stop) up to `budget.maxSteps`, persisting + * every node. Honors `prune`: a pruned track is never extended or branched again. */ +export async function runLineage(opts: RunLineageOptions): Promise { + const store = opts.store ?? memLineageStore() + const lineage = await store.load() + const log = opts.log ?? (() => {}) + const pruned = new Set() + + // The proposer a track extends with — seeded, inherited by branches. + const trackProposer = new Map() + + const persist = async (node: LineageNode) => { + await store.append(node) + } + + for (const seed of opts.seeds) { + const node = lineage.addNode({ + parentIds: [], + track: seed.track, + surface: seed.surface, + score: seed.score, + proposer: seed.proposer, + ...(seed.vision !== undefined ? { vision: seed.vision } : {}), + ...(seed.scoreVector !== undefined ? { scoreVector: seed.scoreVector } : {}), + }) + trackProposer.set(seed.track, seed.proposer) + await persist(node) + } + + let steps = 0 + while (steps < opts.budget.maxSteps) { + const op = await opts.governor.decide({ + lineage, + step: steps, + budgetRemaining: opts.budget.maxSteps - steps, + prunedTracks: [...pruned], + }) + + if (op.op === 'stop') { + log('lineage: governor stop', { steps }) + break + } + + if (op.op === 'prune') { + pruned.add(op.track) + log('lineage: prune', { track: op.track }) + steps += 1 + continue + } + + if (op.op === 'merge') { + const parents = op.parentIds + .map((id) => lineage.get(id)) + .filter((n): n is LineageNode => n !== undefined) + if (parents.length < 2) { + log('lineage: merge skipped (fewer than 2 known parents)', { op }) + steps += 1 + continue + } + const result = await opts.merge({ parents, track: op.track }) + const node = lineage.merge({ + parentIds: parents.map((p) => p.id), + track: op.track, + surface: result.surface, + score: result.score, + ...(parents[0]!.vision !== undefined ? { vision: parents[0]!.vision } : {}), + ...(result.scoreVector !== undefined ? { scoreVector: result.scoreVector } : {}), + ...(result.rationale !== undefined ? { rationale: result.rationale } : {}), + }) + await persist(node) + steps += 1 + continue + } + + if (op.op === 'branch') { + if (pruned.has(op.track)) { + log('lineage: branch skipped (target track pruned)', { track: op.track }) + steps += 1 + continue + } + const from = lineage.get(op.fromNodeId) + if (!from) { + log('lineage: branch skipped (unknown fromNodeId)', { op }) + steps += 1 + continue + } + trackProposer.set(op.track, op.proposer) + const result = await opts.step({ track: op.track, proposer: op.proposer, tip: from }) + const node = lineage.addNode({ + parentIds: [from.id], + track: op.track, + surface: result.surface, + score: result.score, + proposer: op.proposer, + ...(op.vision !== undefined ? { vision: op.vision } : {}), + ...(result.scoreVector !== undefined ? { scoreVector: result.scoreVector } : {}), + ...(result.rationale !== undefined ? { rationale: result.rationale } : {}), + ...(result.gate !== undefined ? { gate: result.gate } : {}), + }) + await persist(node) + steps += 1 + continue + } + + // op.op === 'extend' + if (pruned.has(op.track)) { + log('lineage: extend skipped (track pruned)', { track: op.track }) + steps += 1 + continue + } + const tip = lineage.trackTip(op.track) + if (!tip) { + log('lineage: extend skipped (no tip for track)', { track: op.track }) + steps += 1 + continue + } + const proposer = trackProposer.get(op.track) ?? tip.proposer + const result = await opts.step({ track: op.track, proposer, tip }) + const node = lineage.addNode({ + parentIds: [tip.id], + track: op.track, + surface: result.surface, + score: result.score, + proposer, + ...(tip.vision !== undefined ? { vision: tip.vision } : {}), + ...(result.scoreVector !== undefined ? { scoreVector: result.scoreVector } : {}), + ...(result.rationale !== undefined ? { rationale: result.rationale } : {}), + ...(result.gate !== undefined ? { gate: result.gate } : {}), + }) + await persist(node) + steps += 1 + } + + return { lineage, best: lineage.best(), steps } +}