diff --git a/packages/paperclip-runner/devtools/issue-thread/src/evalbook-site.css b/packages/paperclip-runner/devtools/issue-thread/src/evalbook-site.css
index 6567eafaaf..cdba55acb8 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/evalbook-site.css
+++ b/packages/paperclip-runner/devtools/issue-thread/src/evalbook-site.css
@@ -56,9 +56,32 @@
.evalbook-site .status { display: inline-block; padding: var(--pit-space-1) var(--pit-space-2); border-radius: var(--pit-radius-sm); font-weight: 600; text-transform: uppercase; }
.evalbook-site small { display: block; margin-top: var(--pit-space-1); }
.evalbook-site footer { margin-top: var(--pit-space-5); font-size: var(--pit-text-xs); }
+.evalbook-site .trend-group { padding: var(--pit-space-4); border: 1px solid var(--pit-border); border-radius: var(--pit-radius-sm); background: var(--pit-surface-sunken); }
+.evalbook-site .trend-group > summary { color: var(--pit-foreground); font-weight: 600; }
+.evalbook-site .trend-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--pit-space-5); }
+.evalbook-site figure { min-width: 0; margin: var(--pit-space-4) 0; }
+.evalbook-site figcaption { color: var(--pit-muted-foreground); font-size: var(--pit-text-xs); }
+.evalbook-site .trend-chart { width: 100%; overflow: visible; }
+.evalbook-site .trend-chart text { fill: var(--pit-muted-foreground); font: var(--pit-text-xs) var(--pit-font-mono); }
+.evalbook-site .trend-axis { fill: none; stroke: var(--pit-border); }
+.evalbook-site .trend-line { fill: none; stroke-width: 2; }
+.evalbook-site :is(.trend-line, .trend-point).pass { stroke: var(--pit-success); }
+.evalbook-site :is(.trend-line, .trend-point).all_attempts { stroke: var(--pit-live); }
+.evalbook-site :is(.trend-line, .trend-point).final_attempts { stroke: var(--pit-accent); stroke-dasharray: 4 3; }
+.evalbook-site .trend-point { fill: var(--pit-surface); stroke-width: 2; }
+.evalbook-site .trend-point:hover { fill: var(--pit-foreground); }
+.evalbook-site .pass-rate { display: block; margin-top: var(--pit-space-2); white-space: nowrap; }
+.evalbook-site .history-table table { min-width: 68rem; }
+.evalbook-site .history-table td:first-child { max-width: 22rem; }
+.evalbook-site .history-table details { margin: var(--pit-space-2) 0; }
+.evalbook-site .history-table li { margin: var(--pit-space-2) 0; }
+.evalbook-site .incomplete { background: var(--pit-warning-surface); color: var(--pit-warning); }
@media (max-width: 760px) {
.evalbook-site main { padding: var(--pit-space-4); }
.evalbook-site .grid { grid-template-columns: 1fr; }
+ .evalbook-site .trend-grid { grid-template-columns: 1fr; }
+ .evalbook-site .trend-grid figure { overflow-x: auto; }
+ .evalbook-site .trend-chart { min-width: 30rem; }
.evalbook-site .matrix th:first-child { width: 12rem; min-width: 10rem; }
.evalbook-site .badge { white-space: normal; text-align: center; }
}
diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md
index 9585aaea57..fe9ee06f6e 100644
--- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md
+++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md
@@ -74,8 +74,10 @@ revisions do not change. This is not a new model qualification run. Future live
runs create chat reports automatically.
Refreshes are ordered by their render time in the history list, but keep the
original measurement timestamp and never replace the latest or latest-green
-qualification pointers. Those two real-run records remain retained even when
-many report revisions fill the history window.
+qualification identities. The HTML history links each measurement to its newest
+presentation, while retaining every original bundle and listing refreshes separately.
+Refreshes also recover retry-inclusive estimated and provider-list costs from the
+retained attempt records. They never add another model-cost measurement.
This is the provider-backed, one-turn protocol qualification layer in
`paperclipai/paperclip-evals/evals/paperclip-runner`. It is intentionally
@@ -255,9 +257,27 @@ runner-protocol-evals/
Campaign files use immutable cache headers and a digest manifest. Reusing a
campaign ID with different bytes fails closed. Only the root history and
pointer files are mutable, and the publisher never deletes objects. The root
-history retains at most 200 records, reserving one record for the latest green
-campaign when it would otherwise fall outside that window so its pointer stays
-valid.
+history retains **all** run records; it no longer drops entries after 200 campaigns.
+
+The history index includes:
+
+- Pass-rate and cost timelines, grouped by identical cell/model/driver membership
+ and eval-suite SHA. Different suites and subsets cannot silently share a baseline.
+- Regression and recovery lists against the previous matching run, linking to the
+ affected tests. Infrastructure failures stay distinct from behavior failures.
+- Estimated cost and provider-reported list cost, shown separately, never added.
+ New campaigns include all retained attempts (including retries). Backfilled old
+ campaigns with only final-cell usage are labeled **historical final attempts only**.
+ Missing usage is unknown, not zero; partial totals use `≥` and display coverage.
+- Exact Paperclip and eval-suite commit links (full SHA on hover), the source ref,
+ and the GitHub Actions run. These identify the code **evaluated**, not merely
+ the commit used to render an old report.
+- A separate report-refresh list, excluded from trend points and model-spend totals.
+
+`history.json` stores a versioned, derived `analytics` projection separately from
+immutable campaign records. The publisher backfills missing analytics from each
+original `campaign.json`; a refresh may enrich costs using retained raw attempts
+only when its source metadata and scores still match the original record.
The publishing job uses dedicated `RUNNER_PROTOCOL_EVAL_HISTORY_*` variables
when present and falls back to the existing Runner E2E history role, region,
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index 74e717232e..030fc51505 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -115,7 +115,7 @@
"test:capability-evals": "vitest run src/conformance/capability-eval-suite.test.ts",
"test:eval-slice": "pnpm run ensure:eval-build-deps && vitest run src/eval",
"test:runner-workflow-evals": "pnpm run ensure:eval-build-deps && node --test scripts/render-runner-workflow-evalbook.test.mjs && vitest run src/eval/workflow-evals.test.ts src/eval/live-workflow-executor.test.ts",
- "test:runner-protocol-eval-publish": "node --test scripts/runner-protocol-eval-campaign.test.mjs scripts/publish-runner-protocol-eval-history.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs scripts/public-eval-chat.test.mjs",
+ "test:runner-protocol-eval-publish": "node --test scripts/runner-protocol-eval-campaign.test.mjs scripts/publish-runner-protocol-eval-history.test.mjs scripts/runner-protocol-eval-metrics.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs scripts/public-eval-chat.test.mjs",
"check:runner-workflow-traceability": "pnpm run build:typescript && node scripts/check-runner-workflow-traceability.mjs",
"report:capability-evals": "pnpm run build:typescript && node scripts/run-capability-eval-suite.mjs",
"report:capability-live-evals": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/run-capability-live-eval-matrix.mjs",
diff --git a/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs b/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs
index 8e16d10639..15acc1036c 100644
--- a/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs
+++ b/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs
@@ -14,6 +14,9 @@ import {
import { tmpdir } from "node:os";
import { extname, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
+import { enrichProtocolEvalHistory } from "./runner-protocol-eval-metrics.mjs";
+import { renderProtocolEvalHistoryIndex } from "./runner-protocol-eval-history-view.mjs";
+export { renderProtocolEvalHistoryIndex } from "./runner-protocol-eval-history-view.mjs";
import {
trustedViewerFiles,
validatePublicViewerPage,
@@ -46,21 +49,11 @@ const ACTIVE_HTML_PATTERNS = [
/javascript\s*:/iu,
/(?:src|href)\s*=\s*["'](?:https?:)?\/\//iu,
];
-const MAX_HISTORY_CAMPAIGNS = 200;
function json(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
-function html(value) {
- return String(value ?? "")
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
async function loadObject(path) {
const value = JSON.parse(await readFile(path, "utf8"));
if (value === null || Array.isArray(value) || typeof value !== "object") {
@@ -338,18 +331,13 @@ export function mergeProtocolEvalHistory(history, record) {
qualifications.find(
(campaign) => campaign.complete && campaign.allPassed,
) ?? null;
- const pointers = [...new Set([latest, latestGreen].filter(Boolean))];
- const retained = campaigns
- .filter((campaign) => !pointers.includes(campaign))
- .slice(0, MAX_HISTORY_CAMPAIGNS - pointers.length)
- .concat(pointers)
- .sort(activityOrder);
return {
schema: history.schema,
updatedAt: new Date().toISOString(),
latestCampaignId: latest?.campaignId ?? null,
latestGreenCampaignId: latestGreen?.campaignId ?? null,
- campaigns: retained,
+ campaigns,
+ ...(history.analytics ? { analytics: history.analytics } : {}),
};
}
@@ -383,46 +371,6 @@ export function buildProtocolEvalPointers(history) {
};
}
-function date(value) {
- return new Intl.DateTimeFormat("en-US", {
- dateStyle: "medium",
- timeStyle: "short",
- timeZone: "UTC",
- }).format(new Date(value));
-}
-
-export function renderProtocolEvalHistoryIndex(history, stylesheetHref) {
- if (!/^campaigns\/gha-[a-z0-9-]+\/viewer\/assets\/[A-Za-z0-9._-]+\.css$/.test(stylesheetHref ?? ""))
- throw new Error("History requires an immutable campaign's Runner Lab stylesheet");
- const rows = history.campaigns.length
- ? history.campaigns
- .map((campaign) => {
- const status =
- campaign.complete && campaign.allPassed ? "passed" : "failed";
- const rosters = campaign.rosters
- .map(
- (roster) =>
- `${html(roster.model)} · ${roster.passed}/${roster.selected}`,
- )
- .join(" ");
- return `
${html(campaign.campaignId)}${html(date(campaign.generatedAt))} UTC${campaign.reportRevision ? `Report refresh · no new model calls · source ${html(campaign.reportRevision.sourceCampaignId)}` : ""}
Cost: solid cyan includes retries; dashed purple is historical final-attempt cost. Missing cost is a gap, not zero. Hover or select a point to inspect its run.
`;
+ }).join("") || '
Comparable run metadata has not been recorded yet.
Every recorded campaign, its cost, and its exact source. Public chat replays are linked below; full provider evidence remains in access-controlled workflow artifacts.
Like-for-like trends
Only identical cells, model configurations, and eval-suite SHAs are compared. A changed suite starts a separate series. Report refreshes never count as new measurements. Regressions distinguish model behavior from infrastructure failures.
${trends}
All runs · ${measurements.length}
Estimated cost and provider list cost are alternatives, not additive. ≥ means some entries lack usage. Historical final-only cost excludes retry spending. Commit links expose the full SHA on hover.