feat: let the coordinator see the relationship graph

The graph has been built for months and fed nothing but a dashboard. Nothing in
the autonomy pipeline has ever read an edge: not the coordinator, not
calibration, not execution. Its only downstream consumer, trade_signals, last
produced anything in April. It was roughly 70% of the llm bill and informed no
prediction, decision or order.

Relationships are the one piece of context a per-event coordinator genuinely
cannot derive from its own articles, because "this company supplies that one" is
knowledge about companies rather than about this event. So the coordinator now
receives the relationships of the companies the event is about, and is told to
name the relationship in causal_channel when it reasons through one.

The cutoff filter is the part that matters. first_seen_at on a relationship is
derived from article dates rather than processing time, so a historical proposal
only sees what the world had actually revealed by its own cutoff. Without that
this feature would quietly reintroduce the lookahead the evidence check exists to
prevent, and it is pinned by a test rather than left to review.

Relationships are explicitly background rather than evidence: predictions still
have to cite the article ids the story came from, and an instrument the articles
give no reason to care about is still not a prediction.

strategy_version moves to autonomy-2, because a prompt change this material
changes what a prediction means and the two populations should be comparable
later rather than silently blended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
ImBenji
2026-09-04 22:46:26 +01:00
co-authored by Claude Opus 5
parent 9b490c4d39
commit 9e6ef20560
3 changed files with 131 additions and 4 deletions
+76
View File
@@ -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 };
+45
View File
@@ -12,6 +12,8 @@ const { calculateOutcome, addTradingDays } = require('../src/autonomy/outcomes')
const { yahooSymbol } = require('../workers/outcomeAutonomyWorker'); const { yahooSymbol } = require('../workers/outcomeAutonomyWorker');
const { createOrderIntent } = require('../src/autonomy/orderIntents'); const { createOrderIntent } = require('../src/autonomy/orderIntents');
const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch, isTransientCoordinatorFailure } = require('../workers/autonomyWorker'); 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 { scheduleNext } = require('../workers/replayWorker');
const { refreshHistoricalCalibration, createDecisions, ensureCalibrationColumns } = require('../workers/calibrationWorker'); 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/); }, { informationCutoff: '2020-01-01T00:00:00Z' }), /missing evidence/);
assert.equal(intelligence.prepare('SELECT COUNT(*) AS n FROM autonomy_predictions').get().n, 0); 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);
});
+10 -4
View File
@@ -7,9 +7,14 @@ const { leaseNextJob, completeJob, failJob } = require('../src/autonomy/jobs');
const { callCoordinator } = require('../src/autonomy/llm'); const { callCoordinator } = require('../src/autonomy/llm');
const { acceptProposal, recordRejectedProposal, INSTRUMENT_RULES } = require('../src/autonomy/coordinator'); const { acceptProposal, recordRejectedProposal, INSTRUMENT_RULES } = require('../src/autonomy/coordinator');
const { EVENT_FAMILY_NAMES } = require('../src/autonomy/calibration'); const { EVENT_FAMILY_NAMES } = require('../src/autonomy/calibration');
const { buildGraphContext } = require('../src/autonomy/graphContext');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } 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() { function loadConfig() {
const configPath = path.resolve(process.env.DURIIN_CONFIG || path.join(__dirname, '..', 'config.json')); const configPath = path.resolve(process.env.DURIIN_CONFIG || path.join(__dirname, '..', 'config.json'));
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')); const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
@@ -20,11 +25,11 @@ function loadConfig() {
return raw; return raw;
} }
function buildPrompt(event, articles) { function buildPrompt(event, articles, graphContext = '') {
const evidence = articles.map((article, index) => 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)}` `[Evidence ${index + 1}] article_id=${article.id}\nTitle: ${article.title}\n${String(article.content || article.description || '').slice(0, 4000)}`
).join('\n\n---\n\n'); ).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: '<ticker supported by the articles>', direction: '<positive or negative>', instrument: '<ticker supported by the articles>', direction: '<positive or negative>',
event_type: '<one value from the event_type list below>', event_type: '<one value from the event_type list below>',
causal_channel: '<short description>', horizon_days: '<one of 1, 5, 10, 20, 30, 60, 90>', causal_channel: '<short description>', horizon_days: '<one of 1, 5, 10, 20, 30, 60, 90>',
@@ -60,14 +65,15 @@ async function runCoordinatorWorker({ archivePath, intelligencePath, workerId =
const informationCutoff = historical const informationCutoff = historical
? (articles.map((article) => article.pub_date_effective).filter(Boolean).sort().pop() || new Date().toISOString()) ? (articles.map((article) => article.pub_date_effective).filter(Boolean).sort().pop() || new Date().toISOString())
: 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 { try {
acceptProposal(intelligenceDb, archiveDb, raw, { acceptProposal(intelligenceDb, archiveDb, 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',
strategyVersion: 'autonomy-1', strategyVersion: STRATEGY_VERSION,
// only a genuine live lane job may ever feed learning // only a genuine live lane job may ever feed learning
origin: historical ? 'historical' : 'live', origin: historical ? 'historical' : 'live',
learningEligible: !historical, learningEligible: !historical,