feat(FN-4222): complete Step 1 — finalize plan model
Fusion-Task-Id: FN-4222 Fusion-Task-Lineage: 7de6b18c-36d5-49e8-bbd7-084707e88d6a
This commit is contained in:
163
packages/engine/src/__tests__/experiment-finalize-plan.test.ts
Normal file
163
packages/engine/src/__tests__/experiment-finalize-plan.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
|
||||
import { buildDefaultPlan, mergePlanWithUserOverrides } from "../experiment/finalize-plan.js";
|
||||
import { ExperimentFinalizePlanError } from "../experiment/finalize-types.js";
|
||||
|
||||
function createSession(overrides: Partial<ExperimentSession> = {}): ExperimentSession {
|
||||
return {
|
||||
id: "EXP-1",
|
||||
name: "Experiment",
|
||||
status: "active",
|
||||
metric: { name: "score", direction: "maximize" },
|
||||
currentSegment: 2,
|
||||
keptRunIds: [],
|
||||
tags: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runRecord(id: string, segment: number, seq: number, status: "keep" | "discard" = "keep", commit?: string, asi?: Record<string, unknown>): ExperimentSessionRecord {
|
||||
return {
|
||||
id,
|
||||
sessionId: "EXP-1",
|
||||
segment,
|
||||
seq,
|
||||
type: "run",
|
||||
payload: {
|
||||
status,
|
||||
commit,
|
||||
primaryMetric: 1,
|
||||
secondaryMetrics: [],
|
||||
asi,
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("experiment finalize plan", () => {
|
||||
it("groups by segment with unique slug branch names", () => {
|
||||
const records = [
|
||||
runRecord("r1", 1, 1, "keep", "c1"),
|
||||
runRecord("r2", 1, 2, "keep", "c2"),
|
||||
runRecord("r3", 1, 3, "keep", "c3"),
|
||||
runRecord("r4", 2, 4, "keep", "c4"),
|
||||
runRecord("r5", 2, 5, "keep", "c5"),
|
||||
runRecord("r6", 2, 6, "keep", "c6"),
|
||||
];
|
||||
const session = createSession({ keptRunIds: records.map((r) => r.id) });
|
||||
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
expect(plan.groups).toHaveLength(2);
|
||||
expect(plan.groups[0].runRecordIds).toEqual(["r1", "r2", "r3"]);
|
||||
expect(plan.groups[1].runRecordIds).toEqual(["r4", "r5", "r6"]);
|
||||
expect(plan.groups[0].suggestedBranchName).toContain("segment-1");
|
||||
expect(plan.groups[1].suggestedBranchName).toContain("segment-2");
|
||||
expect(plan.groups[0].suggestedBranchName).not.toBe(plan.groups[1].suggestedBranchName);
|
||||
});
|
||||
|
||||
it("clusters by asi.group regardless of segment", () => {
|
||||
const records = [
|
||||
runRecord("r1", 1, 1, "keep", "c1", { group: "Latency" }),
|
||||
runRecord("r2", 2, 2, "keep", "c2", { group: "Latency" }),
|
||||
runRecord("r3", 1, 3, "keep", "c3"),
|
||||
];
|
||||
const session = createSession({ keptRunIds: records.map((r) => r.id) });
|
||||
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
expect(plan.groups).toHaveLength(2);
|
||||
expect(plan.groups[0].title).toBe("Latency");
|
||||
expect(plan.groups[0].runRecordIds).toEqual(["r1", "r2"]);
|
||||
});
|
||||
|
||||
it("captures orphaned kept runs missing commit with warning", () => {
|
||||
const records = [runRecord("r1", 1, 1, "keep", undefined), runRecord("r2", 1, 2, "keep", "c2")];
|
||||
const session = createSession({ keptRunIds: ["r1", "r2"] });
|
||||
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
expect(plan.orphanedRunRecordIds).toEqual(["r1"]);
|
||||
expect(plan.warnings.some((warning) => warning.includes("r1"))).toBe(true);
|
||||
expect(plan.groups).toHaveLength(1);
|
||||
expect(plan.groups[0].runRecordIds).toEqual(["r2"]);
|
||||
});
|
||||
|
||||
it("throws when override causes branch-name collision", () => {
|
||||
const records = [
|
||||
runRecord("r1", 1, 1, "keep", "c1"),
|
||||
runRecord("r2", 2, 2, "keep", "c2"),
|
||||
];
|
||||
const session = createSession({ id: "EXP-COLLIDE", keptRunIds: ["r1", "r2"] });
|
||||
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
expect(() =>
|
||||
mergePlanWithUserOverrides(plan, {
|
||||
groups: plan.groups.map((group) => ({
|
||||
id: group.id,
|
||||
runRecordIds: group.runRecordIds,
|
||||
suggestedBranchName: "same-branch",
|
||||
})),
|
||||
}),
|
||||
).toThrow(ExperimentFinalizePlanError);
|
||||
});
|
||||
|
||||
it("supports overrides moving records and preserving order", () => {
|
||||
const records = [
|
||||
runRecord("r1", 1, 1, "keep", "c1"),
|
||||
runRecord("r2", 1, 2, "keep", "c2"),
|
||||
runRecord("r3", 2, 3, "keep", "c3"),
|
||||
];
|
||||
const session = createSession({ keptRunIds: ["r1", "r2", "r3"] });
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
const merged = mergePlanWithUserOverrides(plan, {
|
||||
groups: [
|
||||
{
|
||||
id: plan.groups[0].id,
|
||||
runRecordIds: ["r2", "r1", "r3"],
|
||||
suggestedBranchName: "experiment/exp-1/custom-a",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(merged.groups).toHaveLength(1);
|
||||
expect(merged.groups[0].runRecordIds).toEqual(["r2", "r1", "r3"]);
|
||||
expect(merged.groups[0].commits).toEqual(["c2", "c1", "c3"]);
|
||||
});
|
||||
|
||||
it("throws when override leaves group empty", () => {
|
||||
const records = [runRecord("r1", 1, 1, "keep", "c1")];
|
||||
const session = createSession({ keptRunIds: ["r1"] });
|
||||
const plan = buildDefaultPlan({ session, records, integrationBranch: "main", mergeBaseCommit: "base" });
|
||||
|
||||
expect(() => mergePlanWithUserOverrides(plan, { groups: [{ id: plan.groups[0].id, runRecordIds: [] }] })).toThrow(
|
||||
ExperimentFinalizePlanError,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back baseline commit to baseline run then merge-base", () => {
|
||||
const baseline = runRecord("base", 1, 1, "keep", "baseline-commit");
|
||||
const kept = runRecord("r1", 1, 2, "keep", "c1");
|
||||
|
||||
const withBaselineRun = buildDefaultPlan({
|
||||
session: createSession({ keptRunIds: ["r1"], baselineRunId: "base" }),
|
||||
records: [baseline, kept],
|
||||
integrationBranch: "main",
|
||||
mergeBaseCommit: "merge-base",
|
||||
});
|
||||
expect(withBaselineRun.baselineCommit).toBe("baseline-commit");
|
||||
|
||||
const mergeBaseFallback = buildDefaultPlan({
|
||||
session: createSession({ keptRunIds: ["r1"] }),
|
||||
records: [kept],
|
||||
integrationBranch: "main",
|
||||
mergeBaseCommit: "merge-base",
|
||||
});
|
||||
expect(mergeBaseFallback.baselineCommit).toBe("merge-base");
|
||||
expect(mergeBaseFallback.warnings).toContain("no baseline commit; using merge-base as degenerate baseline");
|
||||
});
|
||||
});
|
||||
159
packages/engine/src/experiment/finalize-plan.ts
Normal file
159
packages/engine/src/experiment/finalize-plan.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
|
||||
import {
|
||||
ExperimentFinalizePlanError,
|
||||
type FinalizeGroup,
|
||||
type FinalizePlan,
|
||||
type FinalizePlanOverride,
|
||||
getRunRecordById,
|
||||
} from "./finalize-types.js";
|
||||
|
||||
function slugifyGroupTitle(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return slug || "group";
|
||||
}
|
||||
|
||||
function normalizeGroupLabel(record: Extract<ExperimentSessionRecord, { type: "run" }>): { groupKey: string; title: string } {
|
||||
const asi = record.payload.asi as Record<string, unknown> | undefined;
|
||||
const group = typeof asi?.group === "string" && asi.group.trim() ? asi.group.trim() : null;
|
||||
if (group) {
|
||||
return { groupKey: `asi:${group}`, title: group };
|
||||
}
|
||||
return { groupKey: `segment:${record.segment}`, title: `Segment ${record.segment}` };
|
||||
}
|
||||
|
||||
function resolveBaselineCommit(session: ExperimentSession, records: ExperimentSessionRecord[], mergeBaseCommit: string, warnings: string[]): string {
|
||||
const metadataBaseline = session.metadata?.baselineCommit;
|
||||
if (typeof metadataBaseline === "string" && metadataBaseline.trim()) {
|
||||
return metadataBaseline.trim();
|
||||
}
|
||||
if (session.baselineRunId) {
|
||||
const baselineRun = getRunRecordById(records, session.baselineRunId);
|
||||
if (baselineRun?.payload.commit) {
|
||||
return baselineRun.payload.commit;
|
||||
}
|
||||
}
|
||||
warnings.push("no baseline commit; using merge-base as degenerate baseline");
|
||||
return mergeBaseCommit;
|
||||
}
|
||||
|
||||
export function buildDefaultPlan(opts: {
|
||||
session: ExperimentSession;
|
||||
records: ExperimentSessionRecord[];
|
||||
integrationBranch: string;
|
||||
mergeBaseCommit: string;
|
||||
}): FinalizePlan {
|
||||
const warnings: string[] = [];
|
||||
const orphanedRunRecordIds: string[] = [];
|
||||
|
||||
const keptRuns = opts.records
|
||||
.filter((record): record is Extract<ExperimentSessionRecord, { type: "run" }> => record.type === "run" && opts.session.keptRunIds.includes(record.id))
|
||||
.filter((record) => {
|
||||
if (record.payload.status !== "keep") return false;
|
||||
if (!record.payload.commit) {
|
||||
orphanedRunRecordIds.push(record.id);
|
||||
warnings.push(`kept run ${record.id} has no commit and was skipped`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const grouped = new Map<string, { title: string; runs: Extract<ExperimentSessionRecord, { type: "run" }>[] }>();
|
||||
for (const run of keptRuns) {
|
||||
const { groupKey, title } = normalizeGroupLabel(run);
|
||||
const existing = grouped.get(groupKey);
|
||||
if (existing) {
|
||||
existing.runs.push(run);
|
||||
continue;
|
||||
}
|
||||
grouped.set(groupKey, { title, runs: [run] });
|
||||
}
|
||||
|
||||
const groups: FinalizeGroup[] = [];
|
||||
const seenBranchNames = new Set<string>();
|
||||
|
||||
let groupIndex = 1;
|
||||
for (const [groupKey, group] of grouped.entries()) {
|
||||
const runs = group.runs.sort((a, b) => a.seq - b.seq);
|
||||
const slug = slugifyGroupTitle(group.title);
|
||||
let candidate = `experiment/${opts.session.id.toLowerCase()}/${slug}-${groupIndex}`;
|
||||
let bump = 2;
|
||||
while (seenBranchNames.has(candidate)) {
|
||||
candidate = `experiment/${opts.session.id.toLowerCase()}/${slug}-${groupIndex}-${bump}`;
|
||||
bump += 1;
|
||||
}
|
||||
seenBranchNames.add(candidate);
|
||||
|
||||
groups.push({
|
||||
id: groupKey,
|
||||
title: group.title,
|
||||
runRecordIds: runs.map((run) => run.id),
|
||||
commits: runs.map((run) => run.payload.commit!).filter(Boolean),
|
||||
suggestedBranchName: candidate,
|
||||
});
|
||||
groupIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: opts.session.id,
|
||||
baselineCommit: resolveBaselineCommit(opts.session, opts.records, opts.mergeBaseCommit, warnings),
|
||||
integrationBranch: opts.integrationBranch,
|
||||
mergeBaseCommit: opts.mergeBaseCommit,
|
||||
groups,
|
||||
orphanedRunRecordIds,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergePlanWithUserOverrides(defaultPlan: FinalizePlan, override?: FinalizePlanOverride): FinalizePlan {
|
||||
if (!override) return defaultPlan;
|
||||
|
||||
const runToCommit = new Map<string, string>();
|
||||
const defaultGroupById = new Map(defaultPlan.groups.map((group) => [group.id, group]));
|
||||
for (const group of defaultPlan.groups) {
|
||||
for (let i = 0; i < group.runRecordIds.length; i += 1) {
|
||||
runToCommit.set(group.runRecordIds[i], group.commits[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: FinalizeGroup[] = override.groups.map((group, idx) => {
|
||||
if (!group.runRecordIds.length) {
|
||||
throw new ExperimentFinalizePlanError(`Group ${group.id ?? idx + 1} has no run records`);
|
||||
}
|
||||
|
||||
const source = group.id ? defaultGroupById.get(group.id) : undefined;
|
||||
const commits = group.runRecordIds.map((runRecordId) => {
|
||||
const commit = runToCommit.get(runRecordId);
|
||||
if (!commit) {
|
||||
throw new ExperimentFinalizePlanError(`Unknown or missing commit for run record ${runRecordId}`);
|
||||
}
|
||||
return commit;
|
||||
});
|
||||
|
||||
if (!commits.length) {
|
||||
throw new ExperimentFinalizePlanError(`Group ${group.id ?? idx + 1} has zero commits`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id ?? `custom:${idx + 1}`,
|
||||
title: group.title ?? source?.title ?? `Group ${idx + 1}`,
|
||||
description: group.description ?? source?.description,
|
||||
suggestedBranchName: group.suggestedBranchName ?? source?.suggestedBranchName ?? `experiment/${defaultPlan.sessionId.toLowerCase()}/group-${idx + 1}`,
|
||||
runRecordIds: [...group.runRecordIds],
|
||||
commits,
|
||||
};
|
||||
});
|
||||
|
||||
const duplicateBranch = groups.find((group, index) => groups.findIndex((g) => g.suggestedBranchName === group.suggestedBranchName) !== index);
|
||||
if (duplicateBranch) {
|
||||
throw new ExperimentFinalizePlanError(`Duplicate suggested branch name: ${duplicateBranch.suggestedBranchName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultPlan,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
85
packages/engine/src/experiment/finalize-types.ts
Normal file
85
packages/engine/src/experiment/finalize-types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { ExperimentSessionRecord } from "@fusion/core";
|
||||
|
||||
export interface FinalizeGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
runRecordIds: string[];
|
||||
commits: string[];
|
||||
suggestedBranchName: string;
|
||||
}
|
||||
|
||||
export interface FinalizePlan {
|
||||
sessionId: string;
|
||||
baselineCommit: string;
|
||||
integrationBranch: string;
|
||||
mergeBaseCommit: string;
|
||||
groups: FinalizeGroup[];
|
||||
orphanedRunRecordIds: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface FinalizeResult {
|
||||
sessionId: string;
|
||||
mergeBaseCommit: string;
|
||||
branches: Array<{ name: string; baseCommit: string; tipCommit: string; runRecordIds: string[]; commits: string[] }>;
|
||||
warnings: string[];
|
||||
finalizeRecordId: string;
|
||||
}
|
||||
|
||||
export interface FinalizePlanOverrideGroup {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
suggestedBranchName?: string;
|
||||
runRecordIds: string[];
|
||||
}
|
||||
|
||||
export interface FinalizePlanOverride {
|
||||
groups: FinalizePlanOverrideGroup[];
|
||||
}
|
||||
|
||||
class ExperimentFinalizeErrorBase extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class ExperimentFinalizeStateError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "state_error" as const;
|
||||
}
|
||||
|
||||
export class ExperimentFinalizeNoKeptRunsError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "no_kept_runs" as const;
|
||||
}
|
||||
|
||||
export class ExperimentFinalizePlanError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "plan_error" as const;
|
||||
}
|
||||
|
||||
export class ExperimentFinalizeMergeBaseError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "merge_base_error" as const;
|
||||
}
|
||||
|
||||
export class ExperimentFinalizeCherryPickConflictError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "cherry_pick_conflict" as const;
|
||||
readonly groupId: string;
|
||||
readonly commit: string;
|
||||
readonly stderr: string;
|
||||
|
||||
constructor(message: string, details: { groupId: string; commit: string; stderr: string }) {
|
||||
super(message);
|
||||
this.groupId = details.groupId;
|
||||
this.commit = details.commit;
|
||||
this.stderr = details.stderr;
|
||||
}
|
||||
}
|
||||
|
||||
export class ExperimentFinalizeBranchExistsError extends ExperimentFinalizeErrorBase {
|
||||
readonly code = "branch_exists" as const;
|
||||
}
|
||||
|
||||
export function getRunRecordById(records: ExperimentSessionRecord[], id: string): Extract<ExperimentSessionRecord, { type: "run" }> | undefined {
|
||||
return records.find((record): record is Extract<ExperimentSessionRecord, { type: "run" }> => record.id === id && record.type === "run");
|
||||
}
|
||||
Reference in New Issue
Block a user