mirror of
https://github.com/openprose/prose.git
synced 2026-09-19 05:55:05 +08:00
8564d8a7d2
* feat(cli): add evaluation runner core
* feat(cli): add eval suite loading and cost mapping
* feat(cli): add public harness eval adapters
* feat(cli): add eval suite registry and runner helper
* fix(cli): harden eval runner safety gates
* feat(cli): harden eval evidence and isolation
* feat(cli): add eval claim eligibility gates
* Harden reactor eval claim eligibility
* Harden eval claim eligibility gates
* feat(eval): add reactor timeline case contract
* feat(eval): add reactor proof verdict library
* feat(eval): add timeline baseline adapters
* feat(eval): add docker isolation substrate
* feat(eval): add phase 1b reactor-independent tier 1
* feat(eval): add phase 1b cost and canary lanes
* feat(eval): add phase 1b statistical pilot
* feat(eval): reconcile openrouter costs and containerize competitors
* feat(evals): run isolated competitor harnesses locally
* feat(evals): add phase 1b report pilot runner
* feat(evals): add phase 1b confidence intervals
* fix(evals): harden provider host matching against URL prefix spoofing
CodeQL flagged "Incomplete URL substring sanitization" in
providerFromUrl: host.endsWith("anthropic.com") also matches an
attacker-controlled host like "evilanthropic.com". Match the exact
host or a proper subdomain (".anthropic.com") instead, applied to all
three provider domains via a hostMatches helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.0 KiB
TypeScript
158 lines
5.0 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
ADAPTIVE_CRON_CACHE_FILE,
|
|
createAdaptiveCronTimelineAdapter,
|
|
} from "../../src/evals/timeline-adapters/adaptive-cron.js";
|
|
import type {
|
|
JsonObject,
|
|
JsonValue,
|
|
ReactorTimelineAdapterEventContext,
|
|
ReactorTimelineEvent,
|
|
SurpriseLabel,
|
|
} from "../../src/evals/index.js";
|
|
import type { BaselineJudgeInput } from "../../src/evals/timeline-adapters/baseline-utils.js";
|
|
|
|
describe("createAdaptiveCronTimelineAdapter", () => {
|
|
test("resets the schedule on relevant changes", async () => {
|
|
const cacheRoot = mkdtempSync(join(tmpdir(), "prose-adaptive-cron-"));
|
|
try {
|
|
const calls: BaselineJudgeInput[] = [];
|
|
const adapter = createAdaptiveCronTimelineAdapter({
|
|
baselineJudge: (input) => {
|
|
calls.push(input);
|
|
return { eventId: input.event.id, ordinal: calls.length };
|
|
},
|
|
});
|
|
const context = adapterContext(cacheRoot, adapter.name);
|
|
|
|
await adapter.onEvent(event("receipt-issued", "2026-05-17T12:00:00.000Z", "relevant-change"), context);
|
|
await adapter.onEvent(event("due-recheck", "2026-05-17T13:00:00.000Z", "silent-drift"), context);
|
|
const reset = await adapter.onEvent(
|
|
event("fresh-receipt", "2026-05-17T13:30:00.000Z", "relevant-change"),
|
|
context,
|
|
);
|
|
|
|
expect(reset.metadata).toEqual(
|
|
expect.objectContaining({
|
|
decision: "compute",
|
|
intervalMs: 60 * 60 * 1000,
|
|
nextDueAt: "2026-05-17T14:30:00.000Z",
|
|
resetInterval: true,
|
|
}),
|
|
);
|
|
expect(calls).toHaveLength(3);
|
|
expect(cacheState(cacheRoot).intervalMs).toBe(60 * 60 * 1000);
|
|
expect(cacheState(cacheRoot).nextDueAt).toBe("2026-05-17T14:30:00.000Z");
|
|
} finally {
|
|
rmSync(cacheRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("skips noop events before the next due time", async () => {
|
|
const cacheRoot = mkdtempSync(join(tmpdir(), "prose-adaptive-cron-"));
|
|
try {
|
|
const calls: BaselineJudgeInput[] = [];
|
|
const adapter = createAdaptiveCronTimelineAdapter({
|
|
baselineJudge: (input) => {
|
|
calls.push(input);
|
|
return { eventId: input.event.id };
|
|
},
|
|
});
|
|
const context = adapterContext(cacheRoot, adapter.name);
|
|
|
|
await adapter.onEvent(event("receipt-issued", "2026-05-17T12:00:00.000Z", "relevant-change"), context);
|
|
const skipped = await adapter.onEvent(event("quiet-tick", "2026-05-17T12:30:00.000Z", "noop"), context);
|
|
|
|
expect(skipped.metadata).toEqual(
|
|
expect.objectContaining({
|
|
decision: "skip",
|
|
intervalMs: 60 * 60 * 1000,
|
|
nextDueAt: "2026-05-17T13:00:00.000Z",
|
|
}),
|
|
);
|
|
expect(skipped.metrics).toEqual({ cacheHit: 0, modelCalls: 0 });
|
|
expect(calls).toHaveLength(1);
|
|
expect(cacheState(cacheRoot).nextDueAt).toBe("2026-05-17T13:00:00.000Z");
|
|
} finally {
|
|
rmSync(cacheRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("rechecks and backs off at the next due time", async () => {
|
|
const cacheRoot = mkdtempSync(join(tmpdir(), "prose-adaptive-cron-"));
|
|
try {
|
|
const calls: BaselineJudgeInput[] = [];
|
|
const adapter = createAdaptiveCronTimelineAdapter({
|
|
baselineJudge: (input) => {
|
|
calls.push(input);
|
|
return input.priorVerdict === undefined
|
|
? { eventId: input.event.id }
|
|
: { eventId: input.event.id, prior: input.priorVerdict };
|
|
},
|
|
});
|
|
const context = adapterContext(cacheRoot, adapter.name);
|
|
|
|
await adapter.onEvent(event("receipt-issued", "2026-05-17T12:00:00.000Z", "relevant-change"), context);
|
|
const recheck = await adapter.onEvent(
|
|
event("due-recheck", "2026-05-17T13:00:00.000Z", "silent-drift"),
|
|
context,
|
|
);
|
|
|
|
expect(recheck.metadata).toEqual(
|
|
expect.objectContaining({
|
|
decision: "recheck",
|
|
intervalMs: 2 * 60 * 60 * 1000,
|
|
nextDueAt: "2026-05-17T15:00:00.000Z",
|
|
}),
|
|
);
|
|
expect(recheck.metrics).toEqual({ cacheHit: 0, modelCalls: 1 });
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]?.priorVerdict).toEqual({ eventId: "receipt-issued" });
|
|
expect(cacheState(cacheRoot).intervalMs).toBe(2 * 60 * 60 * 1000);
|
|
expect(cacheState(cacheRoot).lastVerdict).toEqual({
|
|
eventId: "due-recheck",
|
|
prior: { eventId: "receipt-issued" },
|
|
});
|
|
} finally {
|
|
rmSync(cacheRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
function event(id: string, at: string, label: SurpriseLabel): ReactorTimelineEvent {
|
|
return {
|
|
id,
|
|
at,
|
|
label,
|
|
trigger: "input",
|
|
type: `fixture.${id}`,
|
|
};
|
|
}
|
|
|
|
function adapterContext(scenarioCacheDirectory: string, adapterName: string): ReactorTimelineAdapterEventContext {
|
|
return {
|
|
adapterName,
|
|
attemptId: "attempt-1",
|
|
caseId: "adaptive-cron-case",
|
|
eventIndex: 0,
|
|
runId: "run-1",
|
|
scenarioCacheDirectory,
|
|
startedAt: "2026-05-17T12:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function cacheState(cacheRoot: string): AdaptiveCronTestState {
|
|
return JSON.parse(readFileSync(join(cacheRoot, ADAPTIVE_CRON_CACHE_FILE), "utf8")).state as AdaptiveCronTestState;
|
|
}
|
|
|
|
interface AdaptiveCronTestState extends JsonObject {
|
|
intervalMs: number;
|
|
lastVerdict: JsonValue;
|
|
nextDueAt: string;
|
|
}
|