L2 gain was a bare running average with no contributor-count scaling, so a
policy's gain floor-locks below the promotion threshold once enough
negative-evidence traces accumulate: recent strongly-positive evidence can
never lift it back over the line, the candidate is never promoted, and the
candidate pool grows without bound.
Score gain as clamp(N·V, -1, 1) — N the number of contributing episodes, V
the normalized reward credit — and persist it alongside V:
- Persist a contribution-adjusted `gainValue` per trace with versioned
provenance (`live_normalized` / `inferred_normalized` / `legacy_unscaled`
/ unresolved), so partially-converted state is never observable.
- Add an idempotent, bounded historical-inference pass that runs once per
boot, before the repair timer and its consumers start. Both bounds are
config-tunable for hosts with a large backlog.
- Add a budgeted, timer-driven repair engine that drains the candidate and
active backlog on a configurable cadence, instead of requiring a bulk
one-shot migration.
- Add a read-only preview RPC (`policies.gainPreview`) and a policy-field
CAS rollback RPC (`policies.gainRollback`) for safe operability.
- Migration 019 is schema-only: new columns plus the repair queue and
journal tables. No data conversion, queue seeding, or policy/trace writes
at migration time — all of that happens in the idempotent passes above.
Everything is gated behind `algorithm.l2Induction.gainV2Enabled` (default
false): an upgraded config is a no-op until an operator turns it on.
Summary
Separates policy
gain(used for repair-eligibility/promotion decisions) from normalized reward creditV, and fixes a real promotion stall: with the current scheme,gainis a bare running average with no contributor-count scaling, so once enough negative-evidence traces accumulate, a policy's gain floor-locks below the promotion threshold even when recent evidence is strongly positive — candidates never get a fair look and the pool grows unbounded.This is a deeper fix than #2364/#2367 (which only retunes
MIN_ADAPTIVE_BASELINE, a constant, without changing the underlying scaling). It:gainValueper trace, separate fromV, with versioned historical provenance (live_normalized/inferred_normalized/legacy_unscaled/ unresolved).core/reward/gain-inference.ts) that runs once per boot, before the repair timer/consumers start, so partially-converted state is never observable. Bound is config-tunable (gainInferenceBootMaxGroups/gainInferenceBootTimeBudgetMs) for hosts with a large backlog.core/memory/l2/gain-repair.ts) that drains the candidate/active backlog on a configurable cadence (gainRepairBatchSize,gainRepairIntervalMs,gainRepairMaxTotal) instead of requiring a bulk one-shot migration.policies.gainPreview) and a policy-field CAS rollback RPC (policies.gainRollback) for safe operability.019-policy-gain-value.sql): adds columns + the repair queue/journal tables. No data conversion, no queue seeding, no policy/trace writes at migration time — all of that happens in the idempotent TS passes above.All of this is gated behind
algorithm.l2Induction.gainV2Enabled(defaultfalse) — a blank/upgraded config is a no-op until explicitly turned on.Burned in on three internal hosts at increasing corpus scale (the largest ~1.8k policy groups / ~28k traces) with zero errors/conflicts/failed across every observed repair tick, and real candidate→active promotions where the fleet had produced none in 16–69 days prior.
Test plan
npm run lint(tsc --noEmit) — clean, no new errorsnpx vitest run— all green across the suites that cover this change (memory/l2, reward, storage, pipeline, config, bridge, agent-contract): 65 files / 611 tests passing. The only files that cannot execute in this environment areadapters/deepseek-harness-*, which need private@deepseek-ai/*packages; they are excluded from the counts rather than reported as failures.tests/unit/memory/l2/,tests/unit/reward/,tests/unit/storage/gain-repair-migration.test.ts,tests/unit/pipeline/gain-repair-*.test.ts,tests/unit/bridge/methods.test.ts,tests/unit/agent-contract/gain-maintenance-contract.test.ts,tests/unit/storage/owner-exact.test.ts,tests/unit/config/load.test.ts— all pass