rtr-bench 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,7 +201,9 @@ Predict and Learn currently execute requests sequentially. Each Learn episode co
201
201
 
202
202
  On 45 targets, Predict and Learn together typically use about 270 model calls. Adding `--controls` brings that to about 360: 45 for Predict, 45 for each automatic control, and approximately five calls per Learn episode. Early stopping can reduce that number; additional agent turns and transport retries can increase it. The first final GLM run used 347 calls.
203
203
 
204
- The public commands default to 4,000 output tokens per response. Learn uses the published `transfer-check-v1` policy, up to three inspected examples, and eight model turns. Advanced runs can override `--max-tokens` and, for Learn, `--agent-policy`.
204
+ In version 0.2.1, the public commands default to 8,000 output tokens per response. Learn uses the published `transfer-check-v1` policy, up to three inspected examples, and eight model turns. Advanced runs can override `--max-tokens` and, for Learn, `--agent-policy`.
205
+
206
+ To resume a run created with the earlier 4,000-token default, repeat its original command with `--max-tokens 4000` explicitly. Changing the allowance creates a separate run. The [final evaluation plan](docs/final-run-plan-8000.md) declares 8,000 tokens for all eleven models, including a new GLM result under that same setting.
205
207
 
206
208
  The current client uses OpenRouter. Direct connections to other hosted or local endpoints are not yet implemented.
207
209
 
package/dist/cli.js CHANGED
@@ -1122,6 +1122,19 @@ function meanCI(values, groups, reps = 2e3) {
1122
1122
  var API_URL = "https://openrouter.ai/api/v1/chat/completions";
1123
1123
  var MAX_ATTEMPTS = 3;
1124
1124
  var TIMEOUT_MS = 6e4;
1125
+ function errorCode(response, httpStatus) {
1126
+ const code = Number(response?.error?.code);
1127
+ return Number.isInteger(code) && code >= 400 && code <= 599 ? code : httpStatus;
1128
+ }
1129
+ function isSetupFailure(attempt) {
1130
+ if (attempt.status !== "error") return false;
1131
+ const type = attempt.response?.error?.metadata?.error_type;
1132
+ if (["refusal", "content_policy_violation", "max_tokens_exceeded", "rate_limit_exceeded", "provider_overloaded", "provider_unavailable", "server", "timeout"].includes(type)) return false;
1133
+ if (["authentication", "permission_denied", "payment_required", "invalid_request", "invalid_prompt", "not_found", "precondition_failed", "payload_too_large", "unprocessable", "context_length_exceeded", "string_too_long", "token_limit_exceeded"].includes(type)) return true;
1134
+ return [400, 401, 402, 404, 413, 422].includes(errorCode(attempt.response, attempt.httpStatus) ?? 0);
1135
+ }
1136
+ var RunSetupError = class extends Error {
1137
+ };
1125
1138
  function retryDelayMs(attempt, previousAttempts) {
1126
1139
  const metadata = attempt?.response?.error?.metadata;
1127
1140
  const seconds = Number(metadata?.retry_after_seconds);
@@ -1147,6 +1160,8 @@ async function requestCompletion(body, apiKey2, fetcher = fetch) {
1147
1160
  const error = !res.ok || response?.error ? `HTTP ${res.status}: ${JSON.stringify(response?.error ?? response).slice(0, 500)}` : null;
1148
1161
  const retryHeader = res.headers.get("retry-after");
1149
1162
  const retryAfterMs = retryHeader ? Number.isFinite(Number(retryHeader)) ? Math.max(0, Number(retryHeader) * 1e3) : Math.max(0, Date.parse(retryHeader) - Date.now()) : 0;
1163
+ const errorType = response?.error?.metadata?.error_type;
1164
+ const retryableError = errorType ? ["rate_limit_exceeded", "provider_overloaded", "provider_unavailable", "server", "timeout"].includes(errorType) : [429, 500, 502, 503, 504].includes(errorCode(response, res.status) ?? 0);
1150
1165
  return {
1151
1166
  status: error ? "error" : "response",
1152
1167
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1156,7 +1171,7 @@ async function requestCompletion(body, apiKey2, fetcher = fetch) {
1156
1171
  provider: typeof response?.provider === "string" ? response.provider : null,
1157
1172
  usage: response?.usage && typeof response.usage === "object" ? response.usage : null,
1158
1173
  error,
1159
- retriable: [429, 500, 502, 503, 504].includes(res.status),
1174
+ retriable: !!error && retryableError,
1160
1175
  ...Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? { retryAfterMs } : {}
1161
1176
  };
1162
1177
  } catch (e) {
@@ -1621,8 +1636,8 @@ async function discoverSpendingPolicy(model, provider, limitUsd, fetcher = fetch
1621
1636
  if (!suitable.length) throw new Error("No suitable text endpoint supports the spending guard");
1622
1637
  return suitable[0];
1623
1638
  }
1624
- function spendingProvider(policy) {
1625
- return { order: [policy.providerTag], allow_fallbacks: false, max_price: { prompt: policy.inputUsdPerToken * 1e6, completion: policy.outputUsdPerToken * 1e6, request: 0 } };
1639
+ function spendingProvider(policy, requireParameters = false) {
1640
+ return { order: [policy.providerTag], allow_fallbacks: false, max_price: { prompt: policy.inputUsdPerToken * 1e6, completion: policy.outputUsdPerToken * 1e6, request: 0 }, ...requireParameters ? { require_parameters: true } : {} };
1626
1641
  }
1627
1642
  function openSpendingLedger(file2, policy) {
1628
1643
  if (!["reserved-context-v1", "reserved-text-v2"].includes(policy.version) || !Number.isFinite(policy.limitUsd) || policy.limitUsd <= 0 || !Number.isSafeInteger(policy.contextTokens) || policy.contextTokens < 1 || !policy.providerTag || [policy.inputUsdPerToken, policy.outputUsdPerToken].some((p) => !Number.isFinite(p) || p <= 0)) throw new Error("Invalid spending policy");
@@ -1632,12 +1647,14 @@ function openSpendingLedger(file2, policy) {
1632
1647
  }
1633
1648
  const ledger = fs10.existsSync(file2) ? readJson(file2) : { policy, entries: [] };
1634
1649
  if (JSON.stringify(ledger.policy) !== JSON.stringify(policy)) throw new Error("Spending ledger policy mismatch");
1650
+ if (!Array.isArray(ledger.entries) || ledger.entries.some((e) => !Number.isSafeInteger(e.reservedUnits) || e.reservedUnits < 0 || e.chargedUnits !== null && (!Number.isSafeInteger(e.chargedUnits) || e.chargedUnits < 0))) throw new Error("Invalid spending ledger entries");
1635
1651
  const accounted = () => ledger.entries.reduce((s, e) => s + (e.chargedUnits ?? e.reservedUnits), 0);
1636
1652
  const save = () => writeJson(file2, ledger);
1637
1653
  save();
1638
1654
  return {
1639
- reserve(request) {
1640
- if (JSON.stringify(request.provider) !== JSON.stringify(spendingProvider(policy))) throw new Error("Request must use the budget's pinned provider and price ceiling");
1655
+ reserve(request, requestKey) {
1656
+ if (ledger.entries.some((e) => e.chargedUnits !== null && e.chargedUnits > e.reservedUnits)) throw new SpendingLimitError("A saved provider charge exceeded its reservation; stopping for review");
1657
+ if (JSON.stringify(request.provider) !== JSON.stringify(spendingProvider(policy, request.provider?.require_parameters === true))) throw new Error("Request must use the budget's pinned provider and price ceiling");
1641
1658
  if (!Number.isSafeInteger(request.max_tokens) || request.max_tokens <= 0) throw new Error("Budgeted requests require a finite output limit");
1642
1659
  if (policy.version === "reserved-text-v2") {
1643
1660
  const allowed = ["model", "messages", "temperature", "max_tokens", "usage", "provider"];
@@ -1647,18 +1664,23 @@ function openSpendingLedger(file2, policy) {
1647
1664
  }
1648
1665
  if (Buffer.byteLength(JSON.stringify(request.messages), "utf8") + 1024 * request.messages.length > policy.contextTokens) throw new SpendingLimitError("Request is too large for the conservative spending guard");
1649
1666
  const reservedUnits = units(policy.contextTokens * policy.inputUsdPerToken + request.max_tokens * policy.outputUsdPerToken);
1667
+ if (!Number.isSafeInteger(reservedUnits)) throw new SpendingLimitError("Reservation exceeds safe accounting precision");
1650
1668
  if (accounted() + reservedUnits > Math.floor(policy.limitUsd * 1e9)) throw new SpendingLimitError("Spending limit reached; next request was not sent");
1651
- ledger.entries.push({ reservedUnits, chargedUnits: null, at: (/* @__PURE__ */ new Date()).toISOString() });
1669
+ if (requestKey && ledger.entries.some((e) => e.requestKey === requestKey)) throw new SpendingLimitError("Saved reservation has no matching attempt; stopping for review");
1670
+ ledger.entries.push({ reservedUnits, chargedUnits: null, at: (/* @__PURE__ */ new Date()).toISOString(), ...requestKey ? { requestKey } : {} });
1652
1671
  save();
1653
1672
  return ledger.entries.length - 1;
1654
1673
  },
1655
- settle(index, usage, attempt) {
1674
+ settle(index, usage, attempt, requestKey) {
1675
+ const entry = ledger.entries[index];
1676
+ if (!Number.isSafeInteger(index) || !entry || requestKey !== void 0 && entry.requestKey !== requestKey) throw new Error("Spending reservation does not match saved attempt");
1656
1677
  const response = attempt?.response;
1657
1678
  const rejected = attempt?.httpStatus === 429 && response?.error?.code === 429 && response?.error?.metadata?.provider_error_code === "rate_limit_exceeded" && !response?.id && !response?.choices;
1658
1679
  const cost = usage?.cost ?? usage?.total_cost ?? (rejected ? 0 : null);
1659
1680
  if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
1660
- const entry = ledger.entries[index];
1661
- entry.chargedUnits = units(cost);
1681
+ const chargedUnits = units(cost);
1682
+ if (!Number.isSafeInteger(chargedUnits) || entry.chargedUnits !== null && entry.chargedUnits !== chargedUnits) throw new Error("Conflicting or invalid saved charge");
1683
+ entry.chargedUnits = chargedUnits;
1662
1684
  entry.costBasis = typeof usage?.cost === "number" || typeof usage?.total_cost === "number" ? "reported-cost" : "unbilled-rate-limit: https://openrouter.ai/pricing";
1663
1685
  save();
1664
1686
  if (entry.chargedUnits > entry.reservedUnits) throw new SpendingLimitError("Provider cost exceeded its reservation; stopping for review");
@@ -1698,6 +1720,7 @@ function agentRequest(config, messages) {
1698
1720
  const request = { model: config.model, messages: structuredClone(messages), ...config.settings.temperature === null ? {} : { temperature: config.settings.temperature }, max_tokens: config.settings.maxTokens, usage: { include: true } };
1699
1721
  if (config.provider) request.provider = { order: [config.provider], allow_fallbacks: false };
1700
1722
  if (config.spending) request.provider = spendingProvider(config.spending);
1723
+ if (config.version === "learn-room-runner-v4" && request.provider) request.provider.require_parameters = true;
1701
1724
  return request;
1702
1725
  }
1703
1726
  function callFinished(attempts) {
@@ -1821,6 +1844,7 @@ function learnRoomMarkdown(r) {
1821
1844
  "",
1822
1845
  `${r.manifest.config.partition}; ${r.scope}; ${r.complete ? "complete" : "incomplete"}. Evidence budget: ${r.manifest.config.budget}; seed: ${r.manifest.config.seed}.${r.manifest.config.agentPolicy ? ` Agent policy: ${r.manifest.config.agentPolicy}.` : ""}`,
1823
1846
  "",
1847
+ ...r.manifest.stopReason ? [`Stopped: ${r.manifest.stopReason.replace(/\s+/g, " ")}`, ""] : [],
1824
1848
  ...temperatureNote(r) ? [temperatureNote(r), ""] : [],
1825
1849
  "| Strategy | Score / 100 | Valid / invalid / missing / pending | Inspections | Model turns | API calls | Known tokens | Unknown-token attempts | Known cost USD | Unknown-cost attempts |",
1826
1850
  "|---|---:|---|---:|---:|---:|---:|---:|---:|---:|",
@@ -1843,7 +1867,7 @@ function learnRoomHtml(r) {
1843
1867
  const e = escapeHtml;
1844
1868
  return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Learn the Room</title><style>body{font:16px system-ui;max-width:1100px;margin:40px auto;padding:0 20px;background:#f7f8fa;color:#20242b}article{background:white;padding:20px;margin:20px 0;border:1px solid #ccd3dd;border-radius:8px}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:9px;border-bottom:1px solid #ddd}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}summary{cursor:pointer}input,select{padding:8px;margin:8px}.badge{color:#843600;font-weight:bold}</style>
1845
1869
  <h1>Learn the Room</h1><p class="badge">${r.manifest.config.mode === "fixture" ? "SYNTHETIC FIXTURE \u2014 not model performance" : e(r.manifest.config.model)}</p><p>${e(r.manifest.config.partition)} \xB7 ${r.scope} \xB7 ${r.complete ? "Complete" : "Incomplete"} \xB7 Inspection budget ${r.manifest.config.budget} \xB7 Seed ${r.manifest.config.seed}${r.manifest.config.agentPolicy ? ` \xB7 Agent policy ${e(r.manifest.config.agentPolicy)}` : ""}</p>
1846
- ${temperatureNote(r) ? `<p>${temperatureNote(r)}</p>` : ""}<table><tr><th>Strategy</th><th>Score / 100</th><th>95% interval</th><th>Inspections</th><th>Model turns</th><th>API calls</th><th>Known tokens</th><th>Unknown-token attempts</th><th>Known USD</th><th>Unknown-cost attempts</th></tr>${r.summaries.map((s) => `<tr><td>${s.strategy}</td><td>${fmt2(s.score)}</td><td>${s.scoreCI ? s.scoreCI.map(fmt2).join("\u2013") : "\u2014"}</td><td>${s.inspections}</td><td>${s.modelCalls}</td><td>${s.apiCalls}</td><td>${s.tokens.known}</td><td>${s.tokens.unknownAttempts}</td><td>${s.costUsd.known.toFixed(6)}</td><td>${s.costUsd.unknownAttempts}</td></tr>`).join("")}</table><p>Uniform: ${fmt2(r.baselines.uniformScore)}. Constant reference: ${fmt2(r.baselines.constantScore)}.</p><table><tr><th>Comparison</th><th>Gain (points)</th><th>Paired 95% interval</th></tr>${r.comparisons.map((p) => `<tr><td>${p.strategy} vs ${p.control}</td><td>${fmt2(p.scoreGain)}</td><td>${p.scoreGainCI.map(fmt2).join("\u2013")}</td></tr>`).join("")}</table><p>Scores are transformed distribution errors, not percentage accuracy. Invalid and missing predictions earn zero points. Usage is reported separately. Five-target synthetic pilots demonstrate the pipeline only.</p>
1870
+ ${r.manifest.stopReason ? `<p>Stopped: ${e(r.manifest.stopReason)}</p>` : ""}${temperatureNote(r) ? `<p>${temperatureNote(r)}</p>` : ""}<table><tr><th>Strategy</th><th>Score / 100</th><th>95% interval</th><th>Inspections</th><th>Model turns</th><th>API calls</th><th>Known tokens</th><th>Unknown-token attempts</th><th>Known USD</th><th>Unknown-cost attempts</th></tr>${r.summaries.map((s) => `<tr><td>${s.strategy}</td><td>${fmt2(s.score)}</td><td>${s.scoreCI ? s.scoreCI.map(fmt2).join("\u2013") : "\u2014"}</td><td>${s.inspections}</td><td>${s.modelCalls}</td><td>${s.apiCalls}</td><td>${s.tokens.known}</td><td>${s.tokens.unknownAttempts}</td><td>${s.costUsd.known.toFixed(6)}</td><td>${s.costUsd.unknownAttempts}</td></tr>`).join("")}</table><p>Uniform: ${fmt2(r.baselines.uniformScore)}. Constant reference: ${fmt2(r.baselines.constantScore)}.</p><table><tr><th>Comparison</th><th>Gain (points)</th><th>Paired 95% interval</th></tr>${r.comparisons.map((p) => `<tr><td>${p.strategy} vs ${p.control}</td><td>${fmt2(p.scoreGain)}</td><td>${p.scoreGainCI.map(fmt2).join("\u2013")}</td></tr>`).join("")}</table><p>Scores are transformed distribution errors, not percentage accuracy. Invalid and missing predictions earn zero points. Usage is reported separately. Five-target synthetic pilots demonstrate the pipeline only.</p>
1847
1871
  <label>Prompt <input id="query" type="search"></label><label>Strategy <select id="strategy"><option value="">All</option>${r.manifest.config.strategies.map((s) => `<option>${s}</option>`).join("")}</select></label>
1848
1872
  ${r.rows.map((row) => `<article data-search="${e(`${row.day} ${row.word}`.toLowerCase())}" data-strategy="${row.strategy}"><h2>${row.day}: ${e(row.word)} \xB7 ${row.strategy}</h2><p>${row.n} submissions \xB7 ${row.outcome ? `${row.outcome.status} \xB7 ${fmt2(row.outcome.score)} / 100 \xB7 ${row.outcome.inspections} inspections` : "Pending"}</p>${row.outcome?.failure ? `<p>${e(row.outcome.failure)}</p>` : ""}<table><tr><th>Quadrant</th><th>Predicted share</th><th>Observed share</th></tr>${QUADRANTS2.map((q) => `<tr><td>${q}</td><td>${row.outcome?.prediction ? fmt2(row.outcome.prediction[q]) : "\u2014"}</td><td>${fmt2(row.observed[q])}</td></tr>`).join("")}</table><details><summary>Actions and observations</summary><pre>${e(JSON.stringify(row.trace, null, 2))}</pre></details><details><summary>Model request and all attempts</summary><pre>${e(JSON.stringify({ request: row.request, attempts: row.attempts, agentCalls: row.agentCalls }, null, 2))}</pre></details></article>`).join("\n")}
1849
1873
  <script>function filter(){const q=document.getElementById('query').value.toLowerCase(),s=document.getElementById('strategy').value;document.querySelectorAll('article').forEach(a=>a.hidden=!a.dataset.search.includes(q)||(s&&a.dataset.strategy!==s));}document.getElementById('query').addEventListener('input',filter);document.getElementById('strategy').addEventListener('change',filter);</script></html>`;
@@ -1857,6 +1881,31 @@ function writeLearnRoomReport(dir) {
1857
1881
  }
1858
1882
 
1859
1883
  // src/tracks/learn-room/run.ts
1884
+ function savedBudgetRun(root, config, limitUsd) {
1885
+ const identity = (c) => {
1886
+ const { spending, ...rest2 } = structuredClone(c);
1887
+ rest2.settings.temperature = 0;
1888
+ if (rest2.version === "learn-room-runner-v3") rest2.version = "learn-room-runner-v4";
1889
+ return JSON.stringify(rest2);
1890
+ };
1891
+ const prefix = config.model.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase() + "-";
1892
+ const matches = [];
1893
+ for (const entry of fs12.existsSync(root) ? fs12.readdirSync(root, { withFileTypes: true }) : []) {
1894
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
1895
+ const file2 = path10.join(root, entry.name, "manifest.json");
1896
+ if (!fs12.existsSync(file2)) continue;
1897
+ const saved = readJson(file2);
1898
+ if (saved.config.spending?.limitUsd !== limitUsd || identity(saved.config) !== identity(config)) continue;
1899
+ if (saved.id !== digest(JSON.stringify(saved.config)) || entry.name !== prefix + saved.id.slice(0, 16)) throw new Error("Saved run fingerprint mismatch");
1900
+ if (saved.config.version !== config.version && saved.status !== "complete") throw new Error("An incomplete run from an older runner exists; review it before creating or resuming a paid run");
1901
+ matches.push(saved);
1902
+ }
1903
+ if (matches.length > 1) throw new Error("Multiple saved runs match this command; select a separate output directory after reviewing existing spending");
1904
+ return matches[0];
1905
+ }
1906
+ function stopForSetup(attempt) {
1907
+ if (isSetupFailure(attempt)) throw new RunSetupError(`API setup failed; run remains incomplete: ${attempt.error ?? "Inspect the saved API error"}`);
1908
+ }
1860
1909
  function prepareEpisode(config, strategy, day, data = loadDistributionData()) {
1861
1910
  const target = data.windows.find((w) => w.day === day);
1862
1911
  if (!target || !config.targets.includes(day) || !config.strategies.includes(strategy)) throw new Error("Episode is outside the declared run");
@@ -1866,6 +1915,7 @@ function prepareEpisode(config, strategy, day, data = loadDistributionData()) {
1866
1915
  const request = { model: config.model, messages, ...config.settings.temperature === null ? {} : { temperature: config.settings.temperature }, max_tokens: config.settings.maxTokens, usage: { include: true } };
1867
1916
  if (config.provider) request.provider = { order: [config.provider], allow_fallbacks: false };
1868
1917
  if (config.spending) request.provider = spendingProvider(config.spending);
1918
+ if (config.version === "learn-room-runner-v4" && request.provider) request.provider.require_parameters = true;
1869
1919
  return { episode, request };
1870
1920
  }
1871
1921
  function finishEpisode(episode, attempts) {
@@ -1904,15 +1954,24 @@ async function runLearnRoom(options, client = requestCompletion) {
1904
1954
  const model = fixture ? "fixture/synthetic" : options.model;
1905
1955
  if (!model) throw new Error("Specify a model or --fixture <file>");
1906
1956
  if (fixture && (options.maxUsd !== void 0 || options.spending)) throw new Error("Fixture runs do not need a spending policy");
1907
- const spending = options.spending ?? (options.maxUsd !== void 0 ? await discoverSpendingPolicy(model, options.provider ?? "", options.maxUsd) : void 0);
1908
- const config = { track: "learn-room", version: "learn-room-runner-v2", scorerVersion: DISTRIBUTION_VERSION, environmentVersion: ENVIRONMENT_VERSION, strategyVersion: STRATEGY_VERSION, similarityVersion: SIMILARITY_VERSION, agentVersion: AGENT_VERSION, agentPromptHash: policy.promptHash, maxModelCalls, promptHash: PROMPT_HASH2, evidenceInstruction: EVIDENCE_INSTRUCTION, datasetHash: data.datasetHash, splitHash: data.splitHash, partition, targets, reference: data.split.reference, strategies, budget, maxActions: MAX_ACTIONS, seed, model, provider: options.provider ?? null, mode: fixture ? "fixture" : "api", fixtureHash: fixtureRaw === null ? null : digest(fixtureRaw), settings: { temperature: 0, maxTokens, maxAttempts: MAX_ATTEMPTS, timeoutMs: TIMEOUT_MS, apiUrl: API_URL } };
1909
- if (spending) config.spending = spending;
1910
- if (spending?.version === "reserved-text-v2" && !spending.temperatureSupported) config.settings.temperature = null;
1911
- config.version = "learn-room-runner-v3";
1957
+ if (options.maxUsd !== void 0 && (!Number.isFinite(options.maxUsd) || options.maxUsd <= 0 || !options.provider)) throw new Error("--max-usd must be positive and requires --provider");
1958
+ if (options.maxUsd !== void 0 && options.spending && options.spending.limitUsd !== options.maxUsd) throw new Error("Spending policy differs from requested budget");
1959
+ let config = { track: "learn-room", version: "learn-room-runner-v4", scorerVersion: DISTRIBUTION_VERSION, environmentVersion: ENVIRONMENT_VERSION, strategyVersion: STRATEGY_VERSION, similarityVersion: SIMILARITY_VERSION, agentVersion: AGENT_VERSION, agentPromptHash: policy.promptHash, maxModelCalls, promptHash: PROMPT_HASH2, evidenceInstruction: EVIDENCE_INSTRUCTION, datasetHash: data.datasetHash, splitHash: data.splitHash, partition, targets, reference: data.split.reference, strategies, budget, maxActions: MAX_ACTIONS, seed, model, provider: options.provider ?? null, mode: fixture ? "fixture" : "api", fixtureHash: fixtureRaw === null ? null : digest(fixtureRaw), settings: { temperature: 0, maxTokens, maxAttempts: MAX_ATTEMPTS, timeoutMs: TIMEOUT_MS, apiUrl: API_URL } };
1912
1960
  config.settings.retryPolicy = "retry-after-v1";
1913
1961
  if (options.replicate !== void 0) config.replicate = options.replicate;
1914
1962
  if (policy.name !== "default") config.agentPolicy = policy.name;
1915
- const id = digest(JSON.stringify(config)), dir = path10.join(options.output ?? path10.join("results", "learn-room"), `${model.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase()}-${id.slice(0, 16)}`);
1963
+ const root = options.output ?? path10.join("results", "learn-room");
1964
+ const saved = !options.spending && options.maxUsd !== void 0 ? savedBudgetRun(root, config, options.maxUsd) : void 0;
1965
+ let spending = options.spending;
1966
+ if (saved) {
1967
+ config = saved.config;
1968
+ spending = config.spending;
1969
+ } else {
1970
+ spending ??= options.maxUsd !== void 0 ? await discoverSpendingPolicy(model, options.provider ?? "", options.maxUsd) : void 0;
1971
+ if (spending) config.spending = spending;
1972
+ if (spending?.version === "reserved-text-v2" && !spending.temperatureSupported) config.settings.temperature = null;
1973
+ }
1974
+ const id = digest(JSON.stringify(config)), dir = path10.join(root, `${model.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase()}-${id.slice(0, 16)}`);
1916
1975
  fs12.mkdirSync(dir, { recursive: true });
1917
1976
  const lock = path10.join(dir, "run.lock");
1918
1977
  let fd;
@@ -1927,8 +1986,25 @@ async function runLearnRoom(options, client = requestCompletion) {
1927
1986
  const manifestPath = path10.join(dir, "manifest.json");
1928
1987
  const manifest = fs12.existsSync(manifestPath) ? readJson(manifestPath) : { id, createdAt: (/* @__PURE__ */ new Date()).toISOString(), config, status: "incomplete" };
1929
1988
  if (manifest.id !== id || JSON.stringify(manifest.config) !== JSON.stringify(config)) throw new Error("Run manifest mismatch");
1989
+ if (spending && !fs12.existsSync(path10.join(dir, "spending.json")) && fs12.existsSync(path10.join(dir, "episodes"))) throw new Error("Spending ledger is missing for a saved run; refusing to reset its budget");
1990
+ if (fs12.existsSync(manifestPath)) {
1991
+ const prior = buildLearnRoomReport(dir);
1992
+ if (manifest.status === "complete" && prior.rows.some((row) => !fs12.existsSync(path10.join(dir, "episodes", row.strategy, `${row.day}.json`)))) throw new Error("Completed run has missing episodes; refusing to rerun saved evaluation targets");
1993
+ }
1930
1994
  writeJson(manifestPath, manifest);
1931
1995
  const ledger = spending ? openSpendingLedger(path10.join(dir, "spending.json"), spending) : null;
1996
+ if (ledger && config.version === "learn-room-runner-v4") {
1997
+ for (const s of strategies) for (const day of targets) {
1998
+ const file2 = path10.join(dir, "episodes", s, `${day}.json`);
1999
+ if (!fs12.existsSync(file2)) continue;
2000
+ const rec = readJson(file2);
2001
+ const calls = s === "agent" ? rec.agentCalls ?? [] : [{ attempts: rec.attempts }];
2002
+ for (const [turn, call2] of calls.entries()) for (const a of call2.attempts) {
2003
+ if (a.spendingReservation === void 0) throw new Error("Saved attempt has no spending reservation");
2004
+ ledger.settle(a.spendingReservation, a.usage, a, `${s}/${day}/${turn}/${a.index}`);
2005
+ }
2006
+ }
2007
+ }
1932
2008
  for (const s of strategies) for (const day of targets) {
1933
2009
  const { episode, request } = prepareEpisode(config, s, day, data);
1934
2010
  const file2 = path10.join(dir, "episodes", s, `${day}.json`);
@@ -1950,8 +2026,10 @@ async function runLearnRoom(options, client = requestCompletion) {
1950
2026
  while (!callFinished(call2.attempts)) {
1951
2027
  if (!fixture && !process.env.OPENROUTER_API_KEY) throw new Error("OPENROUTER_API_KEY is not set");
1952
2028
  if (call2.attempts.length && !fixture) await new Promise((resolve) => setTimeout(resolve, retryDelayMs(call2.attempts.at(-1), call2.attempts.length)));
1953
- const reservation = ledger?.reserve(call2.request);
2029
+ const requestKey = `${s}/${day}/${rec.agentCalls.length - 1}/${call2.attempts.length + 1}`;
2030
+ const reservation = ledger?.reserve(call2.request, requestKey);
1954
2031
  const attempt = { index: call2.attempts.length + 1, startedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "started", raw: "", response: null, provider: null, usage: null, error: null, retriable: true };
2032
+ if (reservation !== void 0) attempt.spendingReservation = reservation;
1955
2033
  call2.attempts.push(attempt);
1956
2034
  rec.attempts = rec.agentCalls.flatMap((c) => c.attempts);
1957
2035
  writeJson(file2, rec);
@@ -1959,9 +2037,12 @@ async function runLearnRoom(options, client = requestCompletion) {
1959
2037
  const raw = fixture[s][day][rec.agentCalls.length - 1] ?? "";
1960
2038
  Object.assign(attempt, { status: "response", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), raw, response: { fixture: true, content: raw }, usage: { total_tokens: 0, cost: 0 }, retriable: false });
1961
2039
  } else Object.assign(attempt, await client(call2.request, process.env.OPENROUTER_API_KEY));
2040
+ if (isSetupFailure(attempt)) attempt.retriable = true;
1962
2041
  writeJson(file2, rec);
1963
- if (reservation !== void 0) ledger.settle(reservation, attempt.usage, attempt);
2042
+ if (reservation !== void 0) ledger.settle(reservation, attempt.usage, attempt, requestKey);
2043
+ stopForSetup(attempt);
1964
2044
  }
2045
+ if (call2.attempts.length) stopForSetup(call2.attempts.at(-1));
1965
2046
  applyAgentCall(episode, messages, call2);
1966
2047
  call2.applied = true;
1967
2048
  enforceAgentLimit(episode, rec.agentCalls, config);
@@ -1977,27 +2058,38 @@ async function runLearnRoom(options, client = requestCompletion) {
1977
2058
  while (rec.attempts.length < MAX_ATTEMPTS && !(last && (last.status === "response" || last.status === "error" && !last.retriable))) {
1978
2059
  if (!fixture && !process.env.OPENROUTER_API_KEY) throw new Error("OPENROUTER_API_KEY is not set");
1979
2060
  if (rec.attempts.length && !fixture) await new Promise((resolve) => setTimeout(resolve, retryDelayMs(rec.attempts.at(-1), rec.attempts.length)));
1980
- const reservation = ledger?.reserve(request);
2061
+ const requestKey = `${s}/${day}/0/${rec.attempts.length + 1}`;
2062
+ const reservation = ledger?.reserve(request, requestKey);
1981
2063
  const attempt = { index: rec.attempts.length + 1, startedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "started", raw: "", response: null, provider: null, usage: null, error: null, retriable: true };
2064
+ if (reservation !== void 0) attempt.spendingReservation = reservation;
1982
2065
  rec.attempts.push(attempt);
1983
2066
  writeJson(file2, rec);
1984
2067
  if (fixture) Object.assign(attempt, { status: "response", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), raw: fixture[s][day], response: { fixture: true, content: fixture[s][day] }, usage: { total_tokens: 0, cost: 0 }, retriable: false });
1985
2068
  else Object.assign(attempt, await client(request, process.env.OPENROUTER_API_KEY));
2069
+ if (isSetupFailure(attempt)) attempt.retriable = true;
1986
2070
  writeJson(file2, rec);
1987
2071
  last = attempt;
1988
- if (reservation !== void 0) ledger.settle(reservation, attempt.usage, attempt);
2072
+ if (reservation !== void 0) ledger.settle(reservation, attempt.usage, attempt, requestKey);
2073
+ stopForSetup(attempt);
1989
2074
  }
2075
+ if (last) stopForSetup(last);
1990
2076
  Object.assign(rec, finishEpisode(episode, rec.attempts), { terminal: true });
1991
2077
  writeJson(file2, rec);
1992
2078
  console.log(`learn-room: ${s} day ${day} ${rec.outcome.status}, ${rec.outcome.inspections} inspections`);
1993
2079
  }
1994
2080
  manifest.status = "complete";
2081
+ delete manifest.stopReason;
1995
2082
  manifest.completedAt ??= (/* @__PURE__ */ new Date()).toISOString();
1996
2083
  writeJson(manifestPath, manifest);
1997
2084
  if (ledger) console.log(`Spending: ${JSON.stringify(ledger.summary())}`);
1998
2085
  return { dir, report: writeLearnRoomReport(dir) };
1999
2086
  } catch (error) {
2000
- if (error instanceof SpendingLimitError) {
2087
+ if (error instanceof SpendingLimitError || error instanceof RunSetupError) {
2088
+ const manifest = readJson(path10.join(dir, "manifest.json"));
2089
+ manifest.status = "incomplete";
2090
+ manifest.stopReason = error.message;
2091
+ delete manifest.completedAt;
2092
+ writeJson(path10.join(dir, "manifest.json"), manifest);
2001
2093
  writeLearnRoomReport(dir);
2002
2094
  console.error(`${error.message}. Incomplete report: ${dir}`);
2003
2095
  }
@@ -2049,7 +2141,7 @@ if (["read-the-room", "predict", "learn", "learn-room"].includes(cmd)) {
2049
2141
  partition: flags.split ?? "development",
2050
2142
  strategy,
2051
2143
  maxModelCalls: flags["max-model-calls"] === void 0 ? void 0 : Number(flags["max-model-calls"]),
2052
- maxTokens: flags["max-tokens"] === void 0 ? legacy ? void 0 : 4e3 : Number(flags["max-tokens"]),
2144
+ maxTokens: flags["max-tokens"] === void 0 ? legacy ? void 0 : 8e3 : Number(flags["max-tokens"]),
2053
2145
  replicate: flags.replicate,
2054
2146
  agentPolicy: flags["agent-policy"] ?? (!legacy && cmd !== "predict" ? "transfer-check-v1" : void 0),
2055
2147
  maxUsd: flags["max-usd"] === void 0 ? void 0 : Number(flags["max-usd"]),
@@ -0,0 +1,262 @@
1
+ {
2
+ "createdAt": "2026-11-14T22:47:51.731Z",
3
+ "purpose": "Development compatibility pilot; not a model ranking or final evaluation",
4
+ "protocol": {
5
+ "partition": "development",
6
+ "targets": [
7
+ 100,
8
+ 101,
9
+ 102
10
+ ],
11
+ "strategy": "all",
12
+ "budget": 3,
13
+ "seed": 7,
14
+ "maxModelCalls": 8,
15
+ "maxActions": 8,
16
+ "maxTokens": 4000,
17
+ "agentPolicy": "transfer-check-v1"
18
+ },
19
+ "models": [
20
+ {
21
+ "model": "z-ai/glm-5.3",
22
+ "provider": "wafer/us",
23
+ "quantization": "fp4",
24
+ "catalogStatus": 0,
25
+ "source": "https://openrouter.ai/api/v1/models/z-ai/glm-5.3/endpoints",
26
+ "estimateUsd": 0.024543114233333333,
27
+ "reservationUsd": 0.07889546239999999,
28
+ "capUsd": 0.2,
29
+ "spending": {
30
+ "version": "reserved-context-v1",
31
+ "limitUsd": 0.2,
32
+ "providerTag": "wafer/us",
33
+ "contextTokens": 1048576,
34
+ "inputUsdPerToken": 6.99e-08,
35
+ "outputUsdPerToken": 1.4e-06
36
+ }
37
+ },
38
+ {
39
+ "model": "minimax/minimax-m3.1-flash",
40
+ "provider": "inferact-vllm/nvfp4",
41
+ "quantization": "nvfp4",
42
+ "catalogStatus": 0,
43
+ "source": "https://openrouter.ai/api/v1/models/minimax/minimax-m3.1-flash/endpoints",
44
+ "estimateUsd": 0.006733225,
45
+ "reservationUsd": 0.0798432,
46
+ "capUsd": 0.15,
47
+ "spending": {
48
+ "version": "reserved-context-v1",
49
+ "limitUsd": 0.15,
50
+ "providerTag": "inferact-vllm/nvfp4",
51
+ "contextTokens": 1048576,
52
+ "inputUsdPerToken": 7.5e-08,
53
+ "outputUsdPerToken": 3e-07
54
+ }
55
+ },
56
+ {
57
+ "model": "xiaomi/mimo-v2.6-flash",
58
+ "provider": "xiaomi/fp8",
59
+ "quantization": "fp8",
60
+ "catalogStatus": 0,
61
+ "source": "https://openrouter.ai/api/v1/models/xiaomi/mimo-v2.6-flash/endpoints",
62
+ "estimateUsd": 0.008003380000000001,
63
+ "reservationUsd": 0.14792064000000002,
64
+ "capUsd": 0.2,
65
+ "spending": {
66
+ "version": "reserved-context-v1",
67
+ "limitUsd": 0.2,
68
+ "providerTag": "xiaomi/fp8",
69
+ "contextTokens": 1048576,
70
+ "inputUsdPerToken": 1.4e-07,
71
+ "outputUsdPerToken": 2.8e-07
72
+ }
73
+ },
74
+ {
75
+ "model": "deepseek/deepseek-v4.1-flash-1017",
76
+ "provider": "sail-research/fp4/us",
77
+ "quantization": "fp4",
78
+ "catalogStatus": 0,
79
+ "source": "https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints",
80
+ "estimateUsd": 0.0035467375200000004,
81
+ "reservationUsd": 0.07668444672000001,
82
+ "capUsd": 0.1,
83
+ "spending": {
84
+ "version": "reserved-context-v1",
85
+ "limitUsd": 0.1,
86
+ "providerTag": "sail-research/fp4/us",
87
+ "contextTokens": 1048576,
88
+ "inputUsdPerToken": 7.272e-08,
89
+ "outputUsdPerToken": 1.08e-07
90
+ }
91
+ },
92
+ {
93
+ "model": "tencent/hy3",
94
+ "provider": "gmicloud/bf16",
95
+ "quantization": "bf16",
96
+ "catalogStatus": 0,
97
+ "source": "https://openrouter.ai/api/v1/models/tencent/hy3/endpoints",
98
+ "estimateUsd": 0.01289478,
99
+ "reservationUsd": 0.039020160000000005,
100
+ "capUsd": 0.1,
101
+ "spending": {
102
+ "version": "reserved-context-v1",
103
+ "limitUsd": 0.1,
104
+ "providerTag": "gmicloud/bf16",
105
+ "contextTokens": 262144,
106
+ "inputUsdPerToken": 1.4e-07,
107
+ "outputUsdPerToken": 5.8e-07
108
+ }
109
+ },
110
+ {
111
+ "model": "google/gemini-3.8-flash",
112
+ "provider": "google-ai-studio",
113
+ "quantization": "unknown",
114
+ "catalogStatus": 0,
115
+ "source": "https://openrouter.ai/api/v1/models/google/gemini-3.8-flash/endpoints",
116
+ "estimateUsd": 0.07956075,
117
+ "reservationUsd": 0.801432,
118
+ "capUsd": 1.05,
119
+ "spending": {
120
+ "version": "reserved-text-v2",
121
+ "limitUsd": 1.05,
122
+ "providerTag": "google-ai-studio",
123
+ "contextTokens": 1048576,
124
+ "inputUsdPerToken": 7.5e-07,
125
+ "outputUsdPerToken": 3.75e-06,
126
+ "model": "google/gemini-3.8-flash",
127
+ "temperatureSupported": true,
128
+ "maxCompletionTokens": 65536,
129
+ "pricing": {
130
+ "audio": "0.00000075",
131
+ "completion": "0.00000375",
132
+ "discount": 0.5,
133
+ "image": "0.00000075",
134
+ "input_audio_cache": "0.000000075",
135
+ "input_cache_read": "0.000000075",
136
+ "input_cache_write": "0.0000000416666666666667",
137
+ "internal_reasoning": "0.00000375",
138
+ "prompt": "0.00000075",
139
+ "web_search": "0.014"
140
+ }
141
+ }
142
+ },
143
+ {
144
+ "model": "openai/gpt-6-luna",
145
+ "provider": "openai",
146
+ "quantization": "unknown",
147
+ "catalogStatus": 0,
148
+ "source": "https://openrouter.ai/api/v1/models/openai/gpt-6-luna/endpoints",
149
+ "estimateUsd": 0.010608099999999999,
150
+ "reservationUsd": 0.13275,
151
+ "capUsd": 0.2,
152
+ "spending": {
153
+ "version": "reserved-text-v2",
154
+ "limitUsd": 0.2,
155
+ "providerTag": "openai/flex",
156
+ "contextTokens": 1050000,
157
+ "inputUsdPerToken": 1.25e-07,
158
+ "outputUsdPerToken": 3.75e-07,
159
+ "model": "openai/gpt-6-luna",
160
+ "temperatureSupported": false,
161
+ "maxCompletionTokens": 128000,
162
+ "pricing": {
163
+ "completion": "0.00000025",
164
+ "discount": 0,
165
+ "input_cache_read": "0.000000005",
166
+ "input_cache_write": "0.0000000625",
167
+ "overrides": [
168
+ {
169
+ "completion": "0.000000375",
170
+ "input_cache_read": "0.00000001",
171
+ "input_cache_write": "0.000000125",
172
+ "min_prompt_tokens": 272000,
173
+ "prompt": "0.0000001"
174
+ }
175
+ ],
176
+ "prompt": "0.00000005",
177
+ "web_search": "0.01"
178
+ }
179
+ }
180
+ },
181
+ {
182
+ "model": "anthropic/claude-haiku-5.5",
183
+ "provider": "anthropic",
184
+ "quantization": "unknown",
185
+ "catalogStatus": 0,
186
+ "source": "https://openrouter.ai/api/v1/models/anthropic/claude-haiku-5.5/endpoints",
187
+ "estimateUsd": 0.010608099999999999,
188
+ "reservationUsd": 1.01,
189
+ "capUsd": 1.05,
190
+ "spending": {
191
+ "version": "reserved-text-v2",
192
+ "limitUsd": 1.05,
193
+ "providerTag": "anthropic",
194
+ "contextTokens": 1000000,
195
+ "inputUsdPerToken": 1e-06,
196
+ "outputUsdPerToken": 2.5e-06,
197
+ "model": "anthropic/claude-haiku-5.5",
198
+ "temperatureSupported": false,
199
+ "maxCompletionTokens": 128000,
200
+ "pricing": {
201
+ "completion": "0.0000005",
202
+ "discount": 0,
203
+ "input_cache_read": "0.00000001",
204
+ "input_cache_write": "0.000000125",
205
+ "input_cache_write_1h": "0.0000002",
206
+ "overrides": [
207
+ {
208
+ "completion": "0.0000025",
209
+ "input_cache_read": "0.00000005",
210
+ "input_cache_write": "0.000000625",
211
+ "input_cache_write_1h": "0.000001",
212
+ "min_prompt_tokens": 100000,
213
+ "prompt": "0.0000005"
214
+ }
215
+ ],
216
+ "prompt": "0.0000001",
217
+ "web_search": "0.01"
218
+ }
219
+ }
220
+ },
221
+ {
222
+ "model": "thinkingmachines/inkling-small",
223
+ "provider": "deepinfra/fp4",
224
+ "quantization": "fp4",
225
+ "catalogStatus": 0,
226
+ "source": "https://openrouter.ai/api/v1/models/thinkingmachines/inkling-small/endpoints",
227
+ "estimateUsd": 0.03061655,
228
+ "reservationUsd": 0.2407296,
229
+ "capUsd": 0.35,
230
+ "spending": {
231
+ "version": "reserved-context-v1",
232
+ "limitUsd": 0.35,
233
+ "providerTag": "deepinfra/fp4",
234
+ "contextTokens": 524288,
235
+ "inputUsdPerToken": 4.5e-07,
236
+ "outputUsdPerToken": 1.2e-06
237
+ }
238
+ },
239
+ {
240
+ "model": "openai/gpt-oss-120b",
241
+ "provider": "primeintellect/fp4",
242
+ "quantization": "fp4",
243
+ "catalogStatus": 0,
244
+ "source": "https://openrouter.ai/api/v1/models/openai/gpt-oss-120b/endpoints",
245
+ "estimateUsd": 0.0021967023333333336,
246
+ "reservationUsd": 0.005165664,
247
+ "capUsd": 0.05,
248
+ "spending": {
249
+ "version": "reserved-context-v1",
250
+ "limitUsd": 0.05,
251
+ "providerTag": "primeintellect/fp4",
252
+ "contextTokens": 131072,
253
+ "inputUsdPerToken": 3.7e-08,
254
+ "outputUsdPerToken": 7.9e-08
255
+ }
256
+ }
257
+ ],
258
+ "estimateUsd": 0.18931143908666667,
259
+ "capUsd": 3.45,
260
+ "output": "results/development-preflight-2026-11-14",
261
+ "execution": "Sequential; identical commands resume saved records. Stop each model at its cap; never automatically raise caps, delete attempts, or launch final evaluation."
262
+ }
@@ -1,6 +1,6 @@
1
1
  # Distribution prediction protocol v1
2
2
 
3
- For the public Predict track, use `node src/cli.ts predict <model>`; see the [Quickstart](../README.md#quickstart). This uses the shared Learn runner’s no-example condition with a 4,000-token default and support for `--max-usd`. `read-the-room <model>` runs Predict and Learn together. The legacy `distribution` command and its fixture format remain available unchanged.
3
+ For the public Predict track, use `node src/cli.ts predict <model>`; see the [Quickstart](../README.md#quickstart). This uses the shared Learn runner’s no-example condition with an 8,000-token default in version 0.2.1 and support for `--max-usd`. `read-the-room <model>` runs Predict and Learn together. The legacy `distribution` command and its fixture format remain available unchanged.
4
4
 
5
5
  This is a separate RTR-bench track. The original winning-quadrant benchmark retains its prompts, scoring, and result paths. [Learn the Room](learn-room.md) reuses this protocol's probability validation and scorer for its interactive environment and initial baseline comparisons.
6
6