fix(forecasts): unwrap seed-contract envelope in canonical-key sim patcher (#3348)

* fix(forecasts): unwrap seed-contract envelope in canonical-key sim patcher

Production bug observed 2026-04-23 across both forecast worker services
(seed-forecasts-simulation + seed-forecasts-deep): every successful run
logs `[SimulationDecorations] Cannot patch canonical key — predictions
missing or not an array` and silently fails to write simulation
adjustments back to forecast:predictions:v2.

Root cause: PR #3097 (seed-contract envelope dual-write) wraps canonical
seed writes in `{_seed: {...}, data: {predictions: [...]}}` via runSeed.
The Lua patcher (_SIM_PATCH_LUA) and its JS test-path mirror both read
`payload.predictions` directly with no envelope unwrap, so they always
return 'MISSING' against the new shape — meeting the documented pattern
in the project's worldmonitor-seed-envelope-consumer-drift learning
(91 producers enveloped, private-helper consumers not migrated).

User-visible impact: ForecastPanel renders simulation-adjusted scores
only when a fast-path seed has touched a forecast since the bug landed;
deep-forecast and simulation re-scores never reach the canonical feed.

Fix:
  - _SIM_PATCH_LUA detects envelope shape (`type(payload._seed) == 'table'
    and type(payload.data) == 'table'`), reads `inner.predictions`, and
    re-encodes preserving the wrapper so envelope shape persists across
    patches. Legacy bare values still pass through unchanged.
  - JS test path mirrors the same unwrap/rewrap.
  - New test WD-20b locks the regression: enveloped store fixture, asserts
    `_seed` wrapper preserved on write + inner predictions patched.

Also resolves the per-run `[seed-contract] forecast:predictions missing
fields: sourceVersion — required in PR 3` warning by passing
`sourceVersion: 'detectors+llm-pipeline'` to runSeed (PR 3 of the
seed-contract migration will start enforcing this; cheap to fix now).

Verified: typecheck (both tsconfigs) clean; lint 0 errors; test:data
6631/6631 green (forecast suite 309/309 incl new WD-20b); edge-functions
176/176 green; markdown + version-check clean.

* fix(forecasts): tighten JS envelope guard to match Lua's strict table check

PR #3348 review (P2):

JS test path used `!!published._seed` (any truthy value) while the Lua
script requires `type(payload._seed) == 'table'` (strict object check).
Asymmetry: a fixture with `_seed: true`, `_seed: 1`, or `_seed: 'string'`
would be treated as enveloped by JS and bare by Lua — meaning the JS
test mirror could silently miss real Lua regressions that bisect on
fixture shape, defeating the purpose of having a parity test path.

Tighten JS to require both `_seed` and `data` be plain objects (rejecting
truthy non-objects + arrays), matching Lua's `type() == 'table'` semantics
exactly.

New test WD-20c locks the parity: fixture with non-table `_seed` (string)
+ bare-shape `predictions` → must succeed via bare path, identical to
what Lua would do.

Verified: 6632/6632 tests pass; new WD-20c green.
This commit is contained in:
Elie Habib
2026-04-23 20:38:11 +04:00
committed by GitHub
parent 54479feacc
commit 8278c8e34e
2 changed files with 99 additions and 8 deletions

View File

@@ -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) ───────────────────────────────────────

View File

@@ -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.