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

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