Generalize Persona replay fixtures

This commit is contained in:
Meta Vens 2026-06-06 00:18:46 +01:00
commit 3785318772
13 changed files with 387 additions and 5 deletions

View file

@ -37,6 +37,7 @@ interface CliOptions {
before: number;
after: number;
heldoutMinutes: number;
contextMaxAgeMinutes: number;
semanticMemory: boolean;
semanticMemoryLimit: number;
outRoot: string;
@ -110,7 +111,11 @@ async function main(): Promise<void> {
},
}, pruneResult.state);
const inputMessages = messages.filter((message) => message.timestamp <= target.timestamp);
const inputMessages = filterInputMessagesByMaxAge(
messages.filter((message) => message.timestamp <= target.timestamp),
target.timestamp,
options.contextMaxAgeMinutes,
);
const heldOutMessages = messages.filter((message) =>
message.timestamp > target.timestamp &&
isWithinHeldoutWindow(message.timestamp, target.timestamp, options.heldoutMinutes)
@ -261,6 +266,7 @@ function parseArgs(args: string[]): CliOptions {
before: 18,
after: 18,
heldoutMinutes: 30,
contextMaxAgeMinutes: 0,
semanticMemory: true,
semanticMemoryLimit: 6,
outRoot: ".voidbot/artifacts/persona-replay",
@ -296,6 +302,9 @@ function parseArgs(args: string[]): CliOptions {
} else if (arg === "--heldout-minutes" && next) {
options.heldoutMinutes = parsePositiveInt(next, "--heldout-minutes");
index += 1;
} else if (arg === "--context-max-age-minutes" && next) {
options.contextMaxAgeMinutes = parsePositiveInt(next, "--context-max-age-minutes");
index += 1;
} else if (arg === "--semantic-memory-limit" && next) {
options.semanticMemoryLimit = parsePositiveInt(next, "--semantic-memory-limit");
index += 1;
@ -329,6 +338,7 @@ function parseArgs(args: string[]): CliOptions {
before: options.before ?? 18,
after: options.after ?? 18,
heldoutMinutes: options.heldoutMinutes ?? 30,
contextMaxAgeMinutes: options.contextMaxAgeMinutes ?? 0,
semanticMemory: options.semanticMemory ?? true,
semanticMemoryLimit: options.semanticMemoryLimit ?? 6,
outRoot: resolve(options.outRoot ?? ".voidbot/artifacts/persona-replay"),
@ -350,6 +360,7 @@ function printHelpAndExit(): never {
" --before <n> Context messages before target. Defaults to 18",
" --after <n> Held-out messages after target. Defaults to 18",
" --heldout-minutes <n> Limit held-out truth to n minutes after target. Defaults to 30; 0 disables the time limit",
" --context-max-age-minutes <n> Drop input messages older than n minutes before target. Defaults to 0, disabled",
" --semantic-memory-limit <n> Max replay memory tool results. Defaults to 6",
" --no-semantic-memory Disable replay-scoped Qdrant/MCP memory access",
" --out <dir> Artifact root. Defaults to .voidbot/artifacts/persona-replay",
@ -374,6 +385,18 @@ function isWithinHeldoutWindow(timestamp: string, targetTimestamp: string, minut
return Date.parse(timestamp) <= Date.parse(targetTimestamp) + minutes * 60_000;
}
function filterInputMessagesByMaxAge(
messages: ArchivedMessageRecord[],
targetTimestamp: string,
maxAgeMinutes: number,
): ArchivedMessageRecord[] {
if (maxAgeMinutes === 0) {
return messages;
}
const cutoff = Date.parse(targetTimestamp) - maxAgeMinutes * 60_000;
return messages.filter((message) => Date.parse(message.timestamp) >= cutoff);
}
async function prepareReplaySemanticMemory(input: {
config: ReturnType<typeof loadConfig>;
state: VoidSelfStateTypedProjection;
@ -493,8 +516,10 @@ function renderReplayStatePacketForProjector(input: {
`Affect: ${response.affectiveSignature}`,
`Constraint loss: ${response.constraintLoss}`,
`Behavioral leak: ${response.behavioralLeak}`,
response.tangentAttractors.length > 0 ? `Tangent attractors: ${response.tangentAttractors.join(" | ")}` : "",
response.cadence ? `Cadence: ${response.cadence}` : "",
`Recovery: ${response.recoveryPath}`,
].join(" "),
].filter(Boolean).join(" "),
),
"",
"Visible thread pressure:",

View file

@ -0,0 +1,283 @@
import "dotenv/config";
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
interface ReplayFixtureSuite {
personaId?: string;
publicName?: string;
fixtures: ReplayFixture[];
}
interface ReplayFixture {
id: string;
targetMessageId: string;
before?: number;
after?: number;
heldoutMinutes?: number;
contextMaxAgeMinutes?: number;
pressure?: string;
focus?: string;
}
interface CliOptions {
fixtureFile: string;
fixtureId?: string;
outRoot: string;
runCodex: boolean;
evaluateCodex: boolean;
limit?: number;
}
interface ReplayRunSummary {
fixtureId: string;
targetMessageId: string;
pressure?: string;
focus?: string;
artifactDir?: string;
reportPath?: string;
exitCode: number | null;
timedOut: boolean;
counts?: Record<string, unknown>;
deterministicComparison?: Record<string, unknown>;
modelEvaluation?: unknown;
predictedReply?: unknown;
error?: string;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const suite = JSON.parse(await readFile(options.fixtureFile, "utf8")) as ReplayFixtureSuite;
const selectedFixtures = suite.fixtures
.filter((fixture) => !options.fixtureId || fixture.id === options.fixtureId)
.slice(0, options.limit ?? Number.POSITIVE_INFINITY);
if (selectedFixtures.length === 0) {
throw new Error(options.fixtureId
? `No fixture with id ${options.fixtureId} in ${options.fixtureFile}.`
: `No fixtures found in ${options.fixtureFile}.`);
}
await mkdir(options.outRoot, { recursive: true });
const summaries: ReplayRunSummary[] = [];
for (const fixture of selectedFixtures) {
const summary = await runFixture({ fixture, suite, options });
summaries.push(summary);
console.log(JSON.stringify(summary, null, 2));
}
const aggregate = {
generatedAt: new Date().toISOString(),
fixtureFile: options.fixtureFile,
outRoot: options.outRoot,
runCodex: options.runCodex,
evaluateCodex: options.evaluateCodex,
fixtures: summaries,
};
const aggregatePath = join(options.outRoot, `metacrat-replay-suite-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
await writeFile(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ aggregatePath }, null, 2));
}
async function runFixture(input: {
fixture: ReplayFixture;
suite: ReplayFixtureSuite;
options: CliOptions;
}): Promise<ReplayRunSummary> {
const args = [
resolve("node_modules", "tsx", "dist", "cli.mjs"),
resolve("scripts", "replay-metacrat-thread.ts"),
"--target-message-id",
input.fixture.targetMessageId,
"--persona-id",
input.suite.personaId ?? "metacrat",
"--public-name",
input.suite.publicName ?? "Metacrat",
"--out",
input.options.outRoot,
];
if (typeof input.fixture.before === "number") {
args.push("--before", String(input.fixture.before));
}
if (typeof input.fixture.after === "number") {
args.push("--after", String(input.fixture.after));
}
if (typeof input.fixture.heldoutMinutes === "number") {
args.push("--heldout-minutes", String(input.fixture.heldoutMinutes));
}
if (typeof input.fixture.contextMaxAgeMinutes === "number") {
args.push("--context-max-age-minutes", String(input.fixture.contextMaxAgeMinutes));
}
if (input.options.runCodex) {
args.push("--run-codex");
}
if (input.options.evaluateCodex) {
args.push("--evaluate-codex");
}
const result = await spawnForText(process.execPath, args, 900_000);
const parsed = parseLastJsonObject(result.stdout);
const reportPath = parsed?.reportPath;
const report = typeof reportPath === "string"
? JSON.parse(await readFile(reportPath, "utf8")) as Record<string, unknown>
: undefined;
return {
fixtureId: input.fixture.id,
targetMessageId: input.fixture.targetMessageId,
pressure: input.fixture.pressure,
focus: input.fixture.focus,
artifactDir: typeof parsed?.artifactDir === "string" ? parsed.artifactDir : undefined,
reportPath: typeof reportPath === "string" ? reportPath : undefined,
exitCode: result.exitCode,
timedOut: result.timedOut,
counts: readRecord(report?.counts),
deterministicComparison: readRecord(report?.deterministicComparison),
modelEvaluation: report?.modelEvaluation,
predictedReply: report?.predictedReply,
error: result.exitCode === 0 ? undefined : result.stderr || result.stdout,
};
}
function parseArgs(args: string[]): CliOptions {
const options: Partial<CliOptions> = {
fixtureFile: "fixtures/persona-replay/metacrat.json",
outRoot: ".voidbot/artifacts/persona-replay-suite",
runCodex: false,
evaluateCodex: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const next = args[index + 1];
if (arg === "--fixture-file" && next) {
options.fixtureFile = next;
index += 1;
} else if (arg === "--fixture" && next) {
options.fixtureId = next;
index += 1;
} else if (arg === "--out" && next) {
options.outRoot = next;
index += 1;
} else if (arg === "--limit" && next) {
options.limit = parseNonNegativeInt(next, "--limit");
index += 1;
} else if (arg === "--run-codex") {
options.runCodex = true;
} else if (arg === "--evaluate-codex") {
options.evaluateCodex = true;
options.runCodex = true;
} else if (arg === "--help") {
printHelpAndExit();
} else {
throw new Error(`Unknown or incomplete argument: ${arg}`);
}
}
return {
fixtureFile: resolve(options.fixtureFile ?? "fixtures/persona-replay/metacrat.json"),
fixtureId: options.fixtureId,
outRoot: resolve(options.outRoot ?? ".voidbot/artifacts/persona-replay-suite"),
runCodex: options.runCodex ?? false,
evaluateCodex: options.evaluateCodex ?? false,
limit: options.limit,
};
}
function printHelpAndExit(): never {
console.log([
"Usage: npm run persona:replay-fixtures -- [options]",
"",
"Runs a data-driven Metacrat replay fixture suite through scripts/replay-metacrat-thread.ts.",
"",
"Options:",
" --fixture-file <path> Fixture JSON. Defaults to fixtures/persona-replay/metacrat.json",
" --fixture <id> Run one fixture id",
" --limit <n> Run at most n fixtures from the selected set",
" --out <dir> Artifact root. Defaults to .voidbot/artifacts/persona-replay-suite",
" --run-codex Generate predictions",
" --evaluate-codex Generate predictions and model evaluations",
].join("\n"));
process.exit(0);
}
function parseNonNegativeInt(value: string, label: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`${label} must be a non-negative integer.`);
}
return parsed;
}
function spawnForText(command: string, args: string[], timeoutMs: number): Promise<{
stdout: string;
stderr: string;
exitCode: number | null;
timedOut: boolean;
}> {
return new Promise((resolvePromise) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timeout = setTimeout(() => {
settled = true;
child.kill();
resolvePromise({ stdout, stderr, exitCode: null, timedOut: true });
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("close", (exitCode) => {
if (settled) {
return;
}
clearTimeout(timeout);
resolvePromise({ stdout, stderr, exitCode, timedOut: false });
});
});
}
function parseLastJsonObject(stdout: string): Record<string, unknown> | undefined {
const trimmed = stdout.trim();
if (!trimmed) {
return undefined;
}
for (let index = trimmed.length - 1; index >= 0; index -= 1) {
if (trimmed[index] !== "{") {
continue;
}
const candidate = trimmed.slice(index);
try {
return JSON.parse(candidate) as Record<string, unknown>;
} catch {
// Keep scanning for the JSON object printed by the child harness.
}
}
return undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -2381,8 +2381,10 @@ function renderRepoPersonaStatePacket(
`Affect feels like: ${asSentence(response.affectiveSignature)}`,
`Constraint loss: ${asSentence(response.constraintLoss)}`,
`Behavioral leak: ${asSentence(response.behavioralLeak)}`,
response.tangentAttractors.length > 0 ? `Tangent attractors: ${response.tangentAttractors.map(asSentence).join(" ")}` : "",
response.cadence ? `Cadence: ${asSentence(response.cadence)}` : "",
`Recovery path: ${asSentence(response.recoveryPath)}`,
].join(" "),
].filter(Boolean).join(" "),
),
"When the live room matches a trigger strongly enough, do not project the Persona's calm best self; project the pressured self that is actually online.",
].join("\n"));