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