fix: restart the stalled autonomy pipeline and make calibration honest

Archive ingestion had been dead since 2026-08-02 because nothing in the
compose stack actually ran it. Everything downstream starved from there.

- add ingest + enrichment services. server.js only starts the scheduler when
  DURIIN_RUN_SCHEDULER is not "false", and workers/index.js was not running at
  all, so articles never got event_id/content/has_embedding and the coordinator
  had nothing to lease.
- pass an explicit origin from coordinatorWorker. it was never passed, so
  acceptProposal defaulted to 'live' and 464 historical backfill predictions
  were recorded as live. that also meant verifyEvidence got a null cutoff and
  skipped its date check entirely.
- coarsen cohortKey to event families + horizon buckets. 201 free text event
  types produced 221 cohorts averaging 2.76 samples, so the n>=30 gate could
  never be reached and everything abstained for the wrong reason.
- gate on cohort diversity, not just sample count. one ticker was roughly half
  of all resolved outcomes, so a pure count gate was measuring one company.
  unknown diversity abstains rather than passing.
- resolve the admin archive db explicitly and probe it. it relied on a
  Dockerfile symlink, and without it better-sqlite3 quietly creates an empty
  file and serves a phantom archive.
- clamp implausible future publication dates at ingest.
- pin the db backend to sqlite by default. compose hardcoded postgres "true",
  which would have overridden the operator's own .env on the next redeploy and
  pointed everything at a stale snapshot.

scripts/repair-autonomy-labels.js relabels the affected rows. it is dry run by
default and has not been applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
ImBenji
2026-08-29 21:43:24 +01:00
co-authored by Claude Opus 5
parent d778a02bfb
commit 6f1d1eee2d
19 changed files with 1497 additions and 100 deletions
+72 -14
View File
@@ -50,6 +50,11 @@ services:
networks: networks:
- nginx_proxy_manager_default - nginx_proxy_manager_default
# DB backend is one switch for the whole stack and it defaults to sqlite on purpose.
# the postgres copy is a stale snapshot (predictions stop around 2026-08-17) and the
# migrate script only appends, it never replays UPDATEs, so a `compose up` must not
# quietly flip us over. set DURIIN_DB_BACKEND=postgres + DURIIN_USE_POSTGRES=true in
# .env only after a fresh intelligence migration has been run and verifyed.
api: api:
build: build:
context: . context: .
@@ -61,8 +66,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
DURIIN_RUN_SCHEDULER: "false" DURIIN_RUN_SCHEDULER: "false"
AUTONOMY_EXECUTION_MODE: "${AUTONOMY_EXECUTION_MODE:-shadow}" AUTONOMY_EXECUTION_MODE: "${AUTONOMY_EXECUTION_MODE:-shadow}"
@@ -73,7 +78,60 @@ services:
networks: networks:
- nginx_proxy_manager_default - nginx_proxy_manager_default
# same image as api, but this one actually runs the cron scheduler
# (rss / gdelt / edgar / alphavantage / finnhub). it also boots fastify on
# 3001 but nothing proxies to it, so its effectivly ingestion only.
ingest:
build:
context: .
provenance: false
env_file: .env
volumes:
- ./config.json:/app/config.json:ro
- ./data:/data
environment:
NODE_ENV: production
INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
DURIIN_RUN_SCHEDULER: "true"
AUTONOMY_EXECUTION_MODE: "${AUTONOMY_EXECUTION_MODE:-shadow}"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- nginx_proxy_manager_default
# enrichment chain: queue feeder -> augor -> consolidation -> graph -> signal -> outcome.
# this is what fills event_id / content / has_embedding, which the autonomy
# reconcilers require before they enqueue anything.
enrichment:
build:
context: .
provenance: false
command: node workers/index.js
env_file: .env
volumes:
- ./config.json:/app/config.json:ro
- ./data:/data
environment:
NODE_ENV: production
DURIIN_DB: /data/archive.sqlite
INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- nginx_proxy_manager_default
intelligence: intelligence:
# superseded by the "enrichment" service above, kept behind a profile
profiles: [legacy] profiles: [legacy]
build: build:
context: . context: .
@@ -104,8 +162,8 @@ services:
NODE_ENV: production NODE_ENV: production
DURIIN_DB: /data/archive.sqlite DURIIN_DB: /data/archive.sqlite
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_POLL_MS: "${AUTONOMY_POLL_MS:-5000}" AUTONOMY_POLL_MS: "${AUTONOMY_POLL_MS:-5000}"
depends_on: depends_on:
@@ -130,8 +188,8 @@ services:
NODE_ENV: production NODE_ENV: production
DURIIN_DB: /data/archive.sqlite DURIIN_DB: /data/archive.sqlite
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_POLL_MS: "${AUTONOMY_COORDINATOR_POLL_MS:-5000}" AUTONOMY_POLL_MS: "${AUTONOMY_COORDINATOR_POLL_MS:-5000}"
depends_on: depends_on:
@@ -154,8 +212,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_OUTCOME_POLL_MS: "${AUTONOMY_OUTCOME_POLL_MS:-60000}" AUTONOMY_OUTCOME_POLL_MS: "${AUTONOMY_OUTCOME_POLL_MS:-60000}"
depends_on: depends_on:
@@ -180,8 +238,8 @@ services:
NODE_ENV: production NODE_ENV: production
DURIIN_DB: /data/archive.sqlite DURIIN_DB: /data/archive.sqlite
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_REPLAY_POLL_MS: "${AUTONOMY_REPLAY_POLL_MS:-15000}" AUTONOMY_REPLAY_POLL_MS: "${AUTONOMY_REPLAY_POLL_MS:-15000}"
AUTONOMY_REPLAY_DAILY_BUDGET: "${AUTONOMY_REPLAY_DAILY_BUDGET:-100}" AUTONOMY_REPLAY_DAILY_BUDGET: "${AUTONOMY_REPLAY_DAILY_BUDGET:-100}"
@@ -206,8 +264,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_CALIBRATION_POLL_MS: "${AUTONOMY_CALIBRATION_POLL_MS:-60000}" AUTONOMY_CALIBRATION_POLL_MS: "${AUTONOMY_CALIBRATION_POLL_MS:-60000}"
depends_on: depends_on:
@@ -230,8 +288,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
INTELLIGENCE_DB: /data/intelligence.sqlite INTELLIGENCE_DB: /data/intelligence.sqlite
DURIIN_DB_BACKEND: postgres DURIIN_DB_BACKEND: "${DURIIN_DB_BACKEND:-sqlite}"
DURIIN_USE_POSTGRES: "true" DURIIN_USE_POSTGRES: "${DURIIN_USE_POSTGRES:-false}"
DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}" DURIIN_POSTGRES_URL: "postgresql://${POSTGRES_USER:-duriin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-duriin}"
AUTONOMY_EXECUTION_MODE: "${AUTONOMY_EXECUTION_MODE:-shadow}" AUTONOMY_EXECUTION_MODE: "${AUTONOMY_EXECUTION_MODE:-shadow}"
AUTONOMY_DEFAULT_NOTIONAL: "${AUTONOMY_DEFAULT_NOTIONAL:-100}" AUTONOMY_DEFAULT_NOTIONAL: "${AUTONOMY_DEFAULT_NOTIONAL:-100}"
+1
View File
@@ -53,6 +53,7 @@
.decision-chip { justify-self: end; padding: 5px 8px; color: var(--warning); background: rgba(243,201,105,.06); border: 1px solid rgba(243,201,105,.16); border-radius: 3px; font-family: var(--mono); font-size: 9px; font-weight: 750; } .decision-chip { justify-self: end; padding: 5px 8px; color: var(--warning); background: rgba(243,201,105,.06); border: 1px solid rgba(243,201,105,.16); border-radius: 3px; font-family: var(--mono); font-size: 9px; font-weight: 750; }
.decision-chip.buy { color: var(--positive); border-color: rgba(142,230,168,.18); background: rgba(142,230,168,.06); } .decision-chip.buy { color: var(--positive); border-color: rgba(142,230,168,.18); background: rgba(142,230,168,.06); }
.decision-chip.sell { color: var(--negative); border-color: rgba(255,141,125,.18); background: rgba(255,141,125,.06); } .decision-chip.sell { color: var(--negative); border-color: rgba(255,141,125,.18); background: rgba(255,141,125,.06); }
.decision-chip.qualifies { color: var(--positive); border-color: rgba(142,230,168,.18); background: rgba(142,230,168,.06); }
.performance-panel { padding-bottom: 18px; } .performance-panel { padding-bottom: 18px; }
.accuracy-orbit { --accuracy: 0deg; width: 166px; height: 166px; margin: 28px auto 24px; padding: 1px; display: grid; place-items: center; border-radius: 50%; background: conic-gradient(var(--accent) var(--accuracy), #252b24 0); } .accuracy-orbit { --accuracy: 0deg; width: 166px; height: 166px; margin: 28px auto 24px; padding: 1px; display: grid; place-items: center; border-radius: 50%; background: conic-gradient(var(--accent) var(--accuracy), #252b24 0); }
+82 -2
View File
@@ -35,11 +35,80 @@
</tr>`).join(""); </tr>`).join("");
} }
const ORIGIN_LABELS = {
live: "Live",
historical: "Historical backfill",
replay: "Walk-forward replay",
};
// live, historical and replay are deliberately never blended. only the live
// row is an edge claim, the other two are how the model was taught.
function renderOriginSplit(byOrigin, livePredictions) {
const host = byId("origin-split");
if (!host) return;
const rows = (byOrigin || []).filter(row => row.origin !== "live");
if (!rows.length) {
host.innerHTML = "";
return;
}
host.innerHTML = rows.map(row => {
const total = Number(row.total || 0);
const label = ORIGIN_LABELS[row.origin] || row.origin;
const accuracy = total ? formatPercent(Number(row.correct || 0) / total) : "—";
return `<div><span>${escapeHtml(label)}</span><strong>${formatNumber(total)} measured · ${accuracy}</strong></div>`;
}).join("");
byId("origin-note").textContent = livePredictions
? "Historical backfill and walk-forward replay are listed separately. Neither counts toward live edge."
: "No live predictions exist yet, so the numbers above are training and replay only — not evidence of live edge.";
}
function cohortCell(check, format) {
if (!check || !check.known) return '<td class="mono muted">unknown</td>';
return `<td class="mono ${check.ok ? "positive" : "negative"}">${format(check.value)} / ${format(check.threshold)}</td>`;
}
function renderCohorts(rows) {
const host = byId("cohort-list");
if (!host) return;
if (!rows?.length) {
host.innerHTML = '<tr><td colspan="6" class="empty-state">No calibration snapshots yet.</td></tr>';
return;
}
host.innerHTML = rows.map(row => {
const checks = row.qualification?.checks || {};
const qualified = Boolean(row.qualification?.qualified);
const reasons = row.qualification?.reasons || [];
const status = qualified
? '<span class="decision-chip qualifies">QUALIFIES</span>'
: `<span class="decision-chip abstain">ABSTAIN</span><div class="hypothesis-channel">${escapeHtml(reasons.join(" · ") || "does not qualify")}</div>`;
const key = row.legacy_cohort_key
? `${escapeHtml(row.cohort_key || "—")}<div class="hypothesis-channel">legacy key, not comparable to current cohorts</div>`
: escapeHtml(row.cohort_key || "—");
return `<tr>
<td class="mono">${key}</td>
<td class="muted">${escapeHtml(row.source || "unknown")}</td>
${cohortCell(checks.sample_size, value => formatNumber(value))}
${cohortCell(checks.distinct_instruments, value => formatNumber(value))}
${cohortCell(checks.top_instrument_share, value => formatPercent(value, 0))}
<td>${status}</td>
</tr>`;
}).join("");
}
function render(data) { function render(data) {
if (!data.enabled) throw new Error(data.reason || "Autonomy is unavailable"); if (!data.enabled) throw new Error(data.reason || "Autonomy is unavailable");
const mode = String(data.mode || "shadow").toUpperCase(); const mode = String(data.mode || "shadow").toUpperCase();
const open = count(data.predictionCounts, "status", "open"); const open = count(data.predictionCounts, "status", "open");
const resolved = count(data.predictionCounts, "status", "resolved"); const resolved = count(data.predictionCounts, "status", "resolved");
const byOrigin = data.outcomesByOrigin || [];
const livePredictions = (data.predictionsByOrigin || []).filter(row => row.origin === "live")
.reduce((total, row) => total + Number(row.count || 0), 0);
const outcomes = Number(data.outcomes?.total || 0); const outcomes = Number(data.outcomes?.total || 0);
const correct = Number(data.outcomes?.correct || 0); const correct = Number(data.outcomes?.correct || 0);
const accuracy = outcomes ? correct / outcomes : null; const accuracy = outcomes ? correct / outcomes : null;
@@ -61,7 +130,9 @@
byId("metric-open").textContent = formatNumber(open); byId("metric-open").textContent = formatNumber(open);
byId("metric-resolved").textContent = `${formatNumber(resolved)} resolved`; byId("metric-resolved").textContent = `${formatNumber(resolved)} resolved`;
byId("metric-accuracy").textContent = formatPercent(accuracy); byId("metric-accuracy").textContent = formatPercent(accuracy);
byId("metric-sample").textContent = outcomes ? `${formatNumber(outcomes)} measured outcomes` : "Waiting for outcomes"; byId("metric-sample").textContent = outcomes
? `${formatNumber(outcomes)} measured live outcomes`
: (livePredictions ? "Live predictions have not matured yet" : "No live predictions yet");
byId("metric-alpha").textContent = formatPercent(data.outcomes?.average_excess_return, 2); byId("metric-alpha").textContent = formatPercent(data.outcomes?.average_excess_return, 2);
byId("metric-universe").textContent = formatNumber(data.allowlistedInstruments, true); byId("metric-universe").textContent = formatNumber(data.allowlistedInstruments, true);
@@ -77,7 +148,16 @@
byId("perf-resolved").textContent = formatNumber(outcomes); byId("perf-resolved").textContent = formatNumber(outcomes);
byId("perf-correct").textContent = formatNumber(correct); byId("perf-correct").textContent = formatNumber(correct);
byId("perf-cohorts").textContent = formatNumber(data.calibration?.length || 0); byId("perf-cohorts").textContent = formatNumber(data.calibration?.length || 0);
if (outcomes) byId("performance-note").textContent = `Measured on ${outcomes} matured predictions. Results remain descriptive until the sample is large enough for stable calibration.`; if (outcomes) {
byId("performance-note").textContent = `Measured on ${outcomes} matured live predictions. Results remain descriptive until the sample is large enough for stable calibration.`;
} else if (livePredictions) {
byId("performance-note").textContent = `No live prediction has matured yet — ${formatNumber(livePredictions)} are still open. Nothing here is a live track record.`;
} else {
byId("performance-note").textContent = "No live predictions yet. Everything measured so far is historical backfill or replay, which is training, not a live track record.";
}
renderOriginSplit(byOrigin, livePredictions);
renderCohorts(data.calibration);
const replay = data.replay; const replay = data.replay;
byId("replay-status").textContent = replay ? replay.status : "Not started"; byId("replay-status").textContent = replay ? replay.status : "Not started";
+14 -2
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-3"> <link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-3">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-3"> <link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-3">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-3"> <link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-3">
<link rel="stylesheet" href="/admin/assets/css/autonomy.css?v=20260804-3"> <link rel="stylesheet" href="/admin/assets/css/autonomy.css?v=20260829-1">
</head> </head>
<body class="page-autonomy"> <body class="page-autonomy">
@@ -98,6 +98,8 @@
<div><span>Calibration cohorts</span><strong id="perf-cohorts">0</strong></div> <div><span>Calibration cohorts</span><strong id="perf-cohorts">0</strong></div>
</div> </div>
<p class="performance-note" id="performance-note">Duriin will only claim an edge after predictions mature and are measured out of sample.</p> <p class="performance-note" id="performance-note">Duriin will only claim an edge after predictions mature and are measured out of sample.</p>
<div class="performance-facts" id="origin-split"></div>
<p class="performance-note" id="origin-note">Historical backfill and walk-forward replay are shown separately. Neither is evidence of live edge.</p>
</aside> </aside>
</div> </div>
@@ -138,10 +140,20 @@
<div><span>Watermark</span><strong id="replay-watermark">—</strong></div> <div><span>Watermark</span><strong id="replay-watermark">—</strong></div>
</div> </div>
</section> </section>
<section class="panel" aria-label="Calibration cohorts">
<div class="section-head"><div><span class="section-index">07</span><h3>Calibration cohorts</h3></div><span class="mode-pill">Needs 30 samples · 5 tickers · max 50% in one</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>Cohort</th><th>Source</th><th>Samples</th><th>Distinct tickers</th><th>Top ticker share</th><th>Status</th></tr></thead>
<tbody id="cohort-list"><tr><td colspan="6" class="empty-state">No calibration snapshots yet.</td></tr></tbody>
</table>
</div>
</section>
</main> </main>
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div> <div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-3"></script> <script src="/admin/assets/js/app.js?v=20260804-3"></script>
<script src="/admin/assets/js/autonomy.js?v=20260804-3"></script> <script src="/admin/assets/js/autonomy.js?v=20260829-1"></script>
</body> </body>
</html> </html>
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
/*
* Repairs autonomy prediction provenance labels.
*
* The coordinator worker never handed an origin down to acceptProposal(), and
* acceptProposal defaults metadata.origin to "live". Every prediction produced
* by a historical-lane coordinator job therefore landed in the table wearing an
* origin of "live" even though it was a backfill over an old information cutoff.
*
* Canonical origins after this runs:
* live - genuine real time work
* historical - coordinator historical lane backfill
* replay - walk forward replay
* and learning_eligible may only be 1 when origin is "live".
*
* The discriminator is how old the information cutoff is relative to the moment
* the row was written. A genuinely live prediction reasons about right now, so
* the gap is milliseconds. A backfill reasons about 2024 while being written in
* 2026, so the gap is months.
*
* Defaults to a dry run. Pass --apply to actually write. This touches
* production data so the flag is deliberately not optional.
*/
const path = require("path");
const { openRuntimeDb, isPostgresEnabled } = require("../src/db/runtime");
// A live prediction stamps its cutoff with new Date().toISOString() microseconds
// before the insert, so its gap is effectively zero. A backfill sits months
// behind. Anything in between does not exist in practice, which is why the exact
// threshold is not delicate - 24h just has to be far enough above clock skew,
// queue latency and a midnight rollover that a real live row can never trip it.
// A naive same-calendar-day comparison would misfile a row created at 00:00:00
// whose cutoff was stamped at 23:59:59 the night before, and that mistake is
// silent and unrecoverable once written.
const HISTORICAL_MIN_AGE_MS = 24 * 60 * 60 * 1000;
const LIVE_FILTER = "origin = 'live'";
const ELIGIBILITY_PREDICATE = "origin != 'live' AND learning_eligible != 0";
// postgres has a hard cap on bound parameters and huge IN lists are miserable to
// debug, so the id updates go out in bites.
const UPDATE_CHUNK = 100;
function parseArgs(argv) {
const flags = new Set(argv.slice(2));
if (flags.has("--help") || flags.has("-h")) {
console.log("usage: node scripts/repair-autonomy-labels.js [--dry-run|--apply]");
console.log(" --dry-run report what would change and write nothing (default)");
console.log(" --apply run the repair inside a transaction");
process.exit(0);
}
const apply = flags.has("--apply");
if (apply && flags.has("--dry-run")) {
console.error("[repair] --apply and --dry-run are mutually exclusive");
process.exit(2);
}
return { apply };
}
// Timestamps live in TEXT columns and arrive in two shapes: the ISO strings the
// coordinator writes, and sqlite's datetime('now') output which is UTC with a
// space and no zone marker. Handing the second one to new Date() unqualified
// makes node read it as local time, so we pin it to UTC ourselves.
function parseTimestamp(value) {
if (value === null || value === undefined) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
let text = String(value).trim();
if (!text) return null;
text = text.replace(" ", "T");
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) text += "T00:00:00";
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) text += "Z";
const parsed = new Date(text);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function count(db, where) {
const sql = `SELECT COUNT(*) AS count FROM autonomy_predictions${where ? ` WHERE ${where}` : ""}`;
return Number(db.prepare(sql).get().count || 0);
}
function allPredictionIds(db) {
// ids are BIGINT on the postgres side and come back as strings, so everything
// gets normalised to strings before it goes anywhere near a Set.
return new Set(db.prepare("SELECT id FROM autonomy_predictions").all().map((row) => String(row.id)));
}
// The date arithmetic happens in javascript rather than SQL. src/db/runtime.js
// rewrites date/datetime expressions on its way to postgres, and an interval
// comparison that survives both dialects untouched is not worth the risk on a
// script that edits production provenance.
function findHistoricalCandidates(db) {
const rows = db.prepare(`
SELECT id, information_cutoff, created_at
FROM autonomy_predictions
WHERE ${LIVE_FILTER}
`).all();
const ids = [];
const unparseable = [];
for (const row of rows) {
const cutoff = parseTimestamp(row.information_cutoff);
const created = parseTimestamp(row.created_at);
if (!cutoff || !created) {
unparseable.push({ id: String(row.id), informationCutoff: row.information_cutoff, createdAt: row.created_at });
continue;
}
if (created.getTime() - cutoff.getTime() > HISTORICAL_MIN_AGE_MS) ids.push(row.id);
}
return { ids, unparseable, scanned: rows.length };
}
function chunk(list, size) {
const out = [];
for (let index = 0; index < list.length; index += size) out.push(list.slice(index, index + size));
return out;
}
function snapshot(db) {
const byOrigin = db.prepare(`
SELECT origin, COUNT(*) AS count
FROM autonomy_predictions
GROUP BY origin
ORDER BY origin
`).all().map((row) => ({ origin: row.origin, count: Number(row.count || 0) }));
const byEligibility = db.prepare(`
SELECT learning_eligible, COUNT(*) AS count
FROM autonomy_predictions
GROUP BY learning_eligible
ORDER BY learning_eligible
`).all().map((row) => ({ learningEligible: Number(row.learning_eligible || 0), count: Number(row.count || 0) }));
return { total: count(db, null), byOrigin, byEligibility };
}
function printSnapshot(label, snap) {
console.log(`[repair] ${label} total rows: ${snap.total}`);
for (const row of snap.byOrigin) console.log(`[repair] ${label} origin=${row.origin}: ${row.count}`);
for (const row of snap.byEligibility) console.log(`[repair] ${label} learning_eligible=${row.learningEligible}: ${row.count}`);
}
// A repair script has no business creating schema, so instead of calling
// initAutonomySchema we just check the columns we are about to touch are there.
function assertColumns(db) {
const columns = db.prepare("PRAGMA table_info(autonomy_predictions)").all().map((row) => String(row.name));
const missing = ["id", "origin", "learning_eligible", "information_cutoff", "created_at"].filter((name) => !columns.includes(name));
if (missing.length) throw new Error(`autonomy_predictions is missing required columns: ${missing.join(", ")}`);
}
function reportUnparseable(unparseable) {
if (!unparseable.length) return;
console.error(`[repair] WARNING: ${unparseable.length} live rows have timestamps that could not be parsed and were left untouched`);
for (const row of unparseable.slice(0, 10)) {
console.error(`[repair] id=${row.id} information_cutoff=${JSON.stringify(row.informationCutoff)} created_at=${JSON.stringify(row.createdAt)}`);
}
if (unparseable.length > 10) console.error(`[repair] ... and ${unparseable.length - 10} more`);
}
let db = null;
function main() {
const { apply } = parseArgs(process.argv);
const intelligencePath = process.env.INTELLIGENCE_DB || path.resolve(process.cwd(), "intelligence.sqlite");
// readonly on a dry run means sqlite physically cannot be written to, and the
// postgres path ignores the flag entirely.
db = openRuntimeDb(intelligencePath, { schema: "intelligence", readonly: !apply });
console.log(`[repair] backend: ${isPostgresEnabled() ? "postgres" : `sqlite (${intelligencePath})`}`);
console.log(`[repair] mode: ${apply ? "APPLY (writes)" : "dry-run (no writes)"}`);
console.log(`[repair] historical threshold: created_at - information_cutoff > ${HISTORICAL_MIN_AGE_MS}ms (24h)`);
assertColumns(db);
const before = snapshot(db);
printSnapshot("before", before);
const candidates = findHistoricalCandidates(db);
const eligibilityCandidates = count(db, ELIGIBILITY_PREDICATE);
console.log(`[repair] live rows scanned: ${candidates.scanned}`);
console.log(`[repair] live rows older than the threshold (would become historical): ${candidates.ids.length}`);
console.log(`[repair] rows with a non-live origin but learning_eligible != 0: ${eligibilityCandidates}`);
reportUnparseable(candidates.unparseable);
if (!apply) {
console.log("[repair] dry run finished, nothing was written. re-run with --apply to commit.");
console.log(JSON.stringify({
mode: "dry-run",
total: before.total,
liveScanned: candidates.scanned,
wouldRelabel: candidates.ids.length,
wouldClearEligibility: eligibilityCandidates,
unparseableTimestamps: candidates.unparseable.length,
}));
return;
}
// The no-loss guarantee is an identity check, not a headcount. Workers are
// live and inserting while this runs, so a bigger table afterwards is normal;
// a row that was here before and is gone now is not, and neither is an equal
// sized DELETE+INSERT, which a plain total would happily wave through.
const beforeIds = allPredictionIds(db);
console.log(`[repair] tracking ${beforeIds.size} existing prediction ids through the transaction`);
const summary = { relabelled: 0, eligibilityCleared: 0, newRowsDuringRun: 0 };
const tx = db.transaction(() => {
// Recomputed inside the transaction so we act on a consistent read rather
// than on whatever the table looked like a few seconds ago.
const fresh = findHistoricalCandidates(db);
reportUnparseable(fresh.unparseable);
for (const ids of chunk(fresh.ids, UPDATE_CHUNK)) {
const placeholders = ids.map(() => "?").join(", ");
const result = db.prepare(`
UPDATE autonomy_predictions
SET origin = 'historical', learning_eligible = 0
WHERE id IN (${placeholders})
`).run(...ids);
summary.relabelled += Number(result.changes || 0);
}
// Second pass catches replay rows (and anything else non-live) that somehow
// carry an eligibility flag. We never set learning_eligible back to 1 here:
// the contract makes live a necessary condition, not a sufficent one, and
// the original write-time decision is not ours to reinvent.
const eligibility = db.prepare(`
UPDATE autonomy_predictions
SET learning_eligible = 0
WHERE ${ELIGIBILITY_PREDICATE}
`).run();
summary.eligibilityCleared = Number(eligibility.changes || 0);
const afterIds = allPredictionIds(db);
const missing = [...beforeIds].filter((id) => !afterIds.has(id));
if (missing.length) {
throw new Error(`${missing.length} prediction ids vanished during repair (first few: ${missing.slice(0, 5).join(", ")}), rolling back`);
}
summary.newRowsDuringRun = [...afterIds].filter((id) => !beforeIds.has(id)).length;
const stillBroken = findHistoricalCandidates(db).ids.length + count(db, ELIGIBILITY_PREDICATE);
if (stillBroken !== 0) {
throw new Error(`repair did not converge, ${stillBroken} rows still need repairing, rolling back`);
}
return snapshot(db);
});
let after;
try {
after = tx();
} catch (error) {
console.error("[repair] transaction rolled back:", error && error.stack ? error.stack : error);
throw error;
}
printSnapshot("after", after);
console.log(`[repair] all ${beforeIds.size} pre-existing prediction ids still present, no rows lost`);
if (summary.newRowsDuringRun) {
console.log(`[repair] note: ${summary.newRowsDuringRun} new rows were inserted by other workers while this ran (informational, not an error)`);
}
console.log(JSON.stringify({
mode: "apply",
totalBefore: before.total,
totalAfter: after.total,
idsPreserved: beforeIds.size,
relabelledToHistorical: summary.relabelled,
eligibilityCleared: summary.eligibilityCleared,
newRowsDuringRun: summary.newRowsDuringRun,
}));
}
// PgCompatDb has no close() and its pool keeps the event loop alive, hence the
// typeof guard plus the hard exit at the bottom.
function closeQuietly() {
if (db && typeof db.close === "function") {
try { db.close(); } catch (closeError) { console.error("[repair] close failed:", closeError && closeError.stack ? closeError.stack : closeError); }
}
}
try {
main();
} catch (error) {
console.error("[repair] fatal:", error && error.stack ? error.stack : error);
closeQuietly();
process.exit(1);
}
closeQuietly();
process.exit(0);
+92 -1
View File
@@ -14,10 +14,77 @@ function quantile(values, q) {
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
} }
// The coordinator emits event_type as free text, so production ended up with 200+
// distinct values across ~600 predictions. Keying calibration on the raw string
// gave cohorts of ~2.7 samples each, which can never clear any honest sample gate.
// These families are a closed set: order matters, first match wins, and anything
// we don't recognise lands in `other` rather than inventing its own cohort.
const EVENT_FAMILIES = [
['analyst_action', /\b(analysts?|upgrades?|downgrades?|price[_ ]?targets?|ratings?|initiations?|coverage|overweight|underweight|outperform)\b/],
['guidance', /\b(guidance|outlooks?|forecasts?|pre[_ ]?announce\w*|warns?|warning|raises?[_ ]guid\w*|cuts?[_ ]guid\w*|projections?)\b/],
['earnings', /\b(earnings?|results?|quarterly|eps|revenues?|margins?|beat|miss(ed|es)?|q[1-4]|fy\d{2,4}|financials?)\b/],
['m_and_a', /\b(m&a|merger|mergers|acquisitions?|acquires?|acquired|takeovers?|buyouts?|divestitures?|divests?|spin[_ ]?offs?|stake[_ ]sales?|tender[_ ]offers?)\b/],
['legal', /\b(lawsuits?|litigations?|courts?|patents?|settlements?|verdicts?|injunctions?|class[_ ]actions?|subpoenas?|infringements?|appeals?)\b/],
['regulatory', /\b(regulat\w*|antitrust|probes?|investigations?|sanctions?|export[_ ]controls?|tariffs?|bans?|banned|approvals?|approved|licens\w*|compliance|fda|ftc|doj|sec[_ ]filing|policy)\b/],
['leadership', /\b(ceo|cfo|coo|cto|chairman|executives?|resign\w*|appoint\w*|steps?[_ ]down|boards?|successions?|layoffs?|restructur\w*|hiring|departures?)\b/],
['supply_chain', /\b(supply|suppliers?|shortages?|capacity|production|fabs?|foundry|inventor\w+|logistics?|shipments?|recalls?|manufactur\w*|yields?|backlog)\b/],
['contract', /\b(contracts?|orders?|partnerships?|partners?|agreements?|collaborations?|deals?|customers?|wins?|awards?)\b/],
['product', /\b(products?|launch\w*|unveil\w*|releases?|announcements?|chips?|models?|features?|roadmaps?|platforms?)\b/],
['capital', /\b(buybacks?|repurchases?|dividends?|offerings?|debt|capital[_ ]raise|stock[_ ]splits?|ipos?|financing|bonds?)\b/],
['security_incident', /\b(hacks?|hacked|breach\w*|cyber\w*|ransomware|outages?|downtime|vulnerabilit\w+|exploits?)\b/],
['macro', /\b(macro\w*|fed|federal[_ ]reserve|interest[_ ]rates?|inflation|gdp|econom\w+|recession|currenc\w+|geopolit\w+|war|elections?|demand)\b/],
];
const EVENT_FAMILY_NAMES = EVENT_FAMILIES.map(([name]) => name).concat('other');
// snake_case, camelCase, "Supply Constraint" and "supply-constraint" all have to
// collapse onto the same token stream before we try to match anything.
function normalizeEventType(raw) {
if (raw === null || raw === undefined) return 'other';
const text = String(raw)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
.replace(/[^a-z0-9&]+/g, ' ')
.trim();
if (!text) return 'other';
for (const [family, pattern] of EVENT_FAMILIES) {
if (pattern.test(text)) return family;
}
return 'other';
}
// ALLOWED_HORIZONS is 1/5/10/20/30/60/90 in the coordinator. Seven horizons times
// two directions was another multiplier on the cohort explosion, and a 10 day and
// a 20 day call on the same event are not really different populations.
const HORIZON_BUCKETS = ['short', 'medium', 'long'];
function horizonBucket(horizonDays) {
const days = Number(horizonDays);
if (!Number.isFinite(days) || days <= 0) return 'unknown';
if (days <= 5) return 'short';
if (days <= 20) return 'medium';
return 'long';
}
const COHORT_KEY_VERSION = 'v2';
function cohortKey({ direction, eventType, horizonDays, sector = 'unknown' }) { function cohortKey({ direction, eventType, horizonDays, sector = 'unknown' }) {
return [COHORT_KEY_VERSION, sector, normalizeEventType(eventType), horizonBucket(horizonDays), direction].join('|');
}
// Snapshots written before the taxonomy change still carry the raw key, this keeps
// them readable/joinable without a migration.
function legacyCohortKey({ direction, eventType, horizonDays, sector = 'unknown' }) {
return [sector, eventType || 'unknown', horizonDays, direction].join('|'); return [sector, eventType || 'unknown', horizonDays, direction].join('|');
} }
function instrumentOf(row) {
const symbol = row.instrument ?? row.symbol ?? null;
if (symbol === null || symbol === undefined) return null;
const trimmed = String(symbol).trim().toUpperCase();
return trimmed || null;
}
function calibrateOutcomes(rows, parent = null) { function calibrateOutcomes(rows, parent = null) {
const clean = rows.filter((row) => Number.isFinite(Number(row.excess_return))); const clean = rows.filter((row) => Number.isFinite(Number(row.excess_return)));
const wins = clean.filter((row) => Number(row.direction_correct) === 1).length; const wins = clean.filter((row) => Number(row.direction_correct) === 1).length;
@@ -26,6 +93,17 @@ function calibrateOutcomes(rows, parent = null) {
const priorStrength = parent ? Math.max(2, Math.min(20, parent.effectiveSampleSize / 10)) : 2; const priorStrength = parent ? Math.max(2, Math.min(20, parent.effectiveSampleSize / 10)) : 2;
const probability = (wins + priorProbability * priorStrength) / (total + priorStrength); const probability = (wins + priorProbability * priorStrength) / (total + priorStrength);
const returns = clean.map((row) => Number(row.excess_return)); const returns = clean.map((row) => Number(row.excess_return));
// Concentration matters as much as raw n here. A cohort of 300 outcomes that is
// 95% one ticker is one bet repeated, not 300 independant observations.
const counts = new Map();
for (const row of clean) {
const symbol = instrumentOf(row);
if (!symbol) continue;
counts.set(symbol, (counts.get(symbol) || 0) + 1);
}
const topCount = counts.size ? Math.max(...counts.values()) : 0;
return { return {
sampleSize: total, sampleSize: total,
effectiveSampleSize: total + priorStrength, effectiveSampleSize: total + priorStrength,
@@ -33,7 +111,20 @@ function calibrateOutcomes(rows, parent = null) {
expectedExcessReturn: returns.length ? returns.reduce((sum, value) => sum + value, 0) / returns.length : null, expectedExcessReturn: returns.length ? returns.reduce((sum, value) => sum + value, 0) / returns.length : null,
lowerReturn: quantile(returns, 0.1), lowerReturn: quantile(returns, 0.1),
upperReturn: quantile(returns, 0.9), upperReturn: quantile(returns, 0.9),
distinctInstruments: counts.size,
topInstrumentShare: total ? topCount / total : null,
}; };
} }
module.exports = { betaMean, cohortKey, calibrateOutcomes, quantile }; module.exports = {
betaMean,
cohortKey,
legacyCohortKey,
calibrateOutcomes,
quantile,
normalizeEventType,
horizonBucket,
EVENT_FAMILY_NAMES,
HORIZON_BUCKETS,
COHORT_KEY_VERSION,
};
+11 -7
View File
@@ -36,18 +36,22 @@ function normalizeProposal(raw, { informationCutoff, model = 'unknown', promptVe
function verifyEvidence(archiveDb, articleIds, informationCutoff = null) { function verifyEvidence(archiveDb, articleIds, informationCutoff = null) {
const placeholders = articleIds.map(() => '?').join(','); const placeholders = articleIds.map(() => '?').join(',');
// A replay must only see material which existed at its information cutoff. // No proposal, whatever lane produced it, may cite material which did not yet
// Live proposals retain the simpler existence check. // exist at its own information cutoff. This used to be a replay-only rule and
// that was a lookahead hole for every other origin.
const cutoffClause = informationCutoff ? ' AND datetime(COALESCE(pub_date_effective, pub_date, ingested_at)) <= datetime(?)' : ''; const cutoffClause = informationCutoff ? ' AND datetime(COALESCE(pub_date_effective, pub_date, ingested_at)) <= datetime(?)' : '';
let rows; let rows;
try { try {
rows = archiveDb.prepare(`SELECT id FROM articles WHERE id IN (${placeholders})${cutoffClause}`) rows = archiveDb.prepare(`SELECT id FROM articles WHERE id IN (${placeholders})${cutoffClause}`)
.all(...articleIds, ...(informationCutoff ? [informationCutoff] : [])); .all(...articleIds, ...(informationCutoff ? [informationCutoff] : []));
} catch (error) { } catch (error) {
// Minimal/test archives may not retain publication metadata. A production // Minimal/test archives may not retain publication metadata at all, in which
// replay archive is required to have it, so this fallback is only for the // case the cutoff clause cannot even be prepared. We degrade to a plain
// existing live evidence contract. // existence check rather than blocking the pipeline, but the degredation is
if (informationCutoff) throw error; // never silent - a production archive missing these columns is a real bug.
console.warn('[coordinator] evidence cutoff check unavailable, falling back to existence only.',
`cutoff=${informationCutoff} articles=${JSON.stringify(articleIds)} reason=${error && error.message}`);
if (error && error.stack) console.warn(error.stack);
rows = archiveDb.prepare(`SELECT id FROM articles WHERE id IN (${placeholders})`).all(...articleIds); rows = archiveDb.prepare(`SELECT id FROM articles WHERE id IN (${placeholders})`).all(...articleIds);
} }
const found = new Set(rows.map((row) => row.id)); const found = new Set(rows.map((row) => row.id));
@@ -57,7 +61,7 @@ function verifyEvidence(archiveDb, articleIds, informationCutoff = null) {
function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) { function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) {
const proposal = normalizeProposal(raw, metadata); const proposal = normalizeProposal(raw, metadata);
for (const prediction of proposal.predictions) { for (const prediction of proposal.predictions) {
if (!verifyEvidence(archiveDb, prediction.evidenceArticleIds, metadata.origin === 'replay' ? proposal.informationCutoff : null)) { if (!verifyEvidence(archiveDb, prediction.evidenceArticleIds, proposal.informationCutoff)) {
throw new Error(`proposal references missing evidence for ${prediction.instrument}`); throw new Error(`proposal references missing evidence for ${prediction.instrument}`);
} }
const instrument = intelligenceDb.prepare( const instrument = intelligenceDb.prepare(
+57 -7
View File
@@ -1,15 +1,65 @@
function decide({ direction = 'positive', probability, expectedExcessReturn, lowerReturn, upperReturn, sampleSize }, rules = {}) { // Thresholds live here so the worker, the replay evaluator and the tests all
const minSampleSize = Number(rules.minSampleSize ?? 30); // argue from the same numbers instead of sprinkling magic 30s around.
const minProbability = Number(rules.minProbability ?? 0.58); const DEFAULT_POLICY_RULES = {
const minExpectedReturn = Number(rules.minExpectedReturn ?? 0.005); minSampleSize: 30,
const maxDownside = Number(rules.maxDownside ?? -0.08); // A cohort has to be built from more than a handful of tickers. In production
// one name (NVDA) accounted for roughly half of every resolved outcome, so a
// pure sample-size gate was measuring one company, not an edge.
minDistinctInstruments: 5,
maxInstrumentConcentration: 0.5,
minProbability: 0.58,
minExpectedReturn: 0.005,
maxDownside: -0.08,
};
function decide({
direction = 'positive',
probability,
expectedExcessReturn,
lowerReturn,
upperReturn,
sampleSize,
distinctInstruments,
topInstrumentShare,
}, rules = {}) {
const minSampleSize = Number(rules.minSampleSize ?? DEFAULT_POLICY_RULES.minSampleSize);
const minDistinctInstruments = Number(rules.minDistinctInstruments ?? DEFAULT_POLICY_RULES.minDistinctInstruments);
const maxInstrumentConcentration = Number(rules.maxInstrumentConcentration ?? DEFAULT_POLICY_RULES.maxInstrumentConcentration);
const minProbability = Number(rules.minProbability ?? DEFAULT_POLICY_RULES.minProbability);
const minExpectedReturn = Number(rules.minExpectedReturn ?? DEFAULT_POLICY_RULES.minExpectedReturn);
const maxDownside = Number(rules.maxDownside ?? DEFAULT_POLICY_RULES.maxDownside);
if (![probability, expectedExcessReturn].every(Number.isFinite)) { if (![probability, expectedExcessReturn].every(Number.isFinite)) {
return { action: 'ABSTAIN', rationale: 'calibration unavailable' }; return { action: 'ABSTAIN', rationale: 'calibration unavailable' };
} }
if (sampleSize < minSampleSize) { if (!Number.isFinite(Number(sampleSize)) || Number(sampleSize) < minSampleSize) {
return { action: 'ABSTAIN', rationale: `insufficient calibration sample (${sampleSize}/${minSampleSize})` }; return { action: 'ABSTAIN', rationale: `insufficient calibration sample (${sampleSize}/${minSampleSize})` };
} }
// Snapshots written before diversification was tracked come back with the count
// missing. Unknown diversity is not the same as adequate diversity, abstain.
const instruments = distinctInstruments === null || distinctInstruments === undefined ? NaN : Number(distinctInstruments);
if (!Number.isFinite(instruments)) {
return { action: 'ABSTAIN', rationale: 'cohort instrument diversity unknown' };
}
if (instruments < minDistinctInstruments) {
return { action: 'ABSTAIN', rationale: `insufficient cohort diversity (${instruments}/${minDistinctInstruments} instruments)` };
}
// Same rule as the count above: a missing share is unknown, not safe. Number(null)
// is 0, which would sail straight through the cap, so check for absence first.
const concentration = topInstrumentShare === null || topInstrumentShare === undefined
? NaN
: Number(topInstrumentShare);
if (!Number.isFinite(concentration)) {
return { action: 'ABSTAIN', rationale: 'cohort instrument concentration unknown' };
}
if (concentration > maxInstrumentConcentration) {
return {
action: 'ABSTAIN',
rationale: `cohort dominated by a single instrument (${(concentration * 100).toFixed(0)}% > ${(maxInstrumentConcentration * 100).toFixed(0)}%)`,
};
}
const signedExpectedReturn = direction === 'negative' ? -expectedExcessReturn : expectedExcessReturn; const signedExpectedReturn = direction === 'negative' ? -expectedExcessReturn : expectedExcessReturn;
const signedLowerReturn = direction === 'negative' const signedLowerReturn = direction === 'negative'
? (Number.isFinite(upperReturn) ? -upperReturn : null) ? (Number.isFinite(upperReturn) ? -upperReturn : null)
@@ -23,4 +73,4 @@ function decide({ direction = 'positive', probability, expectedExcessReturn, low
return { action: 'HOLD', rationale: 'calibrated edge does not clear policy thresholds' }; return { action: 'HOLD', rationale: 'calibrated edge does not clear policy thresholds' };
} }
module.exports = { decide }; module.exports = { decide, DEFAULT_POLICY_RULES };
+18 -1
View File
@@ -1,5 +1,12 @@
const AUTONOMY_SCHEMA_VERSION = 2; const AUTONOMY_SCHEMA_VERSION = 2;
// sqlite and postgres word this differently, and we re-run every ALTER on each
// boot, so a re-add is the expected case rather than a failure.
function isDuplicateColumn(error) {
const message = String(error && error.message || '').toLowerCase();
return message.includes('duplicate column') || message.includes('already exists');
}
function initAutonomySchema(db) { function initAutonomySchema(db) {
if (db.dialect === 'postgres') return; if (db.dialect === 'postgres') return;
db.exec(` db.exec(`
@@ -225,8 +232,18 @@ function initAutonomySchema(db) {
'ALTER TABLE autonomy_replay_runs ADD COLUMN cursor_effective_at TEXT', 'ALTER TABLE autonomy_replay_runs ADD COLUMN cursor_effective_at TEXT',
"ALTER TABLE autonomy_calibration_snapshots ADD COLUMN source TEXT NOT NULL DEFAULT 'live'", "ALTER TABLE autonomy_calibration_snapshots ADD COLUMN source TEXT NOT NULL DEFAULT 'live'",
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN replay_run_id INTEGER', 'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN replay_run_id INTEGER',
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN distinct_instruments INTEGER',
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN top_instrument_share REAL',
]) { ]) {
try { db.exec(statement); } catch (_) {} try {
db.exec(statement);
} catch (error) {
// Re-running these is normal, the column is already there. Anything else
// means a migration genuinely failed and we want to hear about it.
if (!isDuplicateColumn(error)) {
console.error(`[autonomy-schema] migration failed: ${statement}`, error.message, error.stack);
}
}
} }
} }
+7 -1
View File
@@ -1,6 +1,7 @@
const db = require('./db'); const db = require('./db');
const { normalizeTitle } = require('./dedup'); const { normalizeTitle } = require('./dedup');
const { markSourceRun } = require('./state'); const { markSourceRun } = require('./state');
const { guardEffectivePubDate } = require('./pubDateGuard');
const sourcesById = Object.fromEntries( const sourcesById = Object.fromEntries(
require('../sources.json').map((s) => [s.id, s]) require('../sources.json').map((s) => [s.id, s])
@@ -88,6 +89,11 @@ function ingestArticle(article) {
const ingestedAt = new Date().toISOString(); const ingestedAt = new Date().toISOString();
const language = (sourcesById[source] && sourcesById[source].language) || null; const language = (sourcesById[source] && sourcesById[source].language) || null;
// pub_date keeps whatever the source claimed (it is still useful for
// debugging a broken feed), but the effective date — the one the coordinator
// turns into an information cutoff — refuses anything from the future.
const effectivePubDate = guardEffectivePubDate(pubDate, ingestedAt, { source, url });
try { try {
const result = insertArticle.run( const result = insertArticle.run(
title, title,
@@ -98,7 +104,7 @@ function ingestArticle(article) {
source, source,
pubDate, pubDate,
ingestedAt, ingestedAt,
pubDate || ingestedAt, effectivePubDate,
language language
); );
+61
View File
@@ -0,0 +1,61 @@
// Guard against publication dates that sit in the future.
//
// pub_date_effective is what the autonomy coordinator uses to derive a
// prediction's information_cutoff (max pub_date_effective across an event's
// articles), so a single bogus feed date drags the cutoff forward and quietly
// breaks evidence-cutoff enforcement and outcome scoring. Production currently
// has exactly one such row, but one is enough to poison an event.
//
// Tolerance: 48 hours. It has to swallow the legitimate cases —
// * date only strings ("2026-08-29") are stored as midnight UTC, and a
// publisher in UTC+14 can legitimately stamp tomorrow's date,
// * feeds that emit local time without an offset, worst case ~14h ahead,
// * modest clock skew on the publisher's box.
// 48h covers all of that with room to spare while still catching anything
// genuinely wrong — the offending production row is about four months out.
const DEFAULT_TOLERANCE_MS = 48 * 60 * 60 * 1000;
function toleranceMs() {
const hours = Number(process.env.INGEST_FUTURE_PUB_DATE_HOURS);
if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000;
return DEFAULT_TOLERANCE_MS;
}
// Returns { ok, value, skewMs, toleranceMs }. `value` is null when the date is
// implausible so the caller can fall back to ingestion time. The article itself
// is never dropped for this — a bad date is not a bad article.
function checkPubDate(value, now = Date.now(), tolerance = toleranceMs()) {
if (!value) return { ok: true, value: null, skewMs: 0, toleranceMs: tolerance };
const parsed = new Date(value).getTime();
if (Number.isNaN(parsed)) return { ok: true, value: null, skewMs: 0, toleranceMs: tolerance };
const skewMs = parsed - now;
if (skewMs > tolerance) {
return { ok: false, value: null, skewMs, toleranceMs: tolerance };
}
return { ok: true, value, skewMs, toleranceMs: tolerance };
}
// Same check, but it also does the shouting. Keeps ingest.js readable and makes
// sure every clamp lands in the logs with the source and the offending value.
function guardEffectivePubDate(pubDate, fallback, context = {}) {
const verdict = checkPubDate(pubDate);
if (verdict.ok) return pubDate || fallback;
const days = (verdict.skewMs / 86400000).toFixed(1);
console.warn(
`[ingest] refusing future pub date from "${context.source || 'unknown source'}": ${pubDate} is ${days} days ahead ` +
`(tolerance ${Math.round(verdict.toleranceMs / 3600000)}h) — pub_date_effective falls back to ${fallback}. url=${context.url || 'n/a'}`
);
return fallback;
}
module.exports = {
checkPubDate,
guardEffectivePubDate,
DEFAULT_TOLERANCE_MS,
};
+243 -28
View File
@@ -9,11 +9,74 @@ const { openRuntimeDb, isPostgresEnabled } = require('../db/runtime');
const pg = require('../db/pgAsync'); const pg = require('../db/pgAsync');
let idb = null; let idb = null;
let adb = null;
let statsSummaryCache = null; let statsSummaryCache = null;
let statsDetailCache = null; let statsDetailCache = null;
const configDir = path.resolve(__dirname, '..', '..');
// The archive is resolved exactly like the workers do it (workers/index.js:31):
// DURIIN_DB wins, then config, and only then the repo relative default. The old
// code here went straight to config.database.path — a repo relative
// "./archive.sqlite" — which inside the container only ever pointed at the real
// data because of a build time symlink, and which quietly opens a brand new
// empty database when that symlink is not there.
function resolveArchivePath() {
const raw = process.env.DURIIN_DB
|| config.duriin_db
|| (config.database && config.database.path)
|| './archive.sqlite';
return path.isAbsolute(raw) ? raw : path.resolve(configDir, raw);
}
function resolveIntelligencePath() {
return process.env.INTELLIGENCE_DB
|| (config.intelligence_db
? (path.isAbsolute(config.intelligence_db) ? config.intelligence_db : path.resolve(configDir, config.intelligence_db))
: path.resolve(configDir, 'intelligence.sqlite'));
}
// Opens the archive and *proves* it is the archive before handing it back. Any
// failure throws with the resolved target in the message — serving the wrong
// database silently is far worse than an error on the sql console.
function getArchiveDb() {
if (adb) return adb;
const target = isPostgresEnabled() ? 'postgres schema "archive"' : resolveArchivePath();
try {
if (isPostgresEnabled()) {
const handle = openRuntimeDb(resolveArchivePath(), { schema: 'archive' });
const probe = handle.prepare("SELECT to_regclass('archive.articles') AS relation").get();
if (!probe || !probe.relation) throw new Error('the archive schema has no articles table');
adb = handle;
return adb;
}
const filePath = resolveArchivePath();
if (!fs.existsSync(filePath)) throw new Error('no such file');
const handle = new Database(filePath, { fileMustExist: true });
try {
const probe = handle.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='articles'").get();
if (!probe) throw new Error('this file has no articles table, so it is not the archive');
} catch (probeError) {
handle.close();
throw probeError;
}
adb = handle;
return adb;
} catch (error) {
// never cached — if the volume shows up later the next request recovers
console.error(`[admin] archive database unavailable (${target}):`, error);
throw new Error(`archive database unavailable (${target}): ${error.message}`);
}
}
function calculateArchiveStats() { function calculateArchiveStats() {
const databasePath = path.resolve(__dirname, '..', '..', config.database.path || './archive.sqlite'); const databasePath = resolveArchivePath();
const workerPath = path.resolve(__dirname, '..', 'adminStatsWorker.js'); const workerPath = path.resolve(__dirname, '..', 'adminStatsWorker.js');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = new Worker(workerPath, { workerData: { databasePath } }); const worker = new Worker(workerPath, { workerData: { databasePath } });
@@ -42,18 +105,134 @@ function calculateArchiveStats() {
function getIntelligenceDb() { function getIntelligenceDb() {
if (idb) return idb; if (idb) return idb;
const configDir = path.resolve(__dirname, '..', '..'); const rawPath = resolveIntelligencePath();
const rawPath = process.env.INTELLIGENCE_DB
|| (config.intelligence_db
? (path.isAbsolute(config.intelligence_db) ? config.intelligence_db : path.resolve(configDir, config.intelligence_db))
: path.resolve(configDir, 'intelligence.sqlite'));
if (!isPostgresEnabled() && !fs.existsSync(rawPath)) return null; if (!isPostgresEnabled() && !fs.existsSync(rawPath)) {
console.error(`[admin] intelligence database unavailable: no such file (${rawPath})`);
return null;
}
idb = isPostgresEnabled() ? openRuntimeDb(rawPath, { schema: 'intelligence' }) : new Database(rawPath); idb = isPostgresEnabled() ? openRuntimeDb(rawPath, { schema: 'intelligence' }) : new Database(rawPath);
return idb; return idb;
} }
// Prediction origins are not interchangeable. 'live' is genuine real time work,
// 'historical' is coordinator backfill over the archive and 'replay' is
// walk-forward replay. Averaging them into a single accuracy number reads like
// live edge when it is nothing of the sort, so the overview reports them side by
// side and lets the page say "nothing live yet" out loud.
const OUTCOME_ORIGINS = ['live', 'historical', 'replay'];
const OUTCOMES_BY_ORIGIN_SQL = `
SELECT p.origin AS origin,
COUNT(*) AS total,
SUM(o.direction_correct) AS correct,
AVG(o.excess_return) AS average_excess_return
FROM autonomy_outcomes o
JOIN autonomy_predictions p ON p.id = o.prediction_id
GROUP BY p.origin
`;
const PREDICTIONS_BY_ORIGIN_SQL = `
SELECT origin, status, COUNT(*) AS count
FROM autonomy_predictions
GROUP BY origin, status
`;
function summarizeOutcomeOrigins(rows) {
const buckets = new Map();
for (const name of OUTCOME_ORIGINS) {
buckets.set(name, { origin: name, total: 0, correct: 0, average_excess_return: null });
}
for (const row of rows || []) {
const origin = String(row.origin || 'unknown').toLowerCase();
if (!buckets.has(origin)) buckets.set(origin, { origin, total: 0, correct: 0, average_excess_return: null });
const bucket = buckets.get(origin);
bucket.total = Number(row.total || 0);
bucket.correct = Number(row.correct || 0);
bucket.average_excess_return = row.average_excess_return == null ? null : Number(row.average_excess_return);
}
const byOrigin = [...buckets.values()];
const live = byOrigin.find((bucket) => bucket.origin === 'live');
return { byOrigin, live };
}
// Diversification gate, mirrored from the policy layer. A cohort only earns the
// right to authorise a trade when it is big enough, spread over enough tickers
// and not dominated by a single one. Missing diversity data counts as a fail —
// the policy treats unknown as disqualifying and the admin view has to agree,
// otherwise the screen says "qualified" while the trader abstains.
const CALIBRATION_MIN_SAMPLES = 30;
const CALIBRATION_MIN_INSTRUMENTS = 5;
const CALIBRATION_MAX_CONCENTRATION = 0.5;
const CALIBRATION_BASE_COLUMNS = [
'cohort_key', 'sample_size', 'effective_sample_size', 'directional_probability',
'expected_excess_return', 'lower_return', 'upper_return', 'created_at',
];
// added by the diversification work; pre-existing rows/deployments may not have
// them yet so they are selected only when they really exist
const CALIBRATION_OPTIONAL_COLUMNS = ['distinct_instruments', 'top_instrument_share', 'source'];
function calibrationSnapshotSql(available) {
const columns = CALIBRATION_BASE_COLUMNS.slice();
for (const name of CALIBRATION_OPTIONAL_COLUMNS) {
columns.push(available.has(name) ? name : `NULL AS ${name}`);
}
return `SELECT ${columns.join(', ')} FROM autonomy_calibration_snapshots ORDER BY id DESC LIMIT 8`;
}
function gate(value, ok, threshold) {
return { value: value == null ? null : Number(value), threshold, ok, known: value != null };
}
function decorateCalibration(rows) {
return (rows || []).map((row) => {
const samples = row.sample_size == null ? null : Number(row.sample_size);
const instruments = row.distinct_instruments == null ? null : Number(row.distinct_instruments);
const share = row.top_instrument_share == null ? null : Number(row.top_instrument_share);
const checks = {
sample_size: gate(samples, samples != null && samples >= CALIBRATION_MIN_SAMPLES, CALIBRATION_MIN_SAMPLES),
distinct_instruments: gate(instruments, instruments != null && instruments >= CALIBRATION_MIN_INSTRUMENTS, CALIBRATION_MIN_INSTRUMENTS),
top_instrument_share: gate(share, share != null && share <= CALIBRATION_MAX_CONCENTRATION, CALIBRATION_MAX_CONCENTRATION),
};
const reasons = [];
if (!checks.sample_size.ok) {
reasons.push(samples == null ? 'sample size unknown' : `only ${samples} samples, needs ${CALIBRATION_MIN_SAMPLES}`);
}
if (!checks.distinct_instruments.ok) {
reasons.push(instruments == null ? 'instrument spread unknown' : `only ${instruments} distinct ticker${instruments === 1 ? '' : 's'}, needs ${CALIBRATION_MIN_INSTRUMENTS}`);
}
if (!checks.top_instrument_share.ok) {
reasons.push(share == null ? 'concentration unknown' : `${Math.round(share * 100)}% sits in one ticker, cap is ${Math.round(CALIBRATION_MAX_CONCENTRATION * 100)}%`);
}
// cohort keys are versioned now. legacy rows use the old key shape and will
// never match a current lookup, so they must not read as live calibration.
const legacy = !String(row.cohort_key || '').startsWith('v2|');
return {
...row,
source: row.source || null,
legacy_cohort_key: legacy,
qualification: { qualified: reasons.length === 0, checks, reasons },
};
});
}
function normalizeOriginCounts(rows) {
return (rows || []).map((row) => ({
origin: String(row.origin || 'unknown').toLowerCase(),
status: row.status,
count: Number(row.count || 0),
}));
}
const adminUser = (config.admin && config.admin.username) || 'admin'; const adminUser = (config.admin && config.admin.username) || 'admin';
const adminPass = (config.admin && config.admin.password) || 'changeme'; const adminPass = (config.admin && config.admin.password) || 'changeme';
@@ -156,12 +335,18 @@ async function adminRoutes(fastify) {
if (isPostgresEnabled()) { if (isPostgresEnabled()) {
const hasSchema = await pg.get('intelligence', "SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", ['intelligence', 'autonomy_jobs']); const hasSchema = await pg.get('intelligence', "SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", ['intelligence', 'autonomy_jobs']);
if (!hasSchema) return { enabled: false, reason: 'autonomy schema is not initialized' }; if (!hasSchema) return { enabled: false, reason: 'autonomy schema is not initialized' };
const [jobs, predictionCounts, decisionCounts, proposalCounts, outcomeSummary, instruments, latestRows, latestOrders, account, calibration, replay] = await Promise.all([
const calibrationColumns = new Set((await pg.all('intelligence',
'SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2',
['intelligence', 'autonomy_calibration_snapshots'])).map((row) => row.column_name));
const [jobs, predictionCounts, predictionOriginRows, decisionCounts, proposalCounts, outcomeRows, instruments, latestRows, latestOrders, account, calibration, replay] = await Promise.all([
pg.all('intelligence', 'SELECT lane, status, COUNT(*) AS count FROM autonomy_jobs GROUP BY lane, status ORDER BY lane, status'), pg.all('intelligence', 'SELECT lane, status, COUNT(*) AS count FROM autonomy_jobs GROUP BY lane, status ORDER BY lane, status'),
pg.all('intelligence', 'SELECT status, COUNT(*) AS count FROM autonomy_predictions GROUP BY status'), pg.all('intelligence', 'SELECT status, COUNT(*) AS count FROM autonomy_predictions GROUP BY status'),
pg.all('intelligence', PREDICTIONS_BY_ORIGIN_SQL),
pg.all('intelligence', 'SELECT action, COUNT(*) AS count FROM autonomy_decisions GROUP BY action'), pg.all('intelligence', 'SELECT action, COUNT(*) AS count FROM autonomy_decisions GROUP BY action'),
pg.all('intelligence', 'SELECT status, COUNT(*) AS count FROM autonomy_proposals GROUP BY status'), pg.all('intelligence', 'SELECT status, COUNT(*) AS count FROM autonomy_proposals GROUP BY status'),
pg.get('intelligence', "SELECT COUNT(*) AS total, SUM(direction_correct) AS correct, AVG(excess_return) AS average_excess_return FROM autonomy_outcomes o JOIN autonomy_predictions p ON p.id = o.prediction_id WHERE p.origin = 'live'"), pg.all('intelligence', OUTCOMES_BY_ORIGIN_SQL),
pg.get('intelligence', 'SELECT COUNT(*) AS count FROM autonomy_instruments WHERE active=1 AND tradable=1'), pg.get('intelligence', 'SELECT COUNT(*) AS count FROM autonomy_instruments WHERE active=1 AND tradable=1'),
pg.all('intelligence', ` pg.all('intelligence', `
SELECT p.id, p.instrument, p.direction, p.event_type, p.causal_channel, SELECT p.id, p.instrument, p.direction, p.event_type, p.causal_channel,
@@ -185,7 +370,7 @@ async function adminRoutes(fastify) {
ORDER BY oi.id DESC LIMIT 12 ORDER BY oi.id DESC LIMIT 12
`), `),
pg.get('intelligence', 'SELECT broker, equity, cash, buying_power, captured_at FROM autonomy_account_snapshots ORDER BY id DESC LIMIT 1'), pg.get('intelligence', 'SELECT broker, equity, cash, buying_power, captured_at FROM autonomy_account_snapshots ORDER BY id DESC LIMIT 1'),
pg.all('intelligence', 'SELECT cohort_key, sample_size, effective_sample_size, directional_probability, expected_excess_return, lower_return, upper_return, created_at FROM autonomy_calibration_snapshots ORDER BY id DESC LIMIT 8'), pg.all('intelligence', calibrationSnapshotSql(calibrationColumns)),
pg.get('intelligence', ` pg.get('intelligence', `
SELECT r.id, r.status, r.watermark_at, r.cursor_article_id, r.cursor_effective_at, SELECT r.id, r.status, r.watermark_at, r.cursor_article_id, r.cursor_effective_at,
r.processed_articles, r.updated_at, r.processed_articles, r.updated_at,
@@ -205,13 +390,18 @@ async function adminRoutes(fastify) {
const { evidence_article_ids: ignored, ...safeRow } = row; const { evidence_article_ids: ignored, ...safeRow } = row;
return { ...safeRow, evidence_count: evidenceCount }; return { ...safeRow, evidence_count: evidenceCount };
}); });
const origins = summarizeOutcomeOrigins(outcomeRows);
return { return {
enabled: true, enabled: true,
mode: process.env.AUTONOMY_EXECUTION_MODE || 'shadow', mode: process.env.AUTONOMY_EXECUTION_MODE || 'shadow',
broker: { name: 'Alpaca Paper', configured: Boolean(process.env.ALPACA_PAPER_KEY_ID && process.env.ALPACA_PAPER_SECRET_KEY) }, broker: { name: 'Alpaca Paper', configured: Boolean(process.env.ALPACA_PAPER_KEY_ID && process.env.ALPACA_PAPER_SECRET_KEY) },
jobs, predictionCounts, decisionCounts, proposalCounts, outcomes: outcomeSummary, jobs, predictionCounts, decisionCounts, proposalCounts,
predictionsByOrigin: normalizeOriginCounts(predictionOriginRows),
outcomes: origins.live,
outcomesByOrigin: origins.byOrigin,
hasLiveOutcomes: origins.live.total > 0,
allowlistedInstruments: instruments.count, latestPredictions, latestOrders, allowlistedInstruments: instruments.count, latestPredictions, latestOrders,
account: account || null, calibration, replay: replay || null, account: account || null, calibration: decorateCalibration(calibration), replay: replay || null,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}; };
} }
@@ -235,13 +425,8 @@ async function adminRoutes(fastify) {
const proposalCounts = intelligenceDb.prepare(` const proposalCounts = intelligenceDb.prepare(`
SELECT status, COUNT(*) AS count FROM autonomy_proposals GROUP BY status SELECT status, COUNT(*) AS count FROM autonomy_proposals GROUP BY status
`).all(); `).all();
const outcomeSummary = intelligenceDb.prepare(` const predictionOriginRows = intelligenceDb.prepare(PREDICTIONS_BY_ORIGIN_SQL).all();
SELECT COUNT(*) AS total, SUM(direction_correct) AS correct, const origins = summarizeOutcomeOrigins(intelligenceDb.prepare(OUTCOMES_BY_ORIGIN_SQL).all());
AVG(excess_return) AS average_excess_return
FROM autonomy_outcomes o
JOIN autonomy_predictions p ON p.id = o.prediction_id
WHERE p.origin = 'live'
`).get();
const instruments = intelligenceDb.prepare(` const instruments = intelligenceDb.prepare(`
SELECT COUNT(*) AS count FROM autonomy_instruments WHERE active=1 AND tradable=1 SELECT COUNT(*) AS count FROM autonomy_instruments WHERE active=1 AND tradable=1
`).get(); `).get();
@@ -275,11 +460,12 @@ async function adminRoutes(fastify) {
SELECT broker, equity, cash, buying_power, captured_at SELECT broker, equity, cash, buying_power, captured_at
FROM autonomy_account_snapshots ORDER BY id DESC LIMIT 1 FROM autonomy_account_snapshots ORDER BY id DESC LIMIT 1
`).get() || null; `).get() || null;
const calibration = intelligenceDb.prepare(` const calibrationColumns = new Set(
SELECT cohort_key, sample_size, effective_sample_size, directional_probability, intelligenceDb.prepare('PRAGMA table_info(autonomy_calibration_snapshots)').all().map((row) => row.name)
expected_excess_return, lower_return, upper_return, created_at );
FROM autonomy_calibration_snapshots ORDER BY id DESC LIMIT 8 const calibration = decorateCalibration(
`).all(); intelligenceDb.prepare(calibrationSnapshotSql(calibrationColumns)).all()
);
const replay = intelligenceDb.prepare(` const replay = intelligenceDb.prepare(`
SELECT r.id, r.status, r.watermark_at, r.cursor_article_id, r.cursor_effective_at, SELECT r.id, r.status, r.watermark_at, r.cursor_article_id, r.cursor_effective_at,
r.processed_articles, r.updated_at, r.processed_articles, r.updated_at,
@@ -302,9 +488,12 @@ async function adminRoutes(fastify) {
}, },
jobs, jobs,
predictionCounts, predictionCounts,
predictionsByOrigin: normalizeOriginCounts(predictionOriginRows),
decisionCounts, decisionCounts,
proposalCounts, proposalCounts,
outcomes: outcomeSummary, outcomes: origins.live,
outcomesByOrigin: origins.byOrigin,
hasLiveOutcomes: origins.live.total > 0,
allowlistedInstruments: instruments.count, allowlistedInstruments: instruments.count,
latestPredictions, latestPredictions,
latestOrders, latestOrders,
@@ -918,8 +1107,29 @@ async function adminRoutes(fastify) {
const { sql, database } = request.body || {}; const { sql, database } = request.body || {};
if (!sql || !sql.trim()) { reply.code(400); return { error: 'no sql provided' }; } if (!sql || !sql.trim()) { reply.code(400); return { error: 'no sql provided' }; }
const target = database === 'intelligence' ? getIntelligenceDb() : db; // empty/omitted means archive, the historic default. anything else has to be
if (!target) { reply.code(400); return { error: 'database not available' }; } // spelled correctly — a typo used to silently run against the archive.
const requested = String(database || 'archive').trim().toLowerCase() || 'archive';
if (requested !== 'archive' && requested !== 'intelligence') {
reply.code(400);
return { error: `unknown database "${requested}" — expected "archive" or "intelligence"` };
}
let target = null;
try {
target = requested === 'intelligence' ? getIntelligenceDb() : getArchiveDb();
} catch (error) {
console.error(`[admin] sql console cannot reach the ${requested} database:`, error);
reply.code(503);
return { error: error.message };
}
if (!target) {
const where = isPostgresEnabled() ? `postgres schema "${requested}"` : resolveIntelligencePath();
console.error(`[admin] sql console cannot reach the ${requested} database (${where})`);
reply.code(503);
return { error: `${requested} database unavailable (${where})` };
}
// split on semicolons, drop empty statements // split on semicolons, drop empty statements
const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
@@ -930,13 +1140,18 @@ async function adminRoutes(fastify) {
for (const s of statements) { for (const s of statements) {
try { try {
const stmt = target.prepare(s); const stmt = target.prepare(s);
if (stmt.reader) { // the postgres adapters dont expose better-sqlite3's `reader` flag, so
// without this fallback every SELECT went down the run() path and came
// back as a change count with no rows at all
const reads = typeof stmt.reader === 'boolean' ? stmt.reader : /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|SHOW)\b/i.test(s);
if (reads) {
results.push({ sql: s, rows: stmt.all() }); results.push({ sql: s, rows: stmt.all() });
} else { } else {
const info = stmt.run(); const info = stmt.run();
results.push({ sql: s, changes: info.changes, lastInsertRowid: info.lastInsertRowid }); results.push({ sql: s, changes: info.changes, lastInsertRowid: info.lastInsertRowid });
} }
} catch (err) { } catch (err) {
console.error(`[admin] sql console statement failed on ${requested}:`, s, err);
results.push({ sql: s, error: err.message }); results.push({ sql: s, error: err.message });
} }
} }
+12 -6
View File
@@ -12,7 +12,7 @@ const { calculateOutcome } = require('../src/autonomy/outcomes');
const { createOrderIntent } = require('../src/autonomy/orderIntents'); const { createOrderIntent } = require('../src/autonomy/orderIntents');
const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker'); const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
const { scheduleNext } = require('../workers/replayWorker'); const { scheduleNext } = require('../workers/replayWorker');
const { refreshHistoricalCalibration, createDecisions } = require('../workers/calibrationWorker'); const { refreshHistoricalCalibration, createDecisions, ensureCalibrationColumns } = require('../workers/calibrationWorker');
test('autonomy schema and leased jobs are restart-safe', () => { test('autonomy schema and leased jobs are restart-safe', () => {
const db = new Database(':memory:'); const db = new Database(':memory:');
@@ -117,8 +117,11 @@ test('calibration and policy abstain on insufficient evidence', () => {
assert.equal(calibration.sampleSize, 2); assert.equal(calibration.sampleSize, 2);
const result = decide({ ...calibration }, { minSampleSize: 30 }); const result = decide({ ...calibration }, { minSampleSize: 30 });
assert.equal(result.action, 'ABSTAIN'); assert.equal(result.action, 'ABSTAIN');
assert.equal(cohortKey({ sector: 'tech', eventType: 'earnings', horizonDays: 10, direction: 'positive' }), 'tech|earnings|10|positive'); assert.equal(cohortKey({ sector: 'tech', eventType: 'earnings', horizonDays: 10, direction: 'positive' }), 'v2|tech|earnings|medium|positive');
assert.equal(decide({ direction: 'negative', probability: 0.8, expectedExcessReturn: -0.02, lowerReturn: -0.04, sampleSize: 40 }).action, 'SELL'); assert.equal(decide({
direction: 'negative', probability: 0.8, expectedExcessReturn: -0.02, lowerReturn: -0.04,
sampleSize: 40, distinctInstruments: 9, topInstrumentShare: 0.25,
}).action, 'SELL');
}); });
test('historical replay outcomes create replay calibration snapshots', () => { test('historical replay outcomes create replay calibration snapshots', () => {
@@ -136,8 +139,9 @@ test('historical replay outcomes create replay calibration snapshots', () => {
VALUES (?, 0.04, 1) VALUES (?, 0.04, 1)
`).run(prediction.lastInsertRowid); `).run(prediction.lastInsertRowid);
assert.equal(refreshHistoricalCalibration(db, 'test-cal'), 1); // one per-run replay snapshot plus the pooled historical/replay snapshot
const snapshot = db.prepare("SELECT source, replay_run_id, sample_size, directional_probability FROM autonomy_calibration_snapshots").get(); assert.equal(refreshHistoricalCalibration(db, 'test-cal'), 2);
const snapshot = db.prepare("SELECT source, replay_run_id, sample_size, directional_probability FROM autonomy_calibration_snapshots WHERE source='replay'").get();
assert.equal(snapshot.source, 'replay'); assert.equal(snapshot.source, 'replay');
assert.equal(snapshot.replay_run_id, 7); assert.equal(snapshot.replay_run_id, 7);
assert.equal(snapshot.sample_size, 1); assert.equal(snapshot.sample_size, 1);
@@ -154,12 +158,14 @@ test('live decisions map calibration snapshot fields into policy inputs', () =>
learning_eligible, strategy_version, origin, status) learning_eligible, strategy_version, origin, status)
VALUES (?, 'NVDA', 'positive', 'earnings', 10, datetime('now'), '[1]', 1, 'test', 'live', 'open') VALUES (?, 'NVDA', 'positive', 'earnings', 10, datetime('now'), '[1]', 1, 'test', 'live', 'open')
`).run(proposal.lastInsertRowid); `).run(proposal.lastInsertRowid);
ensureCalibrationColumns(db);
db.prepare(` db.prepare(`
INSERT INTO autonomy_calibration_snapshots INSERT INTO autonomy_calibration_snapshots
(cohort_key, sample_size, effective_sample_size, directional_probability, expected_excess_return, (cohort_key, sample_size, effective_sample_size, directional_probability, expected_excess_return,
lower_return, upper_return, parent_cohort_key, version, source) lower_return, upper_return, parent_cohort_key, version, source)
VALUES ('unknown|earnings|10|positive', 40, 42, 0.7, 0.02, -0.01, 0.06, NULL, 'test-cal', 'replay') VALUES ('v2|unknown|earnings|medium|positive', 40, 42, 0.7, 0.02, -0.01, 0.06, NULL, 'test-cal', 'historical')
`).run(); `).run();
db.prepare("UPDATE autonomy_calibration_snapshots SET distinct_instruments = 11, top_instrument_share = 0.2").run();
assert.equal(createDecisions(db), 1); assert.equal(createDecisions(db), 1);
const decision = db.prepare('SELECT * FROM autonomy_decisions WHERE prediction_id=?').get(prediction.lastInsertRowid); const decision = db.prepare('SELECT * FROM autonomy_decisions WHERE prediction_id=?').get(prediction.lastInsertRowid);
+168
View File
@@ -0,0 +1,168 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
normalizeEventType,
horizonBucket,
cohortKey,
legacyCohortKey,
calibrateOutcomes,
EVENT_FAMILY_NAMES,
} = require('../src/autonomy/calibration');
const { decide, DEFAULT_POLICY_RULES } = require('../src/autonomy/policy');
test('event types collapse onto a small closed set of families', () => {
assert.equal(normalizeEventType('earnings_beat'), 'earnings');
assert.equal(normalizeEventType('Q3 Earnings Report'), 'earnings');
assert.equal(normalizeEventType('guidance_raise'), 'guidance');
assert.equal(normalizeEventType('supply_constraint'), 'supply_chain');
assert.equal(normalizeEventType('supplyConstraint'), 'supply_chain');
assert.equal(normalizeEventType('antitrust probe'), 'regulatory');
assert.equal(normalizeEventType('ceo_resignation'), 'leadership');
assert.equal(normalizeEventType('analyst-downgrade'), 'analyst_action');
assert.equal(normalizeEventType('share buyback'), 'capital');
assert.equal(normalizeEventType('data breach'), 'security_incident');
assert.equal(normalizeEventType('interest rate decision'), 'macro');
assert.equal(normalizeEventType('product_launch'), 'product');
assert.equal(normalizeEventType('acquisition_rumor'), 'm_and_a');
assert.equal(normalizeEventType('patent lawsuit'), 'legal');
});
test('unrecognised or empty event types fall back to other, never to their own cohort', () => {
assert.equal(normalizeEventType('zebra_convention'), 'other');
assert.equal(normalizeEventType(''), 'other');
assert.equal(normalizeEventType(' '), 'other');
assert.equal(normalizeEventType(null), 'other');
assert.equal(normalizeEventType(undefined), 'other');
assert.equal(normalizeEventType(42), 'other');
assert.ok(EVENT_FAMILY_NAMES.includes('other'));
assert.ok(EVENT_FAMILY_NAMES.length <= 15, `taxonomy grew to ${EVENT_FAMILY_NAMES.length} families`);
});
test('every allowed horizon lands in one of three buckets', () => {
assert.equal(horizonBucket(1), 'short');
assert.equal(horizonBucket(5), 'short');
assert.equal(horizonBucket(10), 'medium');
assert.equal(horizonBucket(20), 'medium');
assert.equal(horizonBucket(30), 'long');
assert.equal(horizonBucket(60), 'long');
assert.equal(horizonBucket(90), 'long');
assert.equal(horizonBucket(null), 'unknown');
assert.equal(horizonBucket('nope'), 'unknown');
});
test('cohort key is versioned, coarse and stable, and the legacy key is still available', () => {
assert.equal(
cohortKey({ direction: 'positive', eventType: 'earnings_beat', horizonDays: 10 }),
'v2|unknown|earnings|medium|positive'
);
// different raw event text, same family and horizon bucket -> same cohort
assert.equal(
cohortKey({ direction: 'positive', eventType: 'quarterly results miss', horizonDays: 20 }),
cohortKey({ direction: 'positive', eventType: 'earnings_beat', horizonDays: 10 })
);
assert.notEqual(
cohortKey({ direction: 'negative', eventType: 'earnings_beat', horizonDays: 10 }),
cohortKey({ direction: 'positive', eventType: 'earnings_beat', horizonDays: 10 })
);
assert.equal(
legacyCohortKey({ direction: 'positive', eventType: 'earnings_beat', horizonDays: 10 }),
'unknown|earnings_beat|10|positive'
);
});
test('the coarse taxonomy actually collapses a realistic spread of free text', () => {
const raw = [
'earnings_beat', 'earnings_miss', 'q2_earnings', 'revenue_growth', 'margin_expansion',
'guidance_raise', 'guidance_cut', 'outlook_downgrade', 'profit_warning',
'supply_constraint', 'chip_shortage', 'production_halt', 'capacity_expansion',
'analyst_upgrade', 'price_target_raise', 'ceo_departure', 'board_shakeup',
'antitrust_probe', 'export_controls', 'tariff_announcement',
];
const families = new Set(raw.map(normalizeEventType));
assert.ok(families.size <= 8, `expected heavy collapse, got ${families.size} families`);
});
test('calibrateOutcomes reports instrument diversity and concentration', () => {
const rows = [
{ excess_return: 0.02, direction_correct: 1, instrument: 'NVDA' },
{ excess_return: 0.01, direction_correct: 1, instrument: 'nvda' },
{ excess_return: -0.01, direction_correct: 0, instrument: 'NVDA' },
{ excess_return: 0.03, direction_correct: 1, instrument: 'AMD' },
];
const result = calibrateOutcomes(rows);
assert.equal(result.sampleSize, 4);
assert.equal(result.distinctInstruments, 2);
assert.equal(result.topInstrumentShare, 0.75);
const empty = calibrateOutcomes([]);
assert.equal(empty.distinctInstruments, 0);
assert.equal(empty.topInstrumentShare, null);
const unlabelled = calibrateOutcomes([{ excess_return: 0.01, direction_correct: 1 }]);
assert.equal(unlabelled.distinctInstruments, 0);
});
test('the diversification gate blocks single ticker cohorts however large they are', () => {
const base = { direction: 'positive', probability: 0.8, expectedExcessReturn: 0.02, lowerReturn: -0.01 };
// 300 samples, one name: this is the NVDA case, and it must not qualify
const concentrated = decide({ ...base, sampleSize: 300, distinctInstruments: 1, topInstrumentShare: 1 });
assert.equal(concentrated.action, 'ABSTAIN');
assert.match(concentrated.rationale, /diversity/);
// enough names but still dominated by one of them
const dominated = decide({ ...base, sampleSize: 300, distinctInstruments: 9, topInstrumentShare: 0.82 });
assert.equal(dominated.action, 'ABSTAIN');
assert.match(dominated.rationale, /dominated/);
// pre-diversification snapshots carry no count, unknown is not adequate
const unknown = decide({ ...base, sampleSize: 300, distinctInstruments: null, topInstrumentShare: null });
assert.equal(unknown.action, 'ABSTAIN');
assert.match(unknown.rationale, /unknown/);
const qualified = decide({ ...base, sampleSize: 40, distinctInstruments: 9, topInstrumentShare: 0.3 });
assert.equal(qualified.action, 'BUY');
});
test('both evidence thresholds are overridable and default conservatively', () => {
assert.equal(DEFAULT_POLICY_RULES.minSampleSize, 30);
assert.equal(DEFAULT_POLICY_RULES.minDistinctInstruments, 5);
assert.equal(DEFAULT_POLICY_RULES.maxInstrumentConcentration, 0.5);
const input = {
direction: 'positive', probability: 0.8, expectedExcessReturn: 0.02, lowerReturn: -0.01,
sampleSize: 12, distinctInstruments: 3, topInstrumentShare: 0.4,
};
assert.equal(decide(input).action, 'ABSTAIN');
assert.equal(decide(input, { minSampleSize: 10, minDistinctInstruments: 2 }).action, 'BUY');
assert.equal(decide(input, { minSampleSize: 10, minDistinctInstruments: 2, maxInstrumentConcentration: 0.3 }).action, 'ABSTAIN');
});
test('sample size gate still runs before the diversity gate', () => {
const result = decide({
direction: 'positive', probability: 0.9, expectedExcessReturn: 0.05,
sampleSize: 2, distinctInstruments: 40, topInstrumentShare: 0.1,
});
assert.equal(result.action, 'ABSTAIN');
assert.match(result.rationale, /insufficient calibration sample/);
});
test('a missing concentration share cannot sneak past the cap as a zero', () => {
const base = {
direction: 'positive', probability: 0.8, expectedExcessReturn: 0.02,
lowerReturn: -0.01, sampleSize: 300, distinctInstruments: 40,
};
// Number(null) is 0, which used to slide straight under the cap even though we
// had no idea what the real concentration was.
for (const share of [null, undefined]) {
const verdict = decide({ ...base, topInstrumentShare: share });
assert.equal(verdict.action, 'ABSTAIN');
assert.match(verdict.rationale, /concentration unknown/);
}
// a genuinely broad cohort still gets through, we havent just bolted it shut
const broad = decide({ ...base, topInstrumentShare: 0.12 });
assert.equal(broad.action, 'BUY');
});
+112
View File
@@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { cohortKey } = require('../src/autonomy/calibration');
const {
refreshCalibration,
refreshHistoricalCalibration,
createDecisions,
calibrationHealth,
} = require('../workers/calibrationWorker');
function seedDb() {
const db = new Database(':memory:');
initAutonomySchema(db);
db.prepare("INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', '2026-01-01T00:00:00Z', 'accepted')").run();
return db;
}
function addPrediction(db, { instrument, direction = 'positive', eventType = 'earnings_beat', horizonDays = 10,
origin = 'live', status = 'resolved', learningEligible = 0, replayRunId = null, excessReturn = null, correct = null }) {
const prediction = db.prepare(`
INSERT INTO autonomy_predictions
(proposal_id, instrument, direction, event_type, horizon_days, information_cutoff, evidence_article_ids,
learning_eligible, strategy_version, origin, replay_run_id, status)
VALUES (1, ?, ?, ?, ?, '2026-01-01T00:00:00Z', '[1]', ?, 'test', ?, ?, ?)
`).run(instrument, direction, eventType, horizonDays, learningEligible, origin, replayRunId, status);
if (excessReturn !== null) {
db.prepare('INSERT INTO autonomy_outcomes(prediction_id, excess_return, direction_correct) VALUES (?, ?, ?)')
.run(prediction.lastInsertRowid, excessReturn, correct);
}
return prediction.lastInsertRowid;
}
test('live calibration no longer starves on the never-set learning_eligible flag', () => {
const db = seedDb();
addPrediction(db, { instrument: 'NVDA', excessReturn: 0.03, correct: 1 });
addPrediction(db, { instrument: 'AMD', excessReturn: -0.01, correct: 0 });
assert.equal(refreshCalibration(db, 'live-cal'), 1);
const snapshot = db.prepare("SELECT * FROM autonomy_calibration_snapshots WHERE source='live'").get();
assert.equal(snapshot.sample_size, 2);
assert.equal(snapshot.distinct_instruments, 2);
// the old behaviour is still reachable on purpose, for once the flag is populated
assert.equal(refreshCalibration(db, 'strict-cal', { requireLearningEligible: true }), 0);
// counters report snapshots written, so a steady state poll is genuinely quiet
assert.equal(refreshCalibration(db, 'live-cal'), 0);
assert.equal(db.prepare("SELECT COUNT(*) c FROM autonomy_calibration_snapshots WHERE source='live'").get().c, 1);
});
test('historical calibration pools origin historical and replay together', () => {
const db = seedDb();
addPrediction(db, { instrument: 'NVDA', origin: 'historical', excessReturn: 0.02, correct: 1 });
addPrediction(db, { instrument: 'AMD', origin: 'historical', excessReturn: 0.01, correct: 1 });
addPrediction(db, { instrument: 'INTC', origin: 'replay', replayRunId: 3, excessReturn: -0.02, correct: 0 });
refreshHistoricalCalibration(db, 'hist-cal');
const pooled = db.prepare("SELECT * FROM autonomy_calibration_snapshots WHERE source='historical'").get();
assert.equal(pooled.sample_size, 3, 'the historical lane must not drop the relabelled rows');
assert.equal(pooled.distinct_instruments, 3);
const perRun = db.prepare("SELECT * FROM autonomy_calibration_snapshots WHERE source='replay'").get();
assert.equal(perRun.replay_run_id, 3);
assert.equal(perRun.sample_size, 1);
});
test('decisions are only written for open live predictions', () => {
const db = seedDb();
const open = addPrediction(db, { instrument: 'NVDA', status: 'open' });
addPrediction(db, { instrument: 'AMD', status: 'resolved', excessReturn: 0.01, correct: 1 });
addPrediction(db, { instrument: 'INTC', status: 'open', origin: 'historical' });
addPrediction(db, { instrument: 'MU', status: 'open', origin: 'replay', replayRunId: 3 });
assert.equal(createDecisions(db), 1);
const rows = db.prepare('SELECT prediction_id, action FROM autonomy_decisions').all();
assert.equal(rows.length, 1);
assert.equal(rows[0].prediction_id, open);
assert.equal(rows[0].action, 'ABSTAIN');
// second pass must not duplicate
assert.equal(createDecisions(db), 0);
});
test('a big single ticker historical cohort still cannot authorise a live buy', () => {
const db = seedDb();
for (let index = 0; index < 60; index++) {
addPrediction(db, { instrument: 'NVDA', origin: 'historical', excessReturn: 0.04, correct: 1 });
}
refreshHistoricalCalibration(db, 'hist-cal');
const prediction = addPrediction(db, { instrument: 'NVDA', status: 'open' });
assert.equal(createDecisions(db), 1);
const decision = db.prepare('SELECT * FROM autonomy_decisions WHERE prediction_id=?').get(prediction);
assert.equal(decision.action, 'ABSTAIN');
assert.match(decision.rationale, /diversity|dominated/);
assert.match(decision.rationale, new RegExp(cohortKey({ direction: 'positive', eventType: 'earnings_beat', horizonDays: 10 }).replace(/\|/g, '\\|')));
});
test('calibration health reports the stall instead of staying silent', () => {
const db = seedDb();
addPrediction(db, { instrument: 'NVDA', origin: 'historical', excessReturn: 0.02, correct: 1 });
addPrediction(db, { instrument: 'AMD', status: 'open' });
refreshHistoricalCalibration(db, 'hist-cal');
const health = calibrationHealth(db);
assert.equal(health.liveOpen, 1);
assert.equal(health.offlineResolved, 1);
assert.equal(health.learningEligible, 0);
assert.ok(health.cohorts >= 1);
assert.equal(health.qualifyingCohorts, 0);
});
+74
View File
@@ -0,0 +1,74 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { checkPubDate, guardEffectivePubDate, DEFAULT_TOLERANCE_MS } = require('../src/pubDateGuard');
const NOW = Date.parse('2026-08-29T12:00:00.000Z');
const HOUR = 60 * 60 * 1000;
test('ordinary past publication dates pass straight through', () => {
const verdict = checkPubDate('2026-08-27T09:30:00.000Z', NOW);
assert.equal(verdict.ok, true);
assert.equal(verdict.value, '2026-08-27T09:30:00.000Z');
});
test('a date-only feed value from an eastern timezone is still accepted', () => {
// "2026-08-30" stored as midnight UTC is 12 hours ahead of now — legitimate
const verdict = checkPubDate('2026-08-30T00:00:00.000Z', NOW);
assert.equal(verdict.ok, true);
});
test('mild clock skew inside the tolerance is accepted', () => {
const verdict = checkPubDate(new Date(NOW + 47 * HOUR).toISOString(), NOW);
assert.equal(verdict.ok, true);
});
test('anything past the tolerance is rejected', () => {
const verdict = checkPubDate(new Date(NOW + 49 * HOUR).toISOString(), NOW);
assert.equal(verdict.ok, false);
assert.equal(verdict.value, null);
assert.ok(verdict.skewMs > DEFAULT_TOLERANCE_MS);
});
test('the real production offender is caught', () => {
const verdict = checkPubDate('2026-12-22T00:00:00.000Z', NOW);
assert.equal(verdict.ok, false);
});
test('missing and unparseable dates are not treated as future dates', () => {
assert.equal(checkPubDate(null, NOW).ok, true);
assert.equal(checkPubDate('', NOW).ok, true);
assert.equal(checkPubDate('not a date at all', NOW).ok, true);
assert.equal(checkPubDate('not a date at all', NOW).value, null);
});
test('the tolerance boundary itself is inclusive', () => {
assert.equal(checkPubDate(new Date(NOW + DEFAULT_TOLERANCE_MS).toISOString(), NOW).ok, true);
assert.equal(checkPubDate(new Date(NOW + DEFAULT_TOLERANCE_MS + 1).toISOString(), NOW).ok, false);
});
test('a rejected date falls back to ingestion time and never drops the article', () => {
const ingestedAt = new Date().toISOString();
const future = new Date(Date.now() + 120 * 24 * HOUR).toISOString();
const warnings = [];
const original = console.warn;
console.warn = (message) => warnings.push(message);
try {
const effective = guardEffectivePubDate(future, ingestedAt, { source: 'gdelt', url: 'https://example.com/a' });
assert.equal(effective, ingestedAt);
} finally {
console.warn = original;
}
assert.equal(warnings.length, 1);
assert.match(warnings[0], /gdelt/);
assert.match(warnings[0], /https:\/\/example\.com\/a/);
assert.ok(warnings[0].includes(future));
});
test('a good date is kept, and a missing one falls back quietly', () => {
const ingestedAt = '2026-08-29T12:00:00.000Z';
assert.equal(guardEffectivePubDate('2026-08-01T00:00:00.000Z', ingestedAt, {}), '2026-08-01T00:00:00.000Z');
assert.equal(guardEffectivePubDate(null, ingestedAt, {}), ingestedAt);
});
+174 -30
View File
@@ -2,10 +2,36 @@ const os = require('os');
const { openRuntimeDb } = require('../src/db/runtime'); const { openRuntimeDb } = require('../src/db/runtime');
const { initAutonomySchema } = require('../src/autonomy/schema'); const { initAutonomySchema } = require('../src/autonomy/schema');
const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration'); const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration');
const { decide } = require('../src/autonomy/policy'); const { decide, DEFAULT_POLICY_RULES } = require('../src/autonomy/policy');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
// Historical calibration has to pool the coordinator backfill lane and the
// walk-forward replay lane, they are the same kind of evidence and splitting them
// would drop the largest cohort on the floor.
const HISTORICAL_ORIGINS = ['historical', 'replay'];
const patchedDbs = new WeakSet();
// The snapshot table predates diversification tracking. Additive only, and the
// duplicate-column error is the expected path on every run after the first.
function ensureCalibrationColumns(db) {
if (patchedDbs.has(db)) return;
for (const statement of [
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN distinct_instruments INTEGER',
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN top_instrument_share REAL',
]) {
try {
db.exec(statement);
} catch (error) {
if (!/duplicate column|already exists/i.test(error.message)) {
console.error('[calibration] snapshot column patch failed:', error.message, error.stack);
}
}
}
patchedDbs.add(db);
}
function snapshotToDecisionInput(snapshot, direction) { function snapshotToDecisionInput(snapshot, direction) {
return { return {
direction, direction,
@@ -14,29 +40,56 @@ function snapshotToDecisionInput(snapshot, direction) {
lowerReturn: snapshot.lower_return, lowerReturn: snapshot.lower_return,
upperReturn: snapshot.upper_return, upperReturn: snapshot.upper_return,
sampleSize: snapshot.sample_size, sampleSize: snapshot.sample_size,
distinctInstruments: snapshot.distinct_instruments,
topInstrumentShare: snapshot.top_instrument_share,
}; };
} }
function refreshCalibration(db, version = `cal-${Date.now()}`, { origin = 'live', source = origin, replayRunId = null } = {}) { function refreshCalibration(db, version = `cal-${Date.now()}`, {
const learningClause = origin === 'live' ? 'AND p.learning_eligible = 1' : ''; origin = 'live',
const replayClause = replayRunId ? 'AND p.replay_run_id = @replayRunId' : ''; origins = null,
source = (origins && origins.length ? origins[0] : origin),
replayRunId = null,
// learning_eligible has never been set to 1 by anything upstream, so requiring it
// starved the live lane permanently. origin='live' *is* the eligibility contract;
// flip this back on once the coordinator actually populates the flag.
requireLearningEligible = false,
} = {}) {
ensureCalibrationColumns(db);
const originList = origins && origins.length ? origins : [origin];
const params = {};
originList.forEach((value, index) => { params[`origin${index}`] = value; });
const originClause = originList.map((_, index) => `@origin${index}`).join(', ');
const learningClause = requireLearningEligible && originList.includes('live') ? 'AND p.learning_eligible = 1' : '';
let replayClause = '';
if (replayRunId !== null && replayRunId !== undefined) {
replayClause = 'AND p.replay_run_id = @replayRunId';
params.replayRunId = replayRunId;
}
const groups = db.prepare(` const groups = db.prepare(`
SELECT p.direction, p.event_type, p.horizon_days, o.* SELECT p.direction, p.event_type, p.horizon_days, p.instrument, o.*
FROM autonomy_predictions p FROM autonomy_predictions p
JOIN autonomy_outcomes o ON o.prediction_id = p.id JOIN autonomy_outcomes o ON o.prediction_id = p.id
WHERE p.status = 'resolved' AND p.origin = @origin ${learningClause} ${replayClause} WHERE p.status = 'resolved' AND p.origin IN (${originClause}) ${learningClause} ${replayClause}
`).all({ origin, replayRunId }).reduce((map, row) => { `).all(params).reduce((map, row) => {
const key = cohortKey({ direction: row.direction, eventType: row.event_type, horizonDays: row.horizon_days }); const key = cohortKey({ direction: row.direction, eventType: row.event_type, horizonDays: row.horizon_days });
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key).push(row); map.get(key).push(row);
return map; return map;
}, new Map()); }, new Map());
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO autonomy_calibration_snapshots INSERT INTO autonomy_calibration_snapshots
(cohort_key, sample_size, effective_sample_size, directional_probability, (cohort_key, sample_size, effective_sample_size, directional_probability,
expected_excess_return, lower_return, upper_return, parent_cohort_key, version, source, replay_run_id) expected_excess_return, lower_return, upper_return, parent_cohort_key, version, source, replay_run_id,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) distinct_instruments, top_instrument_share)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
// Count what we actually wrote, not how many cohorts exist. The old code returned
// groups.size, so a steady state system reported "work happened" on every poll and
// the log line lost all meaning.
let written = 0;
const tx = db.transaction(() => { const tx = db.transaction(() => {
for (const [key, rows] of groups) { for (const [key, rows] of groups) {
if (db.prepare(` if (db.prepare(`
@@ -45,11 +98,13 @@ function refreshCalibration(db, version = `cal-${Date.now()}`, { origin = 'live'
`).get(key, version, source, replayRunId)) continue; `).get(key, version, source, replayRunId)) continue;
const result = calibrateOutcomes(rows); const result = calibrateOutcomes(rows);
insert.run(key, result.sampleSize, result.effectiveSampleSize, result.directionalProbability, insert.run(key, result.sampleSize, result.effectiveSampleSize, result.directionalProbability,
result.expectedExcessReturn, result.lowerReturn, result.upperReturn, null, version, source, replayRunId); result.expectedExcessReturn, result.lowerReturn, result.upperReturn, null, version, source, replayRunId,
result.distinctInstruments, result.topInstrumentShare);
written++;
} }
}); });
tx(); tx();
return groups.size; return written;
} }
function refreshHistoricalCalibration(db, version = `replay-cal-${Date.now()}`) { function refreshHistoricalCalibration(db, version = `replay-cal-${Date.now()}`) {
@@ -59,29 +114,43 @@ function refreshHistoricalCalibration(db, version = `replay-cal-${Date.now()}`)
WHERE origin = 'replay' AND replay_run_id IS NOT NULL WHERE origin = 'replay' AND replay_run_id IS NOT NULL
ORDER BY replay_run_id ORDER BY replay_run_id
`).all(); `).all();
let groups = 0; let written = 0;
for (const run of runs) { for (const run of runs) {
groups += refreshCalibration(db, `${version}-run-${run.replayRunId}`, { written += refreshCalibration(db, `${version}-run-${run.replayRunId}`, {
origin: 'replay', origin: 'replay',
source: 'replay', source: 'replay',
replayRunId: run.replayRunId, replayRunId: run.replayRunId,
}); });
} }
if (!runs.length) {
groups += refreshCalibration(db, version, { origin: 'replay', source: 'replay' }); // Pooled historical view across both offline origins. This is the snapshot the
} // live lane falls back on before it has any live evidence of its own.
return groups; written += refreshCalibration(db, version, {
origins: HISTORICAL_ORIGINS,
source: 'historical',
});
return written;
} }
function createDecisions(db, strategyVersion = 'autonomy-1') { // Decisions stay scoped to open live predictions on purpose: a decision is a
// forward looking policy call, and writing one against a prediction whose outcome
// is already known would put lookahead straight into the executable ledger.
// The stall was never this predicate, it was that nothing upstream was producing
// open live predictions and nothing ever said so out loud.
function createDecisions(db, strategyVersion = 'autonomy-1', rules = {}) {
ensureCalibrationColumns(db);
const predictions = db.prepare(` const predictions = db.prepare(`
SELECT p.* FROM autonomy_predictions p SELECT p.* FROM autonomy_predictions p
LEFT JOIN autonomy_decisions d ON d.prediction_id = p.id LEFT JOIN autonomy_decisions d ON d.prediction_id = p.id
WHERE d.prediction_id IS NULL AND p.status = 'open' AND p.origin = 'live' WHERE d.prediction_id IS NULL AND p.status = 'open' AND p.origin = 'live'
`).all(); `).all();
// Prefer calibration built from live outcomes, fall back to the pooled historical
// snapshot, and always record which one we used in the rationale.
const latest = db.prepare(` const latest = db.prepare(`
SELECT * FROM autonomy_calibration_snapshots SELECT * FROM autonomy_calibration_snapshots
WHERE cohort_key = ? ORDER BY created_at DESC, id DESC LIMIT 1 WHERE cohort_key = ?
ORDER BY (source = 'live') DESC, created_at DESC, id DESC LIMIT 1
`); `);
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO autonomy_decisions INSERT INTO autonomy_decisions
@@ -94,10 +163,13 @@ function createDecisions(db, strategyVersion = 'autonomy-1') {
const key = cohortKey({ direction: prediction.direction, eventType: prediction.event_type, horizonDays: prediction.horizon_days }); const key = cohortKey({ direction: prediction.direction, eventType: prediction.event_type, horizonDays: prediction.horizon_days });
const calibration = latest.get(key); const calibration = latest.get(key);
const decision = calibration const decision = calibration
? decide(snapshotToDecisionInput(calibration, prediction.direction), { minSampleSize: 30 }) ? decide(snapshotToDecisionInput(calibration, prediction.direction), rules)
: { action: 'ABSTAIN', rationale: 'calibration unavailable' }; : { action: 'ABSTAIN', rationale: 'calibration unavailable' };
const rationale = calibration
? `${decision.rationale} [cohort=${key} source=${calibration.source} n=${calibration.sample_size}]`
: `${decision.rationale} [cohort=${key}]`;
insert.run(prediction.id, decision.action, calibration?.directional_probability || null, insert.run(prediction.id, decision.action, calibration?.directional_probability || null,
calibration?.expected_excess_return || null, decision.rationale, strategyVersion); calibration?.expected_excess_return || null, rationale, strategyVersion);
created++; created++;
} }
}); });
@@ -108,7 +180,7 @@ function createDecisions(db, strategyVersion = 'autonomy-1') {
// Replay evaluations are walk-forward: each historical prediction is scored // Replay evaluations are walk-forward: each historical prediction is scored
// against calibration data that had matured strictly before its cutoff. They // against calibration data that had matured strictly before its cutoff. They
// are stored in their own ledger, never in autonomy_decisions. // are stored in their own ledger, never in autonomy_decisions.
function refreshReplayEvaluations(db) { function refreshReplayEvaluations(db, rules = {}) {
const predictions = db.prepare(` const predictions = db.prepare(`
SELECT p.*, o.excess_return, o.direction_correct SELECT p.*, o.excess_return, o.direction_correct
FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id = p.id FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id = p.id
@@ -117,7 +189,7 @@ function refreshReplayEvaluations(db) {
ORDER BY datetime(p.information_cutoff), p.id LIMIT 200 ORDER BY datetime(p.information_cutoff), p.id LIMIT 200
`).all(); `).all();
const prior = db.prepare(` const prior = db.prepare(`
SELECT p.direction, p.event_type, p.horizon_days, o.* SELECT p.direction, p.event_type, p.horizon_days, p.instrument, o.*
FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id = p.id FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id = p.id
WHERE p.origin = 'replay' AND p.status = 'resolved' WHERE p.origin = 'replay' AND p.status = 'resolved'
AND datetime(p.information_cutoff, '+' || p.horizon_days || ' days') < datetime(?) AND datetime(p.information_cutoff, '+' || p.horizon_days || ' days') < datetime(?)
@@ -133,7 +205,7 @@ function refreshReplayEvaluations(db) {
const rows = prior.all(prediction.information_cutoff).filter((row) => const rows = prior.all(prediction.information_cutoff).filter((row) =>
cohortKey({ direction: row.direction, eventType: row.event_type, horizonDays: row.horizon_days }) === key); cohortKey({ direction: row.direction, eventType: row.event_type, horizonDays: row.horizon_days }) === key);
const calibration = rows.length ? calibrateOutcomes(rows) : null; const calibration = rows.length ? calibrateOutcomes(rows) : null;
const decision = calibration ? decide({ ...calibration, direction: prediction.direction }, { minSampleSize: 30 }) const decision = calibration ? decide({ ...calibration, direction: prediction.direction }, rules)
: { action: 'ABSTAIN', rationale: 'walk-forward calibration unavailable' }; : { action: 'ABSTAIN', rationale: 'walk-forward calibration unavailable' };
insert.run(prediction.id, prediction.replay_run_id, prediction.information_cutoff, rows.length, decision.action, insert.run(prediction.id, prediction.replay_run_id, prediction.information_cutoff, rows.length, decision.action,
calibration?.directionalProbability || null, calibration?.expectedExcessReturn || null, decision.rationale); calibration?.directionalProbability || null, calibration?.expectedExcessReturn || null, decision.rationale);
@@ -143,25 +215,97 @@ function refreshReplayEvaluations(db) {
return predictions.length; return predictions.length;
} }
async function runCalibrationWorker({ intelligencePath, pollMs = 60000, workerId = `calibration-${os.hostname()}-${process.pid}` } = {}) { // A worker that only speaks when something happened looks identical to a worker
// that is dead. This is the "why is nothing moving" line.
function calibrationHealth(db, rules = {}) {
const minSampleSize = Number(rules.minSampleSize ?? DEFAULT_POLICY_RULES.minSampleSize);
const minDistinctInstruments = Number(rules.minDistinctInstruments ?? DEFAULT_POLICY_RULES.minDistinctInstruments);
try {
const predictions = db.prepare(`
SELECT
SUM(CASE WHEN origin = 'live' AND status = 'open' THEN 1 ELSE 0 END) AS live_open,
SUM(CASE WHEN origin = 'live' AND status = 'resolved' THEN 1 ELSE 0 END) AS live_resolved,
SUM(CASE WHEN origin IN ('historical', 'replay') AND status = 'resolved' THEN 1 ELSE 0 END) AS offline_resolved,
SUM(CASE WHEN learning_eligible = 1 THEN 1 ELSE 0 END) AS learning_eligible
FROM autonomy_predictions
`).get() || {};
const cohorts = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN sample_size >= ? AND COALESCE(distinct_instruments, 0) >= ? THEN 1 ELSE 0 END) AS qualifying
FROM autonomy_calibration_snapshots
`).get(minSampleSize, minDistinctInstruments) || {};
return {
liveOpen: Number(predictions.live_open || 0),
liveResolved: Number(predictions.live_resolved || 0),
offlineResolved: Number(predictions.offline_resolved || 0),
learningEligible: Number(predictions.learning_eligible || 0),
cohorts: Number(cohorts.total || 0),
qualifyingCohorts: Number(cohorts.qualifying || 0),
};
} catch (error) {
console.error('[calibration] health probe failed:', error.message, error.stack);
return null;
}
}
function formatHealth(health) {
if (!health) return 'health=unavailable';
return `live_open=${health.liveOpen} live_resolved=${health.liveResolved} offline_resolved=${health.offlineResolved}`
+ ` learning_eligible=${health.learningEligible} cohorts=${health.cohorts} qualifying_cohorts=${health.qualifyingCohorts}`;
}
async function runCalibrationWorker({
intelligencePath,
pollMs = 60000,
stallLogMs = 900000,
workerId = `calibration-${os.hostname()}-${process.pid}`,
} = {}) {
const db = openRuntimeDb(intelligencePath, { schema: 'intelligence' }); const db = openRuntimeDb(intelligencePath, { schema: 'intelligence' });
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000'); db.pragma('busy_timeout = 5000');
initAutonomySchema(db); initAutonomySchema(db);
ensureCalibrationColumns(db);
let lastStallLog = 0;
let lastStallSignature = null;
while (true) { while (true) {
try { try {
const state = db.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(prediction_id), 0) AS max_id FROM autonomy_outcomes').get(); const state = db.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(prediction_id), 0) AS max_id FROM autonomy_outcomes').get();
const version = `cal-${state.count}-${state.max_id}`; const version = `cal-${state.count}-${state.max_id}`;
const groups = refreshCalibration(db, version); const snapshots = refreshCalibration(db, version);
const historicalGroups = refreshHistoricalCalibration(db, version); const historicalSnapshots = refreshHistoricalCalibration(db, version);
const decisions = createDecisions(db); const decisions = createDecisions(db);
const replayEvaluations = refreshReplayEvaluations(db); const replayEvaluations = refreshReplayEvaluations(db);
if (groups || historicalGroups || decisions || replayEvaluations) console.log(`[${workerId}] calibration groups=${groups} historical_groups=${historicalGroups} decisions=${decisions} replay_evaluations=${replayEvaluations}`); if (snapshots || historicalSnapshots || decisions || replayEvaluations) {
console.log(`[${workerId}] calibration snapshots=${snapshots} historical_snapshots=${historicalSnapshots} decisions=${decisions} replay_evaluations=${replayEvaluations} ${formatHealth(calibrationHealth(db))}`);
lastStallSignature = null;
lastStallLog = 0;
} else {
// Nothing moved. Say so, but only when the picture changes or every
// stallLogMs, otherwise this is a zeroes-every-60-seconds firehose.
const health = calibrationHealth(db);
const signature = formatHealth(health);
const now = Date.now();
if (signature !== lastStallSignature || now - lastStallLog >= stallLogMs) {
console.log(`[${workerId}] calibration idle (no new cohorts, decisions or evaluations) ${signature}`);
lastStallSignature = signature;
lastStallLog = now;
}
}
} catch (error) { } catch (error) {
console.error(`[${workerId}] calibration error:`, error.message); console.error(`[${workerId}] calibration error:`, error.message, error.stack);
} }
await sleep(pollMs); await sleep(pollMs);
} }
} }
module.exports = { refreshCalibration, refreshHistoricalCalibration, createDecisions, refreshReplayEvaluations, runCalibrationWorker }; module.exports = {
ensureCalibrationColumns,
refreshCalibration,
refreshHistoricalCalibration,
createDecisions,
refreshReplayEvaluations,
calibrationHealth,
runCalibrationWorker,
};
+4
View File
@@ -65,14 +65,18 @@ async function runCoordinatorWorker({ archivePath, intelligencePath, workerId =
model: config.openRouter.llmModel || 'unknown', model: config.openRouter.llmModel || 'unknown',
promptVersion: 'coordinator-1', promptVersion: 'coordinator-1',
strategyVersion: 'autonomy-1', strategyVersion: 'autonomy-1',
// only a genuine live lane job may ever feed learning
origin: historical ? 'historical' : 'live',
learningEligible: !historical, learningEligible: !historical,
}); });
} catch (validationError) { } catch (validationError) {
console.error(`[${workerId}] proposal rejected for event ${event.id}:`, validationError.message);
recordRejectedProposal(intelligenceDb, raw, { recordRejectedProposal(intelligenceDb, raw, {
eventId: event.id, eventId: event.id,
informationCutoff, informationCutoff,
model: config.openRouter.llmModel || 'unknown', model: config.openRouter.llmModel || 'unknown',
promptVersion: 'coordinator-1', promptVersion: 'coordinator-1',
origin: historical ? 'historical' : 'live',
learningEligible: !historical, learningEligible: !historical,
}, validationError.message); }, validationError.message);
} }
+2 -1
View File
@@ -121,7 +121,8 @@ async function runReplayWorker({ archivePath, intelligencePath, workerId = `repl
origin: 'replay', replayRunId: run.id, origin: 'replay', replayRunId: run.id,
}); });
} catch (validationError) { } catch (validationError) {
recordRejectedProposal(db, raw, { informationCutoff: article.effective_at, model: config.openRouter.llmModel || 'unknown', promptVersion: 'replay-coordinator-1' }, validationError.message); console.error(`[${workerId}] replay proposal rejected for article ${article.id}:`, validationError.message);
recordRejectedProposal(db, raw, { informationCutoff: article.effective_at, model: config.openRouter.llmModel || 'unknown', promptVersion: 'replay-coordinator-1', origin: 'replay' }, validationError.message);
} }
db.prepare(`UPDATE autonomy_replay_runs SET cursor_article_id=?, cursor_effective_at=?, processed_articles=processed_articles+1, updated_at=datetime('now') WHERE id=?`) db.prepare(`UPDATE autonomy_replay_runs SET cursor_article_id=?, cursor_effective_at=?, processed_articles=processed_articles+1, updated_at=datetime('now') WHERE id=?`)
.run(article.id, article.effective_at, run.id); .run(article.id, article.effective_at, run.id);