mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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) ───────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user