diff --git a/scripts/seed-forecasts.mjs b/scripts/seed-forecasts.mjs index ca45caa50..a4f91a026 100644 --- a/scripts/seed-forecasts.mjs +++ b/scripts/seed-forecasts.mjs @@ -16041,6 +16041,7 @@ if (_isDirectRun) { lockTtlMs: 180_000, validateFn: (data) => Array.isArray(data?.predictions) && data.predictions.length > 0, declareRecords, + sourceVersion: 'detectors+llm-pipeline', schemaVersion: 1, maxStaleMin: 90, publishTransform: buildPublishedSeedPayload, @@ -16571,16 +16572,22 @@ local raw = redis.call('GET', KEYS[1]) if not raw then return 'MISSING' end local ok, payload = pcall(cjson.decode, raw) if not ok then return 'MISSING' end -if type(payload.predictions) ~= 'table' then return 'MISSING' end +-- Envelope-aware: PR #3097 wraps canonical writes via runSeed in {_seed, data}. +-- Legacy bare values (older snapshots) still pass through. Detect once + unwrap; +-- re-wrap on write so the envelope shape persists. +local enveloped = type(payload._seed) == 'table' and type(payload.data) == 'table' +local inner = payload +if enveloped then inner = payload.data end +if type(inner.predictions) ~= 'table' then return 'MISSING' end local runTs = tonumber(ARGV[1]) or 0 -local pubTs = tonumber(payload.generatedAt) or 0 +local pubTs = tonumber(inner.generatedAt) or 0 if runTs > 0 and pubTs > runTs then return 'SKIPPED:' .. tostring(pubTs) end local ok2, decs = pcall(cjson.decode, ARGV[2]) if not ok2 then return 'MISSING' end local patched = 0 -for _, pred in ipairs(payload.predictions) do +for _, pred in ipairs(inner.predictions) do local id = pred.id local dec = decs[id] local newAdj = dec and tonumber(dec.simulationAdjustment) or 0 @@ -16595,7 +16602,12 @@ for _, pred in ipairs(payload.predictions) do end if patched == 0 then return 'UNCHANGED' end local ttl = tonumber(ARGV[3]) or 21600 -redis.call('SET', KEYS[1], cjson.encode(payload), 'EX', ttl) +if enveloped then + payload.data = inner + redis.call('SET', KEYS[1], cjson.encode(payload), 'EX', ttl) +else + redis.call('SET', KEYS[1], cjson.encode(inner), 'EX', ttl) +end return 'PATCHED:' .. tostring(patched) `.trim(); @@ -16622,14 +16634,23 @@ return 'PATCHED:' .. tostring(patched) */ async function redisAtomicPatchSimDecorations(url, token, canonicalKey, byForecastId, runGeneratedAt, ttlSeconds) { // ── Test path: JavaScript equivalent (no real concurrency in tests) ────────── + // Mirror the production Lua's envelope-aware unwrap/rewrap so test fixtures + // can exercise both legacy bare and PR-#3097 enveloped canonical shapes. if (_testRedisStore) { const published = _testRedisStore[canonicalKey] ?? null; - if (!Array.isArray(published?.predictions)) return 'MISSING'; + if (!published || typeof published !== 'object') return 'MISSING'; + // Match Lua's strict `type(payload._seed) == 'table'` / `type(payload.data) + // == 'table'` checks — any looser JS guard (e.g., truthy on `_seed: true`) + // would mask Lua regressions that bisect on fixture shape. + const enveloped = !!published._seed && typeof published._seed === 'object' && !Array.isArray(published._seed) + && typeof published.data === 'object' && published.data !== null && !Array.isArray(published.data); + const inner = enveloped ? published.data : published; + if (!Array.isArray(inner?.predictions)) return 'MISSING'; const runTs = typeof runGeneratedAt === 'number' ? runGeneratedAt : 0; - const pubTs = typeof published.generatedAt === 'number' ? published.generatedAt : 0; + const pubTs = typeof inner.generatedAt === 'number' ? inner.generatedAt : 0; if (runTs > 0 && pubTs > runTs) return `SKIPPED:${pubTs}`; let patched = 0; - for (const pred of published.predictions) { + for (const pred of inner.predictions) { const dec = byForecastId[pred.id]; const newAdj = dec ? Number(dec.simulationAdjustment || 0) : 0; const newConf = dec ? Number(dec.simPathConfidence ?? 0) : 0; @@ -16642,7 +16663,12 @@ async function redisAtomicPatchSimDecorations(url, token, canonicalKey, byForeca } } if (patched === 0) return 'UNCHANGED'; - _testRedisStore[canonicalKey] = JSON.parse(JSON.stringify(published)); + if (enveloped) { + published.data = inner; + _testRedisStore[canonicalKey] = JSON.parse(JSON.stringify(published)); + } else { + _testRedisStore[canonicalKey] = JSON.parse(JSON.stringify(inner)); + } return `PATCHED:${patched}`; } // ── Production path: Lua EVAL (atomic) ─────────────────────────────────────── diff --git a/tests/forecast-trace-export.test.mjs b/tests/forecast-trace-export.test.mjs index ef73ae38a..42efa7c71 100644 --- a/tests/forecast-trace-export.test.mjs +++ b/tests/forecast-trace-export.test.mjs @@ -8199,6 +8199,71 @@ describe('writeSimulationDecorations and applySimulationDecorationsToForecasts', assert.equal(store[CANONICAL_KEY].generatedAt, newerTs, 'canonical generatedAt preserved'); }); + it('WD-20b: redisAtomicPatchSimDecorations — patches inside seed-contract envelope ({_seed, data}) and preserves wrapper on write', async () => { + // Regression for the production bug observed 2026-04-23 in seed-forecasts-simulation + + // seed-forecasts-deep workers logging "Cannot patch canonical key — predictions missing + // or not an array" on every successful run. Root cause: PR #3097 (seed-contract envelope + // dual-write) wraps the canonical key as {_seed: {...}, data: {predictions: [...]}}, but + // the Lua patcher and JS test path read payload.predictions directly → returns 'MISSING' + // → simulation decorations never reach the canonical feed → ForecastPanel shows stale or + // missing simulation enrichment for entire forecasts. + const runTs = Date.now() - 2_000; + const store = { + [CANONICAL_KEY]: { + _seed: { fetchedAt: runTs, recordCount: 1, sourceVersion: 'detectors+llm-pipeline', schemaVersion: 1 }, + data: { + generatedAt: runTs, + predictions: [ + { id: 'fc-env-01', simulationAdjustment: 0, simPathConfidence: 0, demotedBySimulation: false, title: 'Enveloped 01' }, + ], + }, + }, + }; + __setRedisStoreForTests(store); + + const byForecastId = { 'fc-env-01': { simulationAdjustment: 0.21, simPathConfidence: 0.87, demotedBySimulation: true } }; + const status = await redisAtomicPatchSimDecorations('http://test', 'test', CANONICAL_KEY, byForecastId, runTs, 21600); + + assert.ok(status.startsWith('PATCHED:'), `expected PATCHED, got ${status}`); + // Envelope wrapper preserved on write — _seed metadata stays intact + assert.ok(store[CANONICAL_KEY]._seed, 'envelope _seed wrapper preserved on write'); + assert.equal(store[CANONICAL_KEY]._seed.recordCount, 1, '_seed metadata fields preserved'); + // Inner data patched + const inner = store[CANONICAL_KEY].data; + assert.equal(inner.predictions[0].simulationAdjustment, 0.21, 'sim adjustment applied through envelope'); + assert.equal(inner.predictions[0].simPathConfidence, 0.87, 'sim confidence applied through envelope'); + assert.equal(inner.predictions[0].demotedBySimulation, true, 'demotion applied through envelope'); + }); + + it('WD-20c: redisAtomicPatchSimDecorations — JS envelope guard matches Lua table check (truthy-non-object _seed falls through to bare path)', async () => { + // Parity regression for greptile PR #3348 P2. Lua uses + // `type(payload._seed) == 'table'`; if JS used `!!published._seed` instead, + // a fixture with `_seed: 'bogus'` (truthy but not an object) would make JS + // treat the payload as enveloped and look for `published.data.predictions`, + // while Lua would fall through to the bare path and read `payload.predictions`. + // Both paths must agree: non-table `_seed` → bare read. + const runTs = Date.now() - 1_000; + const store = { + [CANONICAL_KEY]: { + _seed: 'bogus-string-not-a-table', // truthy, but non-object + generatedAt: runTs, + predictions: [ + { id: 'fc-bare-01', simulationAdjustment: 0, simPathConfidence: 0, demotedBySimulation: false, title: 'Bare 01' }, + ], + // no `.data` field — bare shape + }, + }; + __setRedisStoreForTests(store); + + const byForecastId = { 'fc-bare-01': { simulationAdjustment: 0.33, simPathConfidence: 0.66, demotedBySimulation: true } }; + const status = await redisAtomicPatchSimDecorations('http://test', 'test', CANONICAL_KEY, byForecastId, runTs, 21600); + + // Must succeed via bare path — same behavior Lua would produce + assert.ok(status.startsWith('PATCHED:'), `expected PATCHED via bare path, got ${status}`); + assert.equal(store[CANONICAL_KEY].predictions[0].simulationAdjustment, 0.33, 'bare-path patch applied'); + assert.equal(store[CANONICAL_KEY]._seed, 'bogus-string-not-a-table', 'non-table _seed preserved untouched'); + }); + it('WD-21: writeSimulationDecorations skips side key and canonical patch when existing side key is from a newer run', async () => { // Scenario: run B (newer) has already written forecast:sim-decorations:v1. // run A (older) finishes late and calls writeSimulationDecorations — must not overwrite.