diff --git a/src/autonomy/graphContext.js b/src/autonomy/graphContext.js new file mode 100644 index 0000000..70ae920 --- /dev/null +++ b/src/autonomy/graphContext.js @@ -0,0 +1,76 @@ +// Relationship context for the coordinator. +// +// The graph has been built for months and fed nothing but a dashboard: no +// prediction, decision or order has ever seen an edge. That is the one piece of +// context a per-event coordinator genuinely cannot derive from its own article +// set, because "NVDA supplies X, so a capacity story at X matters for NVDA" is +// knowledge about companies rather than about this event. +// +// The hard rule here is the cutoff. company_relationships.first_seen_at is +// derived from article dates rather than processing time, so filtering on it +// keeps a historical proposal from seeing a relationship the world had not yet +// revealed. Without that filter this feature would quietly reintroduce exactly +// the lookahead the evidence check exists to prevent. + +const MAX_COMPANIES = 3; +const MAX_PER_COMPANY = 8; + +function buildGraphContext(intelligenceDb, eventId, informationCutoff, options = {}) { + if (!eventId || !informationCutoff) return ''; + const maxCompanies = Number(options.maxCompanies) || MAX_COMPANIES; + const maxPerCompany = Number(options.maxPerCompany) || MAX_PER_COMPANY; + + try { + const companies = intelligenceDb.prepare(` + SELECT DISTINCT tc.id, tc.name, tc.ticker + FROM event_knowledge ek + JOIN tracked_companies tc ON tc.id = ek.company_id + WHERE ek.event_id = ? + LIMIT ? + `).all(eventId, maxCompanies); + if (!companies.length) return ''; + + const relationships = intelligenceDb.prepare(` + SELECT relationship_type, to_entity, confidence, confirmation_count + FROM company_relationships + WHERE from_company_id = ? + AND first_seen_at IS NOT NULL + AND datetime(first_seen_at) <= datetime(?) + ORDER BY confirmation_count DESC, id ASC + LIMIT ? + `); + + const blocks = []; + for (const company of companies) { + const edges = relationships.all(company.id, informationCutoff, maxPerCompany); + if (!edges.length) continue; + // de-duplicate on the entity name, the graph stores both casings for some + const seen = new Set(); + const lines = []; + for (const edge of edges) { + const key = `${edge.relationship_type}:${String(edge.to_entity || '').toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + lines.push(` - ${edge.relationship_type}: ${edge.to_entity}` + + ` (seen ${edge.confirmation_count}x, ${edge.confidence || 'unrated'})`); + } + const label = company.ticker ? `${company.name} (${company.ticker})` : company.name; + blocks.push(`${label}:\n${lines.join('\n')}`); + } + if (!blocks.length) return ''; + + return `Known company relationships, as they stood at the information cutoff:\n\n${blocks.join('\n\n')}\n\n` + + `These are background knowledge, not evidence. They exist so you can reason about second order effects: ` + + `a story about one company may be the tradable event for a supplier, customer or competitor. ` + + `If you use one, say so in causal_channel, and still cite the article ids the story itself came from. ` + + `Do not predict an instrument the articles give you no reason to believe is affected, and do not treat a ` + + `relationship as evidence on its own.`; + } catch (error) { + // Context is an enhancement. Losing it should never cost us the prediction, + // but it must never be lost silently either. + console.error(`[graph-context] unavailable for event ${eventId}:`, error.message); + return ''; + } +} + +module.exports = { buildGraphContext, MAX_COMPANIES, MAX_PER_COMPANY }; diff --git a/test/autonomy.test.js b/test/autonomy.test.js index 1817c10..f0e0dcd 100644 --- a/test/autonomy.test.js +++ b/test/autonomy.test.js @@ -12,6 +12,8 @@ const { calculateOutcome, addTradingDays } = require('../src/autonomy/outcomes') const { yahooSymbol } = require('../workers/outcomeAutonomyWorker'); const { createOrderIntent } = require('../src/autonomy/orderIntents'); const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch, isTransientCoordinatorFailure } = require('../workers/autonomyWorker'); +const { buildGraphContext } = require('../src/autonomy/graphContext'); +const { buildPrompt } = require('../workers/coordinatorWorker'); const { scheduleNext } = require('../workers/replayWorker'); const { refreshHistoricalCalibration, createDecisions, ensureCalibrationColumns } = require('../workers/calibrationWorker'); @@ -393,3 +395,46 @@ test('lookahead still rejects the whole proposal, not just one prediction', () = }, { informationCutoff: '2020-01-01T00:00:00Z' }), /missing evidence/); assert.equal(intelligence.prepare('SELECT COUNT(*) AS n FROM autonomy_predictions').get().n, 0); }); + +test('graph context never shows a relationship the world had not revealed yet', () => { + const db = new Database(':memory:'); + initAutonomySchema(db); + db.exec(` + CREATE TABLE tracked_companies (id INTEGER PRIMARY KEY, name TEXT, ticker TEXT, aliases TEXT); + CREATE TABLE event_knowledge (id INTEGER PRIMARY KEY, event_id INTEGER, company_id INTEGER, type TEXT, data TEXT, event_date TEXT); + CREATE TABLE company_relationships (id INTEGER PRIMARY KEY, from_company_id INTEGER, relationship_type TEXT, + to_entity TEXT, to_company_id INTEGER, confidence TEXT, confirmation_count INTEGER, first_seen_at DATETIME, + last_seen_at DATETIME, supporting_event_ids TEXT); + `); + db.prepare("INSERT INTO tracked_companies VALUES (1,'Nvidia','NVDA','[]')").run(); + db.prepare("INSERT INTO event_knowledge (event_id, company_id, type) VALUES (55, 1, 'x')").run(); + const edge = db.prepare(`INSERT INTO company_relationships + (from_company_id, relationship_type, to_entity, confidence, confirmation_count, first_seen_at) + VALUES (1, ?, ?, 'high', ?, ?)`); + edge.run('supplier', 'Taiwan Semiconductor', 9, '2025-01-01T00:00:00Z'); + edge.run('customer', 'Future Corp', 4, '2026-01-01T00:00:00Z'); + + // a proposal dated mid-2025 may only see what existed by then + const past = buildGraphContext(db, 55, '2025-06-01T00:00:00Z'); + assert.match(past, /Taiwan Semiconductor/); + assert.equal(/Future Corp/.test(past), false); + + // later, both are legitimately visible + const now = buildGraphContext(db, 55, '2026-06-01T00:00:00Z'); + assert.match(now, /Taiwan Semiconductor/); + assert.match(now, /Future Corp/); + + // an event we know nothing about contributes nothing rather than a stray header + assert.equal(buildGraphContext(db, 999, '2026-06-01T00:00:00Z'), ''); + assert.equal(buildGraphContext(db, 55, null), ''); +}); + +test('the coordinator prompt carries graph context only when there is some', () => { + const withCtx = buildPrompt({ id: 1, title: 't' }, [{ id: 9, title: 'a', content: 'c' }], + 'Known company relationships, as they stood at the information cutoff:\n\nNvidia (NVDA):\n - supplier: TSMC'); + assert.match(withCtx, /Known company relationships/); + assert.match(withCtx, /supplier: TSMC/); + + const without = buildPrompt({ id: 1, title: 't' }, [{ id: 9, title: 'a', content: 'c' }]); + assert.equal(/Known company relationships/.test(without), false); +}); diff --git a/workers/coordinatorWorker.js b/workers/coordinatorWorker.js index 1e4837b..9807b38 100644 --- a/workers/coordinatorWorker.js +++ b/workers/coordinatorWorker.js @@ -7,9 +7,14 @@ const { leaseNextJob, completeJob, failJob } = require('../src/autonomy/jobs'); const { callCoordinator } = require('../src/autonomy/llm'); const { acceptProposal, recordRejectedProposal, INSTRUMENT_RULES } = require('../src/autonomy/coordinator'); const { EVENT_FAMILY_NAMES } = require('../src/autonomy/calibration'); +const { buildGraphContext } = require('../src/autonomy/graphContext'); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +// bumped when the prompt changes in a way that changes what a prediction means. +// autonomy-2 is the first version that can see the relationship graph. +const STRATEGY_VERSION = 'autonomy-2'; + function loadConfig() { const configPath = path.resolve(process.env.DURIIN_CONFIG || path.join(__dirname, '..', 'config.json')); const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')); @@ -20,11 +25,11 @@ function loadConfig() { return raw; } -function buildPrompt(event, articles) { +function buildPrompt(event, articles, graphContext = '') { const evidence = articles.map((article, index) => `[Evidence ${index + 1}] article_id=${article.id}\nTitle: ${article.title}\n${String(article.content || article.description || '').slice(0, 4000)}` ).join('\n\n---\n\n'); - return `Event title: ${event.title}\n\n${evidence}\n\nReturn JSON only in this shape:\n${JSON.stringify({ predictions: [{ + return `Event title: ${event.title}\n\n${evidence}\n\n${graphContext ? `${graphContext}\n\n` : ''}Return JSON only in this shape:\n${JSON.stringify({ predictions: [{ instrument: '', direction: '', event_type: '', causal_channel: '', horizon_days: '', @@ -60,14 +65,15 @@ async function runCoordinatorWorker({ archivePath, intelligencePath, workerId = const informationCutoff = historical ? (articles.map((article) => article.pub_date_effective).filter(Boolean).sort().pop() || new Date().toISOString()) : new Date().toISOString(); - const raw = await callCoordinator(config, buildPrompt(event, articles)); + const graphContext = buildGraphContext(intelligenceDb, event.id, informationCutoff); + const raw = await callCoordinator(config, buildPrompt(event, articles, graphContext)); try { acceptProposal(intelligenceDb, archiveDb, raw, { eventId: event.id, informationCutoff, model: config.openRouter.llmModel || 'unknown', promptVersion: 'coordinator-1', - strategyVersion: 'autonomy-1', + strategyVersion: STRATEGY_VERSION, // only a genuine live lane job may ever feed learning origin: historical ? 'historical' : 'live', learningEligible: !historical,