diff --git a/tools/workflow-engine/evolution.test.ts b/tools/workflow-engine/evolution.test.ts new file mode 100644 index 0000000000..c82bcd5d2b --- /dev/null +++ b/tools/workflow-engine/evolution.test.ts @@ -0,0 +1,207 @@ +/** + * tools/workflow-engine/evolution.test.ts + * + * B-0914.5 — invariant tests for pure-TS evolution agent. + * + * Run via: bun test tools/workflow-engine/evolution.test.ts + */ + +import { describe, expect, it } from "bun:test"; +import { + evolveSurvivors, + evolveTopN, + type EvolutionStrategy, + type Survivor, +} from "./evolution"; + +interface Hypothesis extends Record { + mechanism: string; + drugCandidate?: string; + pathway?: string; + evidence: number; +} + +const survivor = (id: string, substrate: Hypothesis, skill: number): Survivor => ({ + id, + substrate, + conservativeSkill: skill, + composesWith: [`source-${id}`], +}); + +describe("B-0914.5 evolution agent substrate (mash + refine)", () => { + it("simple-merge: top survivor's substrate as base + fills gaps from next", () => { + const top = survivor( + "h1", + { mechanism: "ER-stress-inhibition", drugCandidate: "cur-6", evidence: 0.8 }, + 30, + ); + const next = survivor( + "h2", + { mechanism: "kinase-inhibition", pathway: "PI3K-mTOR", evidence: 0.6 }, + 20, + ); + const result = evolveSurvivors({ survivors: [top, next], strategy: "simple-merge" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.variants.length).toBe(1); + const variant = result.variants[0]!; + expect(variant.strategy).toBe("simple-merge"); + expect(variant.derivedFrom).toEqual(["h1", "h2"]); + // top wins on overlap + expect(variant.substrate.mechanism).toBe("ER-stress-inhibition"); + expect(variant.substrate.drugCandidate).toBe("cur-6"); + expect(variant.substrate.evidence).toBe(0.8); + // gap (pathway) filled from next + expect(variant.substrate.pathway).toBe("PI3K-mTOR"); + }); + + it("cross-pollinate: interleaves attributes between top 2", () => { + const top = survivor( + "a", + { mechanism: "A-mech", drugCandidate: "A-drug", pathway: "A-path", evidence: 0.9 }, + 30, + ); + const next = survivor( + "b", + { mechanism: "B-mech", drugCandidate: "B-drug", pathway: "B-path", evidence: 0.7 }, + 20, + ); + const result = evolveSurvivors({ survivors: [top, next], strategy: "cross-pollinate" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const variant = result.variants[0]!; + expect(variant.strategy).toBe("cross-pollinate"); + // 4 keys sorted: drugCandidate, evidence, mechanism, pathway + // indices: 0=drugCandidate (top), 1=evidence (next), 2=mechanism (top), 3=pathway (next) + expect(variant.substrate.drugCandidate).toBe("A-drug"); + expect(variant.substrate.evidence).toBe(0.7); + expect(variant.substrate.mechanism).toBe("A-mech"); + expect(variant.substrate.pathway).toBe("B-path"); + }); + + it("mutate: applies caller-supplied transformer to top survivor", () => { + const top = survivor( + "h1", + { mechanism: "ER-stress", evidence: 0.8 }, + 30, + ); + const result = evolveSurvivors({ + survivors: [top], + strategy: "mutate", + mutator: (s) => ({ ...s, evidence: s.evidence * 1.1 }), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const variant = result.variants[0]!; + expect(variant.strategy).toBe("mutate"); + expect(variant.derivedFrom).toEqual(["h1"]); + expect(variant.substrate.evidence).toBeCloseTo(0.88, 6); + expect(variant.substrate.mechanism).toBe("ER-stress"); + }); + + it("mutate without mutator → MergeConflict", () => { + const top = survivor("h1", { mechanism: "x", evidence: 0.5 }, 30); + const result = evolveSurvivors({ survivors: [top], strategy: "mutate" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("MergeConflict"); + if (result.feedback.kind === "MergeConflict") { + expect(result.feedback.survivorId).toBe("h1"); + } + }); + + it("empty survivor set → EmptySurvivorSet", () => { + const result = evolveSurvivors({ survivors: [], strategy: "simple-merge" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("EmptySurvivorSet"); + }); + + it("simple-merge with 1 survivor → InsufficientSurvivors", () => { + const top = survivor("h1", { mechanism: "x", evidence: 0.5 }, 30); + const result = evolveSurvivors({ survivors: [top], strategy: "simple-merge" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("InsufficientSurvivors"); + if (result.feedback.kind === "InsufficientSurvivors") { + expect(result.feedback.required).toBe(2); + expect(result.feedback.provided).toBe(1); + } + }); + + it("cross-pollinate with 1 survivor → InsufficientSurvivors", () => { + const top = survivor("h1", { mechanism: "x", evidence: 0.5 }, 30); + const result = evolveSurvivors({ survivors: [top], strategy: "cross-pollinate" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("InsufficientSurvivors"); + }); + + it("derivedFrom + composesWith preserve provenance per honor-those-that-came-before", () => { + const a = survivor("a", { mechanism: "x", evidence: 0.5 }, 30); + const b = survivor("b", { mechanism: "y", evidence: 0.4 }, 20); + const result = evolveSurvivors({ survivors: [a, b], strategy: "simple-merge" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const variant = result.variants[0]!; + expect(variant.derivedFrom).toEqual(["a", "b"]); + // composesWith includes source attributions + the evolution row + expect(variant.composesWith).toContain("source-a"); + expect(variant.composesWith).toContain("source-b"); + expect(variant.composesWith).toContain("B-0914.5"); + }); + + it("evolveTopN slices top-N from sorted survivors", () => { + const survivors = [ + survivor("h1", { mechanism: "alpha", evidence: 0.9 }, 30), + survivor("h2", { mechanism: "beta", evidence: 0.8 }, 25), + survivor("h3", { mechanism: "gamma", evidence: 0.7 }, 20), + survivor("h4", { mechanism: "delta", evidence: 0.6 }, 15), + ]; + const result = evolveTopN(survivors, 2, "simple-merge"); + expect(result.ok).toBe(true); + if (!result.ok) return; + // Only top 2 used for evolution + expect(result.variants[0]!.derivedFrom).toEqual(["h1", "h2"]); + }); + + it("evolveTopN with N=1 mutate works", () => { + const survivors = [ + survivor("h1", { mechanism: "alpha", evidence: 0.9 }, 30), + survivor("h2", { mechanism: "beta", evidence: 0.8 }, 25), + ]; + const result = evolveTopN(survivors, 1, "mutate", { + mutator: (s) => ({ ...s, evidence: s.evidence + 0.01 }), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.variants[0]!.substrate.evidence).toBeCloseTo(0.91, 6); + }); + + it("variant id includes prefix + strategy + survivor ids", () => { + const a = survivor("foo", { mechanism: "x", evidence: 0.5 }, 30); + const b = survivor("bar", { mechanism: "y", evidence: 0.4 }, 20); + const result = evolveSurvivors({ + survivors: [a, b], + strategy: "simple-merge", + variantIdPrefix: "test", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.variants[0]!.id).toBe("test-merge-foo-bar"); + }); + + it("EvolutionStrategy union exhaustive switch (compile-time check)", () => { + const acknowledge = (s: EvolutionStrategy): string => { + switch (s) { + case "simple-merge": + case "cross-pollinate": + case "mutate": + return s; + } + }; + expect(acknowledge("simple-merge")).toBe("simple-merge"); + expect(acknowledge("cross-pollinate")).toBe("cross-pollinate"); + expect(acknowledge("mutate")).toBe("mutate"); + }); +}); diff --git a/tools/workflow-engine/evolution.ts b/tools/workflow-engine/evolution.ts new file mode 100644 index 0000000000..1bd7318724 --- /dev/null +++ b/tools/workflow-engine/evolution.ts @@ -0,0 +1,244 @@ +/** + * tools/workflow-engine/evolution.ts + * + * B-0914.5 — pure-TS evolution agent (mash + refine surviving substrate) + * for workflow engine. Pure function over ranked survivors → refined + * variants per the co-scientist evolution agent pattern. + * + * Source: Google co-scientist evolution agent (Nature 2026) — takes + * surviving hypotheses + mashes them together to create refined + * variants. Bridges logical gaps; iteratively refines high-quality + * substrate. + * + * Substrate-engineering composition: + * - Closes the tournament loop with TrueSkill (PR #5764): + * 1. Generate hypotheses (LLM call; out of scope for this file) + * 2. Rank via TrueSkill (B-0914.1 — shipped) + * 3. Take top-N survivors + * 4. Mash + refine (THIS FILE — B-0914.5) + * 5. Loop back to step 2 with refined variants + * + * Per Aaron 2026-05-28 'S M L all please in that order lol' — this is + * the SMALL substrate-engineering work in the sequence; pure function; + * tight scope; composes with TrueSkill substrate. + * + * Composes with: + * - B-0914.5 backlog row (evolution agent extension) + * - B-0914.1 (PR #5764) TrueSkill substrate (ranking input) + * - B-0867 workflow engine substrate (future ActionClass 'evolve-via-mash-refine') + * - .claude/rules/additive-not-zero-sum.md (substrate compounds via composition) + * - .claude/rules/honor-those-that-came-before.md (survivors' substrate preserved) + * - .claude/rules/monad-propagation-pattern (Result) + * - .claude/rules/asymmetric-authorship (TFeedback authored by function) + * + * PoC scope: pure function over typed survivors with 3 composition + * strategies (simple-merge / cross-pollinate / mutate). Attribute-level + * composition rather than semantic-level (semantic composition would + * require LLM call; deferred to integration layer). + */ + +/** + * Survivor — an item that survived TrueSkill ranking + is candidate for + * evolution. Generic over the substrate type T being evolved. + * + * Composes with TrueSkillRating from trueskill.ts; survivor's TrueSkill + * conservative-skill IS the ranking signal. + */ +export interface Survivor { + readonly id: string; // unique identifier + readonly substrate: T; // the actual substrate being evolved + readonly conservativeSkill: number; // TrueSkill ranking score (higher = better) + readonly composesWith: ReadonlyArray; // attribution + composition tracking +} + +/** + * Evolution strategy — how to combine survivors into refined variants. + */ +export type EvolutionStrategy = + | "simple-merge" // take attributes from highest-ranked + fill gaps from next + | "cross-pollinate" // alternate attributes between 2 survivors + | "mutate"; // perturb single highest-ranked survivor + +/** + * Evolution feedback per asymmetric-authorship + monad-propagation rules. + */ +export type EvolutionFeedback = + | { kind: "InsufficientSurvivors"; required: number; provided: number } + | { kind: "EmptySurvivorSet" } + | { kind: "UnsupportedStrategy"; strategy: string } + | { kind: "MergeConflict"; survivorId: string; reason: string }; + +/** + * Result-shape per monad-propagation rule. + */ +export type EvolutionResult = + | { ok: true; variants: ReadonlyArray> } + | { ok: false; feedback: EvolutionFeedback }; + +/** + * Refined variant — output of evolution. Tracks provenance (which + * survivors it was derived from) for substrate-honest attribution + * per honor-those-that-came-before discipline. + */ +export interface RefinedVariant { + readonly id: string; + readonly substrate: T; + readonly derivedFrom: ReadonlyArray; // survivor ids + readonly strategy: EvolutionStrategy; + readonly composesWith: ReadonlyArray; +} + +/** + * Mash + refine survivors into refined variants per the chosen strategy. + * + * Per the co-scientist evolution agent pattern: + * - simple-merge: take top survivor's substrate as base + fill any + * undefined attributes from next-ranked survivor (substrate-honest: + * prefers higher-ranked; preserves lower-ranked attribute-fill) + * - cross-pollinate: alternate attributes between top 2 survivors + * (interleaved attribute selection by sorted-key parity) + * - mutate: take highest-ranked + apply a perturbation transformer + * + * Survivors expected to be pre-sorted by conservativeSkill descending; + * function does NOT re-sort to preserve caller's sort discipline. + * + * The `mergeAttribute` callback handles per-attribute composition for + * simple-merge and cross-pollinate strategies; allows caller-substrate- + * specific merge logic. For mutate, the `mutator` callback transforms + * the top survivor's substrate. + */ +export interface EvolutionContext { + readonly survivors: ReadonlyArray>; + readonly strategy: EvolutionStrategy; + readonly mergeAttribute?: (a: T, b: T, key: string) => unknown; + readonly mutator?: (substrate: T) => T; + readonly variantIdPrefix?: string; // prefix for generated variant ids +} + +export function evolveSurvivors>( + context: EvolutionContext, +): EvolutionResult { + if (context.survivors.length === 0) { + return { ok: false, feedback: { kind: "EmptySurvivorSet" } }; + } + + const prefix = context.variantIdPrefix ?? "evolved"; + + switch (context.strategy) { + case "simple-merge": { + if (context.survivors.length < 2) { + return { + ok: false, + feedback: { kind: "InsufficientSurvivors", required: 2, provided: context.survivors.length }, + }; + } + const top = context.survivors[0]!; + const next = context.survivors[1]!; + const merged: Record = { ...top.substrate }; + // Fill any undefined keys from next-ranked survivor + for (const key of Object.keys(next.substrate)) { + if (merged[key] === undefined) { + merged[key] = (next.substrate as Record)[key]; + } + } + return { + ok: true, + variants: [ + { + id: `${prefix}-merge-${top.id}-${next.id}`, + substrate: merged as T, + derivedFrom: [top.id, next.id], + strategy: "simple-merge", + composesWith: [...top.composesWith, ...next.composesWith, "B-0914.5"], + }, + ], + }; + } + case "cross-pollinate": { + if (context.survivors.length < 2) { + return { + ok: false, + feedback: { kind: "InsufficientSurvivors", required: 2, provided: context.survivors.length }, + }; + } + const top = context.survivors[0]!; + const next = context.survivors[1]!; + const crossed: Record = {}; + // Interleave attributes: even-indexed keys from top, odd-indexed from next + const allKeys = Array.from(new Set([...Object.keys(top.substrate), ...Object.keys(next.substrate)])).sort(); + for (let i = 0; i < allKeys.length; i++) { + const key = allKeys[i]!; + const source = i % 2 === 0 ? top.substrate : next.substrate; + const fallback = i % 2 === 0 ? next.substrate : top.substrate; + const sourceVal = (source as Record)[key]; + crossed[key] = sourceVal !== undefined ? sourceVal : (fallback as Record)[key]; + } + return { + ok: true, + variants: [ + { + id: `${prefix}-cross-${top.id}-${next.id}`, + substrate: crossed as T, + derivedFrom: [top.id, next.id], + strategy: "cross-pollinate", + composesWith: [...top.composesWith, ...next.composesWith, "B-0914.5"], + }, + ], + }; + } + case "mutate": { + const top = context.survivors[0]!; + if (!context.mutator) { + return { + ok: false, + feedback: { + kind: "MergeConflict", + survivorId: top.id, + reason: "mutate strategy requires mutator callback in EvolutionContext", + }, + }; + } + const mutated = context.mutator(top.substrate); + return { + ok: true, + variants: [ + { + id: `${prefix}-mut-${top.id}`, + substrate: mutated, + derivedFrom: [top.id], + strategy: "mutate", + composesWith: [...top.composesWith, "B-0914.5"], + }, + ], + }; + } + } +} + +/** + * Convenience: take top-N TrueSkill-ranked items + evolve them per + * the chosen strategy. + * + * Composes with TrueSkill substrate (B-0914.1; PR #5764): + * caller sorts items by `conservativeSkill(rating)` descending, slices + * top-N, passes to this function. + */ +export function evolveTopN>( + survivors: ReadonlyArray>, + n: number, + strategy: EvolutionStrategy, + options?: { + mergeAttribute?: (a: T, b: T, key: string) => unknown; + mutator?: (substrate: T) => T; + variantIdPrefix?: string; + }, +): EvolutionResult { + const topN = survivors.slice(0, n); + return evolveSurvivors({ + survivors: topN, + strategy, + ...(options?.mergeAttribute && { mergeAttribute: options.mergeAttribute }), + ...(options?.mutator && { mutator: options.mutator }), + ...(options?.variantIdPrefix && { variantIdPrefix: options.variantIdPrefix }), + }); +}