248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
runPairedProcessExperiment,
|
|
type ProcessCapturePort,
|
|
} from "../src/application/ProcessPairedExperiment.js";
|
|
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js";
|
|
import { parseProcessCapture } from "../src/domain/processCapture.js";
|
|
import {
|
|
analyzeProcessRepeatability,
|
|
bindProcessScenario,
|
|
processPairedExperimentSchema,
|
|
} from "../src/domain/processPairedExperiment.js";
|
|
import { ok } from "../src/domain/result.js";
|
|
|
|
const experiment = () =>
|
|
processPairedExperimentSchema.parse({
|
|
approved: true,
|
|
shared_scenario: {
|
|
approved: true,
|
|
executable: "/bin/template",
|
|
arguments: ["shared"],
|
|
working_directory: "/tmp",
|
|
environment: { SHARED: "yes" },
|
|
},
|
|
authority: {
|
|
id: "authority",
|
|
executable: "/bin/authority",
|
|
environment: { SIDE: "authority" },
|
|
},
|
|
candidate: {
|
|
id: "candidate",
|
|
executable: "/bin/candidate",
|
|
arguments: ["candidate"],
|
|
environment: { SIDE: "candidate" },
|
|
},
|
|
repeat_count: 3,
|
|
freshness_policy: { max_capture_age_ms: 60_000 },
|
|
required_dimensions: [
|
|
"terminal",
|
|
"interaction",
|
|
"exit",
|
|
"filesystem",
|
|
"protocol",
|
|
"process",
|
|
"shim",
|
|
],
|
|
});
|
|
|
|
const policy = {
|
|
enabled: true,
|
|
executableRoots: ["/bin"],
|
|
workingRoots: ["/tmp"],
|
|
allowedEnvironment: ["SHARED", "SIDE"],
|
|
allowExternalNetwork: false,
|
|
};
|
|
|
|
describe("paired process experiments", () => {
|
|
it("binds only declared side inputs onto the shared observation contract", () => {
|
|
const parsed = experiment();
|
|
const authority = bindProcessScenario(
|
|
parsed.shared_scenario,
|
|
parsed.authority,
|
|
);
|
|
const candidate = bindProcessScenario(
|
|
parsed.shared_scenario,
|
|
parsed.candidate,
|
|
);
|
|
expect(authority).toMatchObject({
|
|
executable: "/bin/authority",
|
|
arguments: ["shared"],
|
|
environment: { SHARED: "yes", SIDE: "authority" },
|
|
});
|
|
expect(candidate).toMatchObject({
|
|
executable: "/bin/candidate",
|
|
arguments: ["candidate"],
|
|
environment: { SHARED: "yes", SIDE: "candidate" },
|
|
});
|
|
});
|
|
|
|
it("runs same-side comparisons first and compares stable sides", async () => {
|
|
const observedExecutables: string[] = [];
|
|
const capture = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
|
const capturePort: ProcessCapturePort = {
|
|
capture: (scenario) => {
|
|
observedExecutables.push(scenario.executable);
|
|
return Promise.resolve(ok(structuredClone(capture)));
|
|
},
|
|
};
|
|
const result = await runPairedProcessExperiment(experiment(), policy, {
|
|
capturePort,
|
|
now: () => Date.parse("2026-01-01T00:00:01.000Z"),
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(observedExecutables).toEqual([
|
|
"/bin/authority",
|
|
"/bin/authority",
|
|
"/bin/authority",
|
|
"/bin/candidate",
|
|
"/bin/candidate",
|
|
"/bin/candidate",
|
|
]);
|
|
expect(result.value.authority_repeatability.stable).toBe(true);
|
|
expect(result.value.candidate_repeatability.stable).toBe(true);
|
|
expect(result.value.cross_side?.status).toBe("unchanged");
|
|
});
|
|
|
|
it("blocks cross-side claims when a candidate has a flaky exit", async () => {
|
|
const base = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
|
let candidateRun = 0;
|
|
const capturePort: ProcessCapturePort = {
|
|
capture: (scenario) => {
|
|
let capture = structuredClone(base);
|
|
if (scenario.executable === "/bin/candidate") {
|
|
candidateRun += 1;
|
|
if (candidateRun === 2)
|
|
capture = {
|
|
...capture,
|
|
exit: { code: 1, signal: null, reason: "exited" },
|
|
};
|
|
}
|
|
return Promise.resolve(ok(capture));
|
|
},
|
|
};
|
|
const result = await runPairedProcessExperiment(experiment(), policy, {
|
|
capturePort,
|
|
now: () => Date.parse("2026-01-01T00:00:01.000Z"),
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.candidate_repeatability).toMatchObject({
|
|
stable: false,
|
|
classification: "flaky_exit",
|
|
unstable_dimensions: ["exit"],
|
|
});
|
|
expect(result.value.cross_side).toBeNull();
|
|
expect(result.value.cross_side_blocked_reason).toMatch(/unstable/u);
|
|
});
|
|
|
|
it("distinguishes normalized timing variance from semantic output drift", () => {
|
|
const base = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
|
const early = {
|
|
...base,
|
|
frames: [{ sequence: 0, at_ms: 10, data: "ready" }],
|
|
};
|
|
const late = {
|
|
...base,
|
|
frames: [{ sequence: 0, at_ms: 20, data: "ready" }],
|
|
};
|
|
expect(
|
|
analyzeProcessRepeatability([early, late], ["terminal"]),
|
|
).toMatchObject({
|
|
stable: false,
|
|
classification: "timing_variance",
|
|
unstable_dimensions: ["terminal"],
|
|
});
|
|
const changed = {
|
|
...base,
|
|
frames: [{ sequence: 0, at_ms: 20, data: "failed" }],
|
|
};
|
|
expect(
|
|
analyzeProcessRepeatability([early, changed], ["terminal"]),
|
|
).toMatchObject({ classification: "nondeterministic_output" });
|
|
});
|
|
|
|
it("stops before capture when the experiment is cancelled", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const capturePort: ProcessCapturePort = {
|
|
capture: () => {
|
|
throw new Error("capture must not start after cancellation");
|
|
},
|
|
};
|
|
|
|
const result = await runPairedProcessExperiment(experiment(), policy, {
|
|
capturePort,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ProcessCaptureError", reason: "cancelled" },
|
|
});
|
|
});
|
|
|
|
it("blocks full cross-side output when only a subset was repeatable", async () => {
|
|
const capture = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
|
const capturePort: ProcessCapturePort = {
|
|
capture: () => Promise.resolve(ok(structuredClone(capture))),
|
|
};
|
|
const subset = processPairedExperimentSchema.parse({
|
|
...experiment(),
|
|
required_dimensions: ["exit"],
|
|
});
|
|
|
|
const result = await runPairedProcessExperiment(subset, policy, {
|
|
capturePort,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
cross_side: null,
|
|
cross_side_blocked_reason: expect.stringMatching(/every returned/u),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("compares the newest stable captures under the freshness policy", async () => {
|
|
const base = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
|
let captureIndex = 0;
|
|
const capturePort: ProcessCapturePort = {
|
|
capture: () => {
|
|
const completedAt =
|
|
captureIndex === 0
|
|
? "2026-01-01T00:00:00.000Z"
|
|
: "2026-01-01T00:00:09.500Z";
|
|
captureIndex += 1;
|
|
return Promise.resolve(
|
|
ok({
|
|
...structuredClone(base),
|
|
manifest: {
|
|
...base.manifest,
|
|
started_at: completedAt,
|
|
completed_at: completedAt,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
const shortFreshness = processPairedExperimentSchema.parse({
|
|
...experiment(),
|
|
freshness_policy: { max_capture_age_ms: 1_000 },
|
|
});
|
|
|
|
const result = await runPairedProcessExperiment(shortFreshness, policy, {
|
|
capturePort,
|
|
now: () => Date.parse("2026-01-01T00:00:10.000Z"),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: { cross_side: { status: "unchanged" } },
|
|
});
|
|
});
|
|
});
|