Files
Duriin-API/test/autonomy.test.js
T

269 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { enqueueJob, leaseNextJob, completeJob } = require('../src/autonomy/jobs');
const { normalizeProposal, acceptProposal } = require('../src/autonomy/coordinator');
const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration');
const { decide } = require('../src/autonomy/policy');
const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution');
const { calculateOutcome } = require('../src/autonomy/outcomes');
const { createOrderIntent } = require('../src/autonomy/orderIntents');
const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
const { scheduleNext } = require('../workers/replayWorker');
const { refreshHistoricalCalibration, createDecisions } = require('../workers/calibrationWorker');
test('autonomy schema and leased jobs are restart-safe', () => {
const db = new Database(':memory:');
initAutonomySchema(db);
assert.equal(enqueueJob(db, {
jobType: 'enrich', lane: 'live', priority: 10, entityType: 'article', entityId: 42,
idempotencyKey: 'enrich:42',
}).inserted, true);
assert.equal(enqueueJob(db, {
jobType: 'enrich', lane: 'live', priority: 10, entityType: 'article', entityId: 42,
idempotencyKey: 'enrich:42',
}).inserted, false);
const job = leaseNextJob(db, 'test-worker');
assert.equal(job.lane, 'live');
assert.equal(completeJob(db, job.id, 'test-worker'), true);
assert.equal(db.prepare("SELECT status FROM autonomy_jobs WHERE id = ?").get(job.id).status, 'complete');
});
test('coordinator proposals require evidence and contain no arbitrary numeric confidence', () => {
const normalized = normalizeProposal({ predictions: [{
instrument: 'nvda', direction: 'positive', event_type: 'supply_constraint',
horizon_days: 10, evidence_article_ids: [7],
}] }, { informationCutoff: '2026-01-01T00:00:00Z', model: 'test-model' });
assert.equal(normalized.predictions[0].instrument, 'NVDA');
assert.equal('probability' in normalized.predictions[0], false);
assert.throws(() => normalizeProposal({ predictions: [{ instrument: 'NVDA', direction: 'positive', horizon_days: 10 }] }));
});
test('accepted proposal preserves evidence and creates immutable prediction', () => {
const archive = new Database(':memory:');
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY)');
archive.prepare('INSERT INTO articles (id) VALUES (?)').run(7);
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
intelligence.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA', 'test', 1, 1)").run();
const result = acceptProposal(intelligence, archive, {
predictions: [{ instrument: 'NVDA', direction: 'positive', event_type: 'earnings', horizon_days: 10, evidence_article_ids: [7] }],
}, { informationCutoff: '2026-01-01T00:00:00Z', strategyVersion: 'test' });
assert.equal(result.predictionCount, 1);
assert.deepEqual(JSON.parse(intelligence.prepare('SELECT evidence_article_ids FROM autonomy_predictions').get().evidence_article_ids), [7]);
});
test('replay evidence cannot look beyond its information cutoff and remains non-executable', () => {
const archive = new Database(':memory:');
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY, pub_date_effective TEXT, pub_date TEXT, ingested_at TEXT)');
archive.prepare("INSERT INTO articles VALUES (7, '2020-01-01T00:00:00Z', NULL, '2020-01-01T00:00:00Z')").run();
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
intelligence.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA', 'test', 1, 1)").run();
assert.throws(() => acceptProposal(intelligence, archive, {
predictions: [{ instrument: 'NVDA', direction: 'positive', event_type: 'earnings', horizon_days: 10, evidence_article_ids: [7] }],
}, { informationCutoff: '2019-12-31T00:00:00Z', origin: 'replay', replayRunId: 1 }), /missing evidence/);
const proposal = intelligence.prepare("INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', datetime('now'), 'accepted')").run();
const prediction = intelligence.prepare(`INSERT INTO autonomy_predictions
(proposal_id, instrument, direction, event_type, horizon_days, information_cutoff, evidence_article_ids, strategy_version, origin)
VALUES (?, 'NVDA', 'positive', 'test', 10, datetime('now'), '[7]', 'test', 'replay')`).run(proposal.lastInsertRowid);
intelligence.prepare("INSERT INTO autonomy_decisions(prediction_id, action, rationale, strategy_version) VALUES (?, 'BUY', 'test', 'test')").run(prediction.lastInsertRowid);
const executable = intelligence.prepare(`SELECT d.id FROM autonomy_decisions d JOIN autonomy_predictions p ON p.id=d.prediction_id
WHERE d.action IN ('BUY','SELL') AND p.origin='live'`).all();
assert.equal(executable.length, 0);
});
test('replay scheduler skips terminal replay jobs instead of pinning the cursor', () => {
const archive = new Database(':memory:');
archive.exec(`
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
content TEXT,
pub_date_effective TEXT,
is_index_page INTEGER
)
`);
archive.prepare("INSERT INTO articles VALUES (1, 'bad', '', 'content', '2020-01-01T00:00:00Z', 0)").run();
archive.prepare("INSERT INTO articles VALUES (2, 'next', '', 'content', '2020-01-02T00:00:00Z', 0)").run();
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
const runId = intelligence.prepare(`
INSERT INTO autonomy_replay_runs (watermark_at, strategy_version, prompt_version, coordinator_model)
VALUES ('2020-01-03T00:00:00Z', 'test', 'test', 'test')
`).run().lastInsertRowid;
enqueueJob(intelligence, {
jobType: 'replay_article', lane: 'historical', priority: 1, entityType: 'article', entityId: 1,
idempotencyKey: `replay:${runId}:article:1`,
});
intelligence.prepare("UPDATE autonomy_jobs SET status='dead_letter', attempts=5, last_error='fetch failed'").run();
const run = intelligence.prepare('SELECT * FROM autonomy_replay_runs WHERE id=?').get(runId);
const next = scheduleNext(intelligence, archive, run);
assert.equal(next.id, 2);
assert.equal(intelligence.prepare('SELECT cursor_article_id FROM autonomy_replay_runs WHERE id=?').get(runId).cursor_article_id, 1);
assert.equal(intelligence.prepare("SELECT COUNT(*) count FROM autonomy_jobs WHERE status='pending' AND entity_id='2'").get().count, 1);
});
test('calibration and policy abstain on insufficient evidence', () => {
const calibration = calibrateOutcomes([
{ excess_return: 0.02, direction_correct: 1 },
{ excess_return: -0.01, direction_correct: 0 },
]);
assert.equal(calibration.sampleSize, 2);
const result = decide({ ...calibration }, { minSampleSize: 30 });
assert.equal(result.action, 'ABSTAIN');
assert.equal(cohortKey({ sector: 'tech', eventType: 'earnings', horizonDays: 10, direction: 'positive' }), 'tech|earnings|10|positive');
assert.equal(decide({ direction: 'negative', probability: 0.8, expectedExcessReturn: -0.02, lowerReturn: -0.04, sampleSize: 40 }).action, 'SELL');
});
test('historical replay outcomes create replay calibration snapshots', () => {
const db = new Database(':memory:');
initAutonomySchema(db);
const proposal = db.prepare("INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', '2020-01-01T00:00:00Z', 'accepted')").run();
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 (?, 'NVDA', 'positive', 'earnings', 10, '2020-01-01T00:00:00Z', '[1]', 0, 'test', 'replay', 7, 'resolved')
`).run(proposal.lastInsertRowid);
db.prepare(`
INSERT INTO autonomy_outcomes(prediction_id, excess_return, direction_correct)
VALUES (?, 0.04, 1)
`).run(prediction.lastInsertRowid);
assert.equal(refreshHistoricalCalibration(db, 'test-cal'), 1);
const snapshot = db.prepare("SELECT source, replay_run_id, sample_size, directional_probability FROM autonomy_calibration_snapshots").get();
assert.equal(snapshot.source, 'replay');
assert.equal(snapshot.replay_run_id, 7);
assert.equal(snapshot.sample_size, 1);
assert(snapshot.directional_probability > 0.5);
});
test('live decisions map calibration snapshot fields into policy inputs', () => {
const db = new Database(':memory:');
initAutonomySchema(db);
const proposal = db.prepare("INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', datetime('now'), 'accepted')").run();
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, status)
VALUES (?, 'NVDA', 'positive', 'earnings', 10, datetime('now'), '[1]', 1, 'test', 'live', 'open')
`).run(proposal.lastInsertRowid);
db.prepare(`
INSERT INTO autonomy_calibration_snapshots
(cohort_key, sample_size, effective_sample_size, directional_probability, expected_excess_return,
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')
`).run();
assert.equal(createDecisions(db), 1);
const decision = db.prepare('SELECT * FROM autonomy_decisions WHERE prediction_id=?').get(prediction.lastInsertRowid);
assert.equal(decision.action, 'BUY');
assert.equal(decision.calibrated_probability, 0.7);
assert.equal(decision.expected_excess_return, 0.02);
});
test('paper execution is allowlisted, bounded and idempotent', () => {
const intent = validatePaperIntent({ decisionId: 12, instrument: 'NVDA', action: 'BUY', notional: 100 }, { tradable: true, maxNotional: 500 });
const broker = createSimulator();
assert.deepEqual(broker.submit(intent), broker.submit(intent));
assert.throws(() => validatePaperIntent({ decisionId: 13, instrument: 'PRIVATE', action: 'BUY', notional: 100 }, { tradable: false }));
assert.throws(() => validatePaperIntent({ decisionId: 14, instrument: 'NVDA', action: 'BUY', notional: 501 }, { tradable: true, maxNotional: 500 }));
});
test('archive reconciliation prioritizes recent usable events and is bounded', () => {
const archive = new Database(':memory:');
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY, event_id INTEGER, ingested_at TEXT, content TEXT, has_embedding INTEGER)');
archive.prepare('INSERT INTO articles VALUES (1, 99, ?, ?, 1)').run(new Date().toISOString(), 'content');
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
const result = reconcileArchiveBatch(archive, intelligence, 1);
assert.equal(result.scanned, 1);
const job = intelligence.prepare("SELECT lane, priority FROM autonomy_jobs WHERE job_type='coordinator_event'").get();
assert.equal(job.lane, 'live');
assert.equal(job.priority, 100);
const live = reconcileLiveBatch(archive, intelligence, 1);
assert.equal(live.scanned, 1);
});
test('archive reconciliation recovers transient dead-lettered coordinator jobs', () => {
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
enqueueJob(intelligence, {
jobType: 'coordinator_event', lane: 'historical', priority: 10, entityType: 'event', entityId: 99,
idempotencyKey: 'coordinator_event:99',
});
intelligence.prepare(`
UPDATE autonomy_jobs
SET status='dead_letter', attempts=5, last_error='TypeError: fetch failed'
WHERE idempotency_key='coordinator_event:99'
`).run();
const result = enqueueCoordinatorEvent(intelligence, {
event_id: 99,
ingested_at: new Date().toISOString(),
content: 'content',
has_embedding: 1,
});
assert.equal(result.recovered, true);
const job = intelligence.prepare("SELECT status, lane, priority, attempts, last_error FROM autonomy_jobs WHERE idempotency_key='coordinator_event:99'").get();
assert.equal(job.status, 'pending');
assert.equal(job.lane, 'live');
assert.equal(job.priority, 100);
assert.equal(job.attempts, 0);
assert.match(job.last_error, /Recovered transient coordinator failure/);
});
test('archive reconciliation leaves non-transient coordinator dead letters alone', () => {
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
enqueueJob(intelligence, {
jobType: 'coordinator_event', lane: 'historical', priority: 10, entityType: 'event', entityId: 100,
idempotencyKey: 'coordinator_event:100',
});
intelligence.prepare(`
UPDATE autonomy_jobs
SET status='dead_letter', attempts=5, last_error='no tradable instruments are allowlisted'
WHERE idempotency_key='coordinator_event:100'
`).run();
const result = enqueueCoordinatorEvent(intelligence, {
event_id: 100,
ingested_at: new Date().toISOString(),
content: 'content',
has_embedding: 1,
});
assert.equal(result.recovered, false);
const job = intelligence.prepare("SELECT status, attempts, last_error FROM autonomy_jobs WHERE idempotency_key='coordinator_event:100'").get();
assert.equal(job.status, 'dead_letter');
assert.equal(job.attempts, 5);
assert.equal(job.last_error, 'no tradable instruments are allowlisted');
});
test('outcome calculation uses benchmark-relative return', () => {
const outcome = calculateOutcome(
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 5, direction: 'positive' },
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-09', close: 110 }],
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-09', close: 105 }]
);
assert.equal(outcome.directionCorrect, 1);
assert.equal(outcome.excessReturn, 0.05);
});
test('order intents require the explicit instrument allowlist', () => {
const db = new Database(':memory:');
initAutonomySchema(db);
db.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA', 'sim', 1, 1)").run();
const proposal = db.prepare(`INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', datetime('now'), 'accepted')`).run();
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) VALUES (?, 'NVDA', 'positive', 'test', 10, datetime('now'), '[1]', 1, 'test')`).run(proposal.lastInsertRowid);
const decision = db.prepare(`INSERT INTO autonomy_decisions(prediction_id, action, rationale, strategy_version) VALUES (?, 'BUY', 'test', 'test')`).run(prediction.lastInsertRowid);
const intent = createOrderIntent(db, decision.lastInsertRowid, 100, { maxNotional: 100 });
assert.equal(intent.side, 'buy');
assert.equal(db.prepare('SELECT status FROM autonomy_order_intents').get().status, 'shadow');
});