SPB Git

spb/autoscholar

Public

QA: execution evidence accepts platform reproduction or tolerant experiment logs; reported values may live in results reports; soft reproduction gate; admin re-evaluate action

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 30, 2026) parent 87b4e55 Browse files

3 changed files +93 −15

added apps/web/src/app/api/admin/quality/[runId]/reevaluate/route.ts +25 −0
@@ -0,0 +1,25 @@
1+import { audit, transition } from "@autoscholar/core";
2+import { handler, json } from "@/lib/server/api";
3+import { db } from "@/lib/server/db";
4+import { requireAdmin } from "@/lib/server/session";
5+
6+export const runtime = "nodejs";
7+
8+/** Re-runs the deterministic quality gates on the existing artifacts (e.g. after a gate policy fix). */
9+export const POST = handler(async (req, { params }) => {
10+ const sess = await requireAdmin(req, { mutation: true });
11+ const { runId } = (await params) as { runId: string };
12+ const run = await db().transaction(async (tx) => {
13+ const r = await transition(tx, runId, "quality_check", {
14+ actor: `admin:${sess.userId}`,
15+ reason: "re-evaluation requested",
16+ expectFrom: ["quality_failed"],
17+ });
18+ await audit(tx, { type: "admin", id: sess.userId, ip: sess.ip }, "quality.reevaluate", {
19+ type: "run",
20+ id: runId,
21+ });
22+ return r;
23+ });
24+ return json({ ok: true, status: run.status });
25+});
modified packages/schemas/src/run.ts +1 −1
@@ -29,7 +29,7 @@ export const RUN_TRANSITIONS: Record<RunStatus, readonly RunStatus[]> = {
29 provisioning: ["running", "quality_check", "retrying", "cancelling", "cancelled", "failed"], 29 provisioning: ["running", "quality_check", "retrying", "cancelling", "cancelled", "failed"],
30 running: ["quality_check", "retrying", "cancelling", "failed", "revising"], 30 running: ["quality_check", "retrying", "cancelling", "failed", "revising"],
31 quality_check: ["quality_failed", "ready_to_publish", "cancelling", "failed"], 31 quality_check: ["quality_failed", "ready_to_publish", "cancelling", "failed"],
32− quality_failed: ["revising", "cancelled", "failed", "ready_to_publish"], 32+ quality_failed: ["revising", "quality_check", "cancelled", "failed", "ready_to_publish"],
33 revising: ["queued", "retrying", "cancelling", "failed"], 33 revising: ["queued", "retrying", "cancelling", "failed"],
34 ready_to_publish: ["awaiting_approval", "publishing", "cancelled", "failed"], 34 ready_to_publish: ["awaiting_approval", "publishing", "cancelled", "failed"],
35 awaiting_approval: ["publishing", "cancelled", "quality_failed"], 35 awaiting_approval: ["publishing", "cancelled", "quality_failed"],
modified services/qa/src/gates.ts +67 −14
@@ -370,7 +370,12 @@ export async function runGates(ctx: GateContext): Promise<GateResult[]> {
370 out.push(fail("results.consistency", "hard", `metrics.json invalid: ${metrics.error}`)); 370 out.push(fail("results.consistency", "hard", `metrics.json invalid: ${metrics.error}`));
371 else { 371 else {
372 const rv = metrics.value.reportedValues; 372 const rv = metrics.value.reportedValues;
373− const missing = rv.filter((v) => !texContainsNumber(tex, v.value)); 373+ const reportTexts: string[] = [];
374+ for (const a of ctx.artifacts)
375+ if (/^results\/.*\.(md|tex)$/.test(a.path) && a.bytes < 4 * 1024 * 1024)
376+ reportTexts.push((await a.read()).toString("utf8"));
377+ const corpus = `${tex}\n${reportTexts.join("\n")}`;
378+ const missing = rv.filter((v) => !texContainsNumber(corpus, v.value));
374 out.push( 379 out.push(
375 rv.length >= 3 && missing.length === 0 380 rv.length >= 3 && missing.length === 0
376 ? pass( 381 ? pass(
@@ -390,33 +395,81 @@ export async function runGates(ctx: GateContext): Promise<GateResult[]> {
390 if (metrics.value.synthetic && ctx.mode === "empirical") 395 if (metrics.value.synthetic && ctx.mode === "empirical")
391 out.push(fail("results.real", "hard", "empirical results flagged synthetic")); 396 out.push(fail("results.real", "hard", "empirical results flagged synthetic"));
392 } 397 }
398+ // execution evidence: platform reproduction (strongest), then the experiment log (tolerant of the agent's log shape)
399+ const repro = await readJson(
400+ find(ctx, "results/reproduction.json"),
401+ (x) =>
402+ x as {
403+ reproduceScript?: { ran?: boolean; exitCode?: number | null };
404+ metricsCompared?: number;
405+ metricsMismatched?: unknown[];
406+ metricsMissingInRerun?: number;
407+ },
408+ );
409+ const reproOk = repro.value?.reproduceScript?.ran === true && repro.value.reproduceScript.exitCode === 0;
393 const logA = find(ctx, P.experimentLog); 410 const logA = find(ctx, P.experimentLog);
394− const entries = logA 411+ const rawEntries = logA
395 ? (await logA.read()) 412 ? (await logA.read())
396 .toString("utf8") 413 .toString("utf8")
397 .split(/\r?\n/) 414 .split(/\r?\n/)
398 .filter(Boolean) 415 .filter(Boolean)
399 .map((l) => { 416 .map((l) => {
400 try { 417 try {
401− return ExperimentLogEntry.parse(JSON.parse(l)); 418+ return JSON.parse(l) as Record<string, unknown>;
402 } catch { 419 } catch {
403 return null; 420 return null;
404 } 421 }
405 }) 422 })
423+ .filter((e): e is Record<string, unknown> => !!e)
406 : []; 424 : [];
407− const ok = entries.filter((e) => e && e.exitCode === 0).length; 425+ const okEntries = rawEntries.filter((e) => {
426+ const strict = ExperimentLogEntry.safeParse(e);
427+ if (strict.success) return strict.data.exitCode === 0;
428+ const code = e.exitCode ?? e.exit_code ?? e.returncode ?? e.status;
429+ if (code !== undefined) return code === 0 || code === "0" || code === "ok" || code === "success";
430+ return !!(e.script || e.command || e.name) && !(e.error || e.failed === true);
431+ }).length;
408 const codeFiles = ctx.artifacts.filter( 432 const codeFiles = ctx.artifacts.filter(
409− (a) => a.path.startsWith("code/") && a.path !== P.codeReadme, 433+ (a) => a.path.startsWith("code/") && a.path !== P.codeReadme && !/\.(md|txt)$/.test(a.path),
410 ).length; 434 ).length;
411− out.push( 435+ const mismatched = repro.value?.metricsMismatched?.length ?? 0;
412− ok >= 1 && codeFiles >= 1 436+ if ((reproOk || okEntries >= 1) && codeFiles >= 1) {
413− ? pass("execution.evidence", "hard", `${ok} successful executions logged, ${codeFiles} code files`) 437+ out.push(
414− : fail( 438+ pass(
415− "execution.evidence", 439+ "execution.evidence",
416− "hard", 440+ "hard",
417− `insufficient execution evidence (successful runs: ${ok}, code files: ${codeFiles})`, 441+ `${reproOk ? "platform reproduction succeeded; " : ""}${okEntries} logged execution(s), ${codeFiles} code files`,
418− ), 442+ { reproOk, okEntries, codeFiles, metricsCompared: repro.value?.metricsCompared, mismatched },
419− ); 443+ ),
444+ );
445+ } else {
446+ out.push(
447+ fail(
448+ "execution.evidence",
449+ "hard",
450+ `insufficient execution evidence (platform reproduction ok: ${reproOk}, logged successful executions: ${okEntries}, code files: ${codeFiles})`,
451+ { reproOk, okEntries, codeFiles },
452+ ),
453+ );
454+ }
455+ if (repro.value?.reproduceScript?.ran) {
456+ out.push(
457+ reproOk && mismatched === 0
458+ ? pass(
459+ "reproduction.clean",
460+ "soft",
461+ `reproduce.sh re-ran cleanly; ${repro.value.metricsCompared ?? 0} metrics compared, 0 mismatches`,
462+ )
463+ : fail(
464+ "reproduction.clean",
465+ "soft",
466+ reproOk
467+ ? `${mismatched} metric(s) differed on re-execution`
468+ : `reproduce.sh exited with ${repro.value.reproduceScript.exitCode}`,
469+ { mismatched: repro.value.metricsMismatched?.slice(0, 10) },
470+ ),
471+ );
472+ }
420 } else out.push(skip("results.consistency", "hard", "not required for this mode")); 473 } else out.push(skip("results.consistency", "hard", "not required for this mode"));
421 474
422 // G11 data provenance 475 // G11 data provenance
423 476
Menu

Theme