Every engine version. Every change. Every accuracy review.
Current production version: v1.6.111.
144 releases on record.
Every change documented. Every version archived.
SignalTuned doesn't ship "the latest rankings." It ships a specific, versioned engine
with every parameter spelled out. When a parameter changes, the version increments and the
change shows up here with the reason. When the engine runs an accuracy review against
real outcomes, the results show up here too.
Every ranking the engine produces carries a manifest_hash tied to one of
these versions. Click the hash on any rankings page to see which engine version
generated that output.
DYNASTY H3b buried-depth-chart discount SHIPPED (ports the redraft H3b gate; audit P1 'port the three redraft-only gates to dynasty'). Rostered RB/WR/TE listed at Sleeper depth_chart_order >= 4 (4th-string or deeper) get weighted_ppg x 0.90 (10% cut) before LTV; QB excluded (DISC-Q2 handles QB depth). New buried_depth_discount config block + schema entry (schema entry added up front to avoid the v1.6.110 te_use_v2_ltv dead-flag trap), _apply_buried_depth_discount in dynasty_engine.py wired as step 2g-ter after fa_status_discount.
SNAPSHOT-GUARDED (returns early when FF_SNAPSHOT_AS_OF_SEASON is set): depth_chart_order is a point-in-time live signal, so it never runs in backtests/walk-forward and the caches are byte-identical content (re-stamped to v1.6.111, not behavior-rebuilt). Diverges from the unguarded fa_status_discount sibling on purpose: team affiliation is season-stable, depth-chart order is not.
VALIDATION (live-board OFF/ON, scripts/validate_buried_depth_dynasty.py; NOT ab_harness-gateable since snapshot-guarded): cohort = 185 deep backups (highest rank 122, Parker Washington), 0 discounted players in the top 60 (no startable asset touched), named cases Aiyuk #242->263 + Tank Dell #263->277 discounted as expected. Factor held at 0.90 (vs the 0.85 proposal) because June depth charts are offseason-provisional and a few high-capital ascending sophomores (Troy Franklin, Jack Bech, Kayshon Boutte whom the engine's own YPRR tier rates elite) sit in the cohort.
SCOPE NOTE: the other two 'redraft-only gates' from the backlog were NOT ported — H7 missed-year-0 is already covered (and stronger, 0.50 vs 0.20) by _apply_stale_player_decay; DISC-R4 young-rushing-QB is a QB lever deferred to the Sept 2026 QB pass.
TE LTV formula: te_use_v2_ltv -> false (TE switched from v2 relative-factor LTV back to v1 absolute). Audit A4/A11 + DIAG-1 pt3 found dynasty LTV lost to raw weighted_ppg 4/4 ref years at TE, the position running v2 unconditionally; v2's relative-factor flattens the vet-heavy TE field.
ACCEPTED TRADEOFF: ab_harness has no named-case guard; v2's only virtue was protecting ascending elite TEs (Bowers 15.31->13.87, Kelce class), which v1 demotes. Shipped per the field-accuracy gain (Ryan's call).
PREREQUISITE FIX: te_use_v2_ltv was read in age_curves._ltv_row but ABSENT from engine_config.schema.json; under additionalProperties:false any attempt to set it invalidated the config and _ltv_row's try/except silently fell back to the hardcoded v2 default — the gate was unsettable. Added to schema this session; the ab_harness sentinel caught it (zero rows differed until the schema fix). Setting it false is the behavior change; walk-forward caches REBUILT (TE ltv_discounted changes), hence this version bump.
PHASE 4 MODIFIER RETIREMENTS (routing memo final two; verdicts from scripts/validate_modifier_routing.py, the eval_lib-gated re-test built this session — both TIE, route-only-on-SHIP rule applies; 2 rows in docs/experiments_ledger.jsonl):
trajectory_momentum RETIRED via enabled=false, knobs kept: ltv-routing re-test TIE (headline Δrank-MAE -0.036 CI95 [-0.082, +0.011] on unconditional multi-year realized total points, touched slice -0.31, 4/7 ref years favorable — direction positive but CI crosses zero). Re-testable at a future recal; production_delta stays live as a descriptive signal (recommendations narrative, explain_engine MOMENTUM).
contract_year BOOST retired, FLAG kept: re-test TIE with the touched slice wrong-direction (+0.16 Δrank-MAE on only 49 touched top-N rows across ref 2018-2024). _apply_contract_year_modifier now flag-only (contract_year_boost stamped 0.0 for payload compat); boost_pct/_boost_note removed from config+schema; ltv_explainer + app.js LAYER_INFO boost entries retired; app.js signal chip reads the flag. Extension-event machinery (news layer Class B) stays — flag + extension are display-layer together per memo §2.
Both modifiers multiplied dynasty_ppg only (dead on production rank, DIAG-3), so walk-forward RANKS are unchanged by construction — but dynasty_ppg display values move where the boosts fired, hence cache REBUILD (not re-stamp) + this version bump.
DEAD-MODIFIER RETIREMENT (modifier routing memo, approved by Ryan 2026-06-05; audit A5 + DIAG-3). Four dynasty_ppg-only modifiers that never reached production rank (rank sorts on ltv_discounted; pre-retirement kill-test: scheme_fingerprint OFF -> ref2024 rebuild -> RANK DIFFS: 0) removed as behavior-preserving cleanup:
scheme_fingerprint RETIRED OUTRIGHT: _apply_scheme_fingerprint_modifier deleted, config block + schema removed, scheme_modifier payload field + ltv_explainer entry + app.js LAYER_INFO entry removed. Descriptive scheme_label/scheme_pass_rate columns from metrics.py survive. Audit A9: stale-equilibrium signal; vacated-opportunity feature is the planned replacement.
snap_opportunity BOOST retired, FLAG kept: _apply_snap_opportunity_boost deleted; suppression_boost_pct removed from config+schema. snap_opportunity_flag (metrics.py) unchanged on payloads/UI.
qb_dependency BOOST retired, FLAG kept: poor-CPOE suppression boost (Step 3) deleted from _apply_qb_dependency_flag; cpoe_poor_threshold/suppression_boost/min_targets_for_boost removed from config+schema. qb_dependency_risk flag + ltv_p25 widening remain live.
yprr modifier retired, tier label kept: _apply_yprr_modifier renamed _classify_yprr_tier (descriptive elite/below_avg only); elite_boost_pct/below_boost_pct removed from config+schema. Re-introduction as engine math requires re-SHIP on ltv rank after the harness target re-alignment.
young_qb_ceiling DEFERRED to Sept 2026 QB pass; trajectory_momentum + contract_year re-tests gated on harness target re-alignment (memo sequencing rule). No engine math changed on any live path — walk-forward ranks byte-identical by construction.
GHOST-PLAYER HARD FILTER (engine audit 2026-06-05 A10 + DIAG-3 confirmed bug; docs/ENGINE_AUDIT_2026-06-05.md). New ghost_player_filter config block + dynasty_engine._apply_ghost_player_filter (step 2g-pre, before stale decay): drops any non-rookie whose last played season is >= max_seasons_missing (2) behind the dataset's latest season. Root cause: the walk-forward runner injects ALL stats <= ref_year, so decades-retired players (Aikman/Marino/McNair/Couch/Harrington at ref2024 QB ranks 43-53, LTV 17-21) entered the pool and the stacked soft discounts (stale 0.5 x FA 0.5 = 0.25) could not remove them. Live path was already protected by load_active_players thresholds (code-verified) - this is primarily an eval-universe + FA-pool correctness fix. Deliberately NOT snapshot-guarded: walk-forward caches CHANGE and must be REBUILT (--rebuild --force), not re-stamped. Division of labor preserved: 1 missing season = stale_player_decay 0.5x; 2+ missing seasons = dropped.
A8 DUPLICATE-DEF FIX (monte_carlo.py): deleted the dead first compute_player_ltv_bands definition (~line 1178, config-driven model) that was shadowed by the live second definition (~line 1397, hand-set constants). No behavior change - the live model is unchanged. player_ltv_bands config _note updated to state honestly that the block is NOT read by the live band model (constants hardcoded in monte_carlo.py); wiring config -> live model deferred to the D9 band-coverage calibration, which will refit the constants anyway. Unblocks the D9 coverage script (it now measures the model production actually serves).
2026 DECLARED A LOCKED HOLDOUT (audit D2): no 2026 stat rows may feed any calibration/fit/tuning script. 2025 is burnt (rows already feed calibration). Declaration + seven stale-doc corrections landed in CLAUDE.md.
NEWS/SITUATION LAYER PHASE 2 APPLY + Class B math + arbitrage-surface news wiring (spec sections 4 + 5b; the engine now ACTS on real-world events, not just receives them).
(1) TEAM-CHANGE MULTIPLIER LIVE: dynasty_engine._apply_news_situation_adjustment (step 2h-bis) applies the calibrated team_change_deltas.json multiplier to players carrying an active trade/signing ledger event. Year-0-ONLY via the DISC-Q4 channel: projected_season_ppg + LTV horizon year 0 (news_year_0_factor, threaded in age_curves + the age-aware proj recompute, NaN-safe); years 1-6 stay at baseline because the calibration measured next-season PPG. Role tier = weighted_ppg rank within position (QB 12/24, RB 24/48, WR 36/72, TE 12/24 = calibration cells). Decay-to-1.0 linearly as post-event-season games accrue (decay_games=12) + hard apply_days=330 window. Confidence downgraded one tier while active. Rookies skipped; snapshot-guarded (FF_SNAPSHOT_AS_OF_SEASON) so backtests/walk-forward stay byte-identical -> caches re-stamped only.
(2) TWO-STAGE CELL GATING: calibration reliability was necessary but NOT sufficient. New scripts/validate_team_change_apply.py (mover-cohort walk-forward gate: applies multipliers to REAL engine projections ref 2018-2024, scores vs realized outcomes, paired bootstrap CI + A.J. Brown named-case). All-4-reliable-cells run: TIE (dMAE -0.044, CI crosses; RB.starter HURTS +1.02 MAE - the committee/stale-decay/RB-cliff machinery already prices RB moves, the raw multiplier double-counts; WR.starter TIE +0.10). SHIPPED CONFIG = cells allowlist [QB.mid, WR.mid]: n=38, MAE 3.832 -> 3.371, dMAE -0.461, CI95 [-0.844, -0.060] all-negative, bias +1.33 -> -0.07, A.J. Brown WR7 -> WR7 PASS (his WR.starter cell excluded - marquee consensus untouched) => VERDICT: SHIP.
(3) SUSPENSION MATH (Class B, locked): games-available arithmetic, factor = (17 - games)/17 on the same year-0 channel. Games parsed from event severity ('6') or note ('6 games'); unparseable -> flag-only. Skips players Sleeper already marks suspended (no double-count). Arithmetic, not modeling - no calibration gate.
(4) CONTRACT-EXTENSION RESET (Class B, locked): new 'extension' event type; _apply_contract_year_modifier suppresses the draft-round contract-year boost for players with an extension event within extension_suppress_years=3 (Drake London case - the heuristic would boost a player who just got paid). Snapshot-guarded; news_events.extended_player_ids.
(5) ARBITRAGE-SURFACE NEWS WIRING (the accepted Phase 0/1 gap, closed): news_events.arbitrage_suppression_flags (never-raise) consumed by arbitrage_engine.compute_signals (direction -> news_flagged + news payload; recs Buy-Low/Sell-High + Sell/Buy Windows inherit + new news_suppressed list), trade_simulator._build_hype_index (flagged players excluded from hype -> neutral tilt in Trade Finder + Trade Central match finder + ARBITRAGE ANGLES), market_inefficiency_engine.detect (consensus_direction -> news_flagged + NEWS note; undervalued/overvalued filters drop them). 'extension' events do NOT suppress (informational, not repricing).
(6) scheduler.run_daily_news_detect: trade/signing events now wipe + re-warm the rankings cache when team_change.enabled (board math moves same-morning, not at next organic rebuild).
News/situation layer Phase 1 (docs/SPEC_news_situation_layer_2026-06-04.md): the engine now RECEIVES real-world events. (1) news_detect.py diffs the Sleeper player feed daily against a stored snapshot (news_detect_state) and writes trade/signing/released/retirement events into the player_news_events ledger (source=sleeper_diff; engine-universe filtered; 3-day dedupe; cold-start snapshots silently). Detection rules calibrated on the live A.J. Brown trade + Russell Wilson retirement: team change -> trade/signing/released; retirement = active flag dropping on a team-less player (Sleeper does not mark retirement at event time, so manual admin events remain the fast path).
(2) RETIREMENT EXCLUSION in data_loader.load_active_players: players with a retirement event are dropped from the LIVE engine pool (dynasty + redraft boards via load_active_stats). Guarded: FF_SNAPSHOT_AS_OF_SEASON set (backtests/walk-forward) -> exclusion skipped, historical output byte-identical; ledger failure -> pool unfiltered. Un-retire = DELETE the ledger event.
(3) scheduler.run_daily_news_detect (03:15 ET daily + JOB_REGISTRY manual trigger): runs the diff; any retirement event wipes + re-warms the rankings cache so the exclusion bites the same morning.
Rankings content changes ONLY when a retirement event exists (today: Russell Wilson drops from live boards). Backtest/walk-forward output unchanged by the snapshot guard -> caches re-stamped to v1.6.103. Phase 2 (calibrated team-change multipliers) staged separately. Files: news_detect.py (new), news_events.py, data_loader.py, scheduler.py, engine_config.json.
MARKET ARBITRAGE RELIT on the first-party VibeRank crowd source. market_layer.enabled=true with NEW primary_source='crowd': market_layer.fetch() now reads the per-format VibeRank crowd board (crowd_store.resolve_fmt joins the league's dynasty/superflex/te_premium flags to its board) instead of third-party data — exact player_id reconciliation, 0..9999 crowd values, ATTRIBUTION_CROWD. dynasty_engine._load_market_values() gains the same crowd branch (rankings-row arbitrage_score/signal + league-card edge callout relight); legacy ktc-gated DB path retained but dark (ktc.enabled stays false; do not flip — its market_values table holds stale third-party rows).
Rank-based Signal Gap calibration (crowd_store.get_rankings): `signal` is now derived from gap_ranks = crowd_rank - engine_rank vs an adaptive threshold (max(4, 15% of the better rank); 2x = gap_big), replacing the value-scale subtraction that inflated mid-board gaps (market_seed is linear-in-rank, engine_score follows the value curve). Value-scale signal_gap stays on the payload as a diagnostic.
NEW: My Board (PureSignal, login-required) — GET /v1/crowd/my-board replays a user's logged crowd_votes through a fresh per-user aggregator (market_seed priors, full weight, lighter k=2 display shrink) and returns their personal board with vs_crowd / vs_engine rank diffs within the voted subset. /v1/crowd/* otherwise stays public.
Engine math (LTV / weighted_ppg / ordering) untouched — market layer remains diagnostic-only, so rankings CONTENT is unchanged; walk-forward caches re-stamped to v1.6.102 to satisfy the freshness gate. Version bump required so the config edit survives the start.sh image-vs-volume tie (v1.6.40/84/91/98 class). Files: crowd_store.py, crowd_rankings.py (none), routers/crowd.py, market_layer.py, dynasty_engine.py, routers/trade.py, routers/recommendations.py, static/crowd.html, engine_config.json, engine_config.schema.json.
STAGED (flag-OFF) the QB shrinkage elite-skip — primary lever for the +2.50 PPG QB backtest bias. Ported the redraft DISC-R8 elite-skip to dynasty: metrics.apply_shrinkage now accepts config.shrinkage.elite_skip {enabled:false, positions:[QB], elite_mult:1.0} and, when ON, skips Bayesian shrinkage for a thin-sample player whose weighted_ppg > elite_mult x positional prior (a demonstrated above-prior producer is durable, not a one-year wonder dragged toward a sub-replacement prior). Root cause per docs/SPEC_qb_shrinkage_fix_2026-06-02.md: the dominant of two shrinkage ops; young producing QBs (Daniels class) are the affected set. FLAG STAYS OFF: the candidate's QB CI crosses zero on the thin cohort (n~33), so ship only after 2026 actuals tighten it AND a clean full-engine backtest clears (3 prior QB attempts false-SHIP'd on proxies). Flag-OFF = no ranking change; walk-forward caches re-stamp identical (the proof). Files: metrics.py, engine_config.json, engine_config.schema.json.
Rookie calibration refreshed to the 2015-2024 cohort (was 2015-2023; refresh_rookie_baselines.py --cohort-end 2024) — folds 2024 draft-class outcomes in via 2025 season data; regenerates rookie_pick_bucket_baselines/combine/college deltas + historical_rookie_outcomes.parquet. Walk-forward caches rebuilt (2018-2024 ref years); full-engine rookie accuracy healthy (r 0.64-0.80, |bias_peak|<0.55, no regression).
Breakout-age trajectory layer REACTIVATED for live classes. breakout_age + senior_year_decline were 0% populated for 2024-2026 (static CSV column, no derivation pipeline); built scripts/derive_breakout_age.py (multi-season cfbfastR dominator -> age at first >=0.20 season; RB yards_per_team_play>=1.2; validated 95%+ vs historical column) + wired into refresh_rookies_post_draft.py; backfilled 2024-2026 to ~93%.
Trajectory betas RECALIBRATED (rookie_empirical._apply_beta_shrinkage_trajectory). The layer was the ONLY rookie delta layer not shrinking per-bucket betas toward the position fallback (combine + college both do), so small-n cells ran at full strength and produced board-breaking swings (WR R3 beta_senior_decline=+2.0, wrong-signed, drove +5 ppg onto low-production WRs). Fix: breakout_age now uses the robust NEGATIVE position fallback for all buckets (WR -0.52 / RB -0.31 / TE -0.50); senior_year_decline disabled (its fallbacks were wrong-signed). Surfaced by scripts/breakout_age_board_diff.py before ship.
SHIP gate = BIAS gate (scripts/ab_trajectory_breakout.py, paired ON-vs-OFF projection vs realized peak, cohort 2015-2024 n=572). MAE a well-powered TIE (agg dMAE -0.001, CI [-0.027,+0.027]). Breakout age reliably DE-BIASES rookie under-projection, concentrated in WR (OFF bias +0.576 [+0.099,+1.059] -> ON +0.409; paired shift CI [-0.21,-0.12] RELIABLE). RB/TE neutral. A centering win, not a discrimination win.
LTV discount_rate config corrected 0.863 -> 0.92 (documentation alignment, NO ranking change). DEAD KNOB diagnosed 2026-06-01: enrich_age_curves omits discount_rate from _ltv_kwargs, so the engine uses the module default age_curves.DISCOUNT_RATE=0.92 and NEVER the config 0.863 (proven by config sweep giving identical output + code trace). Reset config to 0.92 to match reality + added _discount_rate_note marking it dormant. Walk-forward caches re-stamp IDENTICAL (dead knob -> rankings provably unchanged; the identical rebuild is the proof). Version bump only so the config edit survives the start.sh image-vs-volume tie.
Outcome A/B (scripts/validate_discount_rate_change.py): the discount rate is a WEAK lever for RB-vs-WR ordering (flat at every rate 0.84-0.99) and the overall-Spearman signal that favored gentler rates is confounded by an undiscounted realized target (even perfect projections would show it), so the rate is not cleanly calibratable from outcomes. 0.92 retained; RB-vs-WR-via-discount hypothesis CLOSED. Open follow-up: wire the knob if a deliberate rate change is ever desired (needs full SHIP).
Market arbitrage PULLED from the platform: market_layer.enabled set false (ktc.enabled already false). Both market-data entry points now dark — the rankings/recs path (ktc-gated) and the trade-analyzer + market_inefficiency path (market_layer-gated, previously live on stale FantasyPros static CSVs). All downstream UI surfaces hide gracefully.
No engine math change: market consensus was always diagnostic-only (never modified LTV / weighted_ppg / rankings ordering). Walk-forward cache CONTENT is unchanged; caches re-stamped to v1.6.98 only to satisfy the freshness gate. Version bump required so the engine_config.json edit survives the start.sh image-vs-volume tie (same class as v1.6.40/84/91).
Rationale: the only IP-clean reachable market source (FantasyPros) is stale expert opinion, not true crowd sentiment; Sleeper has no public ADP endpoint; true crowd trade-value data is ToS-blocked (KTC/FantasyCalc class). Pivot to building our own crowd-sourced signal — see BACKLOG P1. Scaffolding kept dormant for relight.
value_over_replacement_ranking ENABLED (QB over-valuation fix). Cross-position Overall sort now blends raw-LTV pct with value-over-replacement pct (dynasty_vos scaled to lifetime), applied to rookies too. Format-conditional weight: 1QB vor_weight=0.7, SF vor_weight_superflex=0.3.
Validated by scripts/validate_vor_ranking.py (walk-forward Spearman vs realized value-over-replacement, 2018-2023): 1QB +0.0213 at w=0.7 (clears +0.010 bar; every weight beats pure-LTV, monotonic to +0.0348 at pure VOR). SF a wash (+0.0067 best at w=0.3); SF set to the mild 0.3 to demote replacement-level rookie QBs while preserving elite QBs (Allen #1, Lamar #6).
Effect: 1QB board now RB/WR-led (Gibbs/Bijan/Achane/Chase/Nacua top 5); Ty Simpson rookie #2 -> overall #195 (1QB) / #50 (SF). ltv_discounted magnitude unchanged; only the Overall ordering (blended_rank_score) changes.
Item A (PlayerProfiler Benchmark Round 3): rookie-year injury victims (acute_single_event, no prior healthy season) were misclassified chronic in the injury_discount path because _enrich_injury did not pass age_lookup into build_injury_index, so current_age was None and the acute branch (age<=25) never fired. Fixed: _enrich_injury now builds and passes age_lookup (parity with _apply_structural_ceiling).
Two-branch EMPIRICAL acute discount (both validated by scripts/validate_acute_injury_taper.py against realized forward availability; the 0.955 recovery curve over-projects BOTH cohorts and is retired for them, kept only as null-fallback). UNPROVEN (no prior healthy season, --cohort unproven n=28): realizes ~0.50; ship unproven_rookie_discount=0.50 (MAE 0.203 vs chronic 0.359, bias +0.004 CI contains 0). PROVEN (>=1 healthy season, --cohort proven n=27): realizes ~0.586; ship proven_recovery_discount=0.65 (MAE 0.234 vs chronic 0.431, bias +0.064 CI contains 0). Chronic under-projects both (bias -0.32/-0.40, CI excludes 0); 0.955 over-projects both (+0.46/+0.37).
Walk-forward cache rebuild REQUIRED before deploy (injury_discount affects dynasty rankings). Run scripts/rebuild_walk_forward_caches.py --rebuild --force locally and recommit the 7 ref-year JSONs.
Session 157 ext bug fix: keeper_horizon_years override was being silently swallowed for young players because age_horizon_caps cap=7 (default-horizon no-op marker) was binding via min(base_horizon, cap). Browser-validated symptom: setting Unlimited renewal (10) on Mystic produced byte-identical LTVs because every QB under age 32 had QB cap=7 from the smallest-key-ge-age lookup, and min(10, 7) clamped horizon to 7. The override silently failed.
Fix in age_curves.py::enrich_age_curves compose-effective-horizon logic: cap binds only when cap < HORIZON (real aged-player truncation). When cap >= HORIZON the cap value is a no-op marker for young players and base_horizon passes through. Aged-player caps (e.g., QB age 38 -> cap 4) still bind even under high base_horizon (Goff stays capped at 4 years regardless of league keeper window).
horizon=3 already worked correctly pre-fix because cap=7 > base=3, and min(3, 7) = 3 (base wins). horizon=10 was the actual broken case: cap=7 < base=10, min(10, 7) = 7. The fix preserves correct horizon=3 behavior and unblocks horizon=10.
Engine math change: 1-line condition added in age_curves.py compose-horizon block. _meta.version v1.6.94 -> v1.6.95. Walk-forward cache rebuild NOT required (same reason as v1.6.94 ship: baselines are format=dynasty, override path inert).
Session 157 ext (BACKLOG Item 10 close): per-league LTV horizon override for keeper-format leagues. New LeagueConfig.keeper_horizon_years field (Optional[int], None default) drives age_curves.enrich_age_curves(base_horizon=N) when set. Sleeper API does not expose this field; user enters their league's effective keeper window via the new Keeper rules modal in the UI.
Architectural rationale: Mystic and other keeper leagues had Rankings tab running dynasty 7-year LTV math regardless of actual keeper window. For unlimited-renewal keeper leagues (Mystic-style: pick N keepers per year, no max term) the 7-year approximation is fine. For finite-window keeper leagues (e.g. 2-year max), 7-year LTV materially over-projects tail production for aging veterans. The user-input field bridges the spectrum.
UI dropdown options: 1-9 years (literal max keeper window) + 'Unlimited renewal' (stores 10 internally, captures ~85% of true-infinity effect without going far past calibration regime). Default empty = engine default of 7. Round-trip: stored value pre-populates the dropdown on next open.
Engine math: when config.format=='keeper' and keeper_horizon_years is set, _apply_framework passes base_horizon kwarg to enrich_age_curves. age_horizon_caps still bind for aged players (effective_horizon = min(base_horizon, age_cap)). Goff at age 31 still gets capped to 7 by the 32-key cap; Maye at age 24 gets the full 10. No change to any other engine layer (Bayesian shrinkage, archetype mods, breakout lifts, scarcity multipliers).
API surface: POST /v1/leagues/{league_id}/keeper-settings accepts {keeper_horizon_years: 1-12 or null}, validates, updates DB, recomputes config_fp, pops the stale cache entry, and reruns rankings under the new horizon. 422 on non-keeper-format leagues (since the field has no semantic meaning for dynasty/redraft).
DB: keeper_horizon_years INTEGER NULL column added to leagues table via boot migration (idempotent ALTER). _config_fingerprint includes the field so a flip invalidates the rankings cache. _H2_DRIFT_FIELDS includes the field for the H2 drift detection on roster-sync.
Filed follow-up (deferred): paired-observation harness for horizon calibration. The 7-year base value was set early in the engine's history without strict empirical validation against long-horizon outcomes. Building one requires 6+ years of held-out actuals per player (i.e. 2018 cohort tested through 2024). Not blocking; the override gives users the lever immediately, math underneath remains the validated dynasty pipeline.
Walk-forward cache rebuild: NOT required for this ship. The 7 historical caches are stamped at the engine version and produced against Dynasdeez baseline (format=dynasty). The keeper_horizon override only fires when config.format=='keeper' AND keeper_horizon_years is set; baseline cache generation skips both gates. Math byte-identical for the cache rebuild path.
Session 157 (PP-Round-2 Item 5 ship): surgical bust-filter applied to rookie_pick_bucket_baselines.json on 8 cells: RB.R4_5, RB.R6_7_UDFA, WR.R3, WR.R4_5, WR.R6_7_UDFA, TE.R3, TE.R4_5, TE.R6_7_UDFA. Closes the late-round-bucket-median pollution class surfaced by the PP-Round-2 benchmark (engine ranked 5 named UDFA/Day-3 RBs (LeQuint Allen, Tahj Brooks, Kaytron Allen, Demond Claiborne, Ollie Gordon) 250-330 spots LOWER than PP because R6_7_UDFA RB median was 0.00, dragged to zero by 57.7% bust rate).
Filter definition: drop players with n_seasons_played == 0 (career-bust cohort, never had a season with 8+ NFL games) before computing median/mean/quantiles. bust_rate + n preserved on full cohort so downstream consumers can reconstruct the prior on roster-failure for prospects who haven't yet made an NFL team.
Preserved cells (explicitly NOT updated): RB.R2 + RB.R3 + WR.R2 (walk-forward-validated in Session 111 DISC-Q5-followup); RB.R1_top + RB.R1_late + WR.R1_top + WR.R1_late + WR.R2 + TE.R1_top + TE.R1_late + TE.R2 (career_avg-tuned in v1.6.15/v1.6.16 RESEARCH-11b, not peak_ppg); ALL QB cells (filter collapses QB late-round sample sizes from n=14-30 to n=2-10; remaining cohort is dominated by starter-caliber outliers, current median 0.00 for QB R3+ is defensible).
Combine + college delta JSONs (rookie_combine_deltas.json, rookie_college_deltas.json) unchanged. Those regressions fit (peak_ppg - bucket_median) against z-scored measurables on the full cohort; refitting under the bust filter would alter their semantics (busted players' combine + college data is real signal about what HASN'T predicted success).
Engine script change: scripts/refresh_rookie_baselines.py extended with --exclude-busts-positions CLI flag (default empty). Future annual rebuilds can pass --exclude-busts-positions RB,WR,TE to apply the filter automatically; QB intentionally excluded. compute_baselines() signature extended to accept exclude_busts_for: Optional[set].
Named-case smoke: all 5 PP-Round-2 RBs are R6_7_UDFA bucket. Their projected_peak_ppg lifts by +4.67 ppg (the bucket median shift). Combine + college deltas unchanged. Engine ranks should move 100-200 spots toward PP without becoming consensus-tuned.
Filed follow-up: bust-decomposition refactor (1-2 day structural fix). Store conditional median + bust probability separately per cell; engine layer interpolates based on live roster signal. Cleaner long-term solution than the surgical filter ship. No paired-observation harness exists for rookie projection accuracy yet - validation is via backtest accuracy + named-case sanity check.
Engine math change: bucket_median lift in 8 cells. _meta.version v1.6.92 -> v1.6.93. Walk-forward cache rebuild required on next push (math touches projected_peak_ppg for partial-volume rookies in affected buckets).
Session 157 (PP-Round-2 Item 2 ship): scarcity_elasticity_per_position bumped uniformly 0.4 -> 1.0 across QB/RB/WR/TE. Closes the 1QB QB over-ranking class surfaced by the PP-Round-2 benchmark (Mystic Dynasty top-14: 11 QBs vs PP's 2).
Harness verdict (scripts/validate_league_aware_ltv_decision_quality.py at elasticity 1.0, per_player_scaling=false): SHIP, aggregate Spearman delta = +0.0726 across 5 configs x 6 ref years. SF 10t baseline no-op at +0.0000 (invariant preserved). SF 12t worst at -0.0003 (well within DEFEND tolerance of -0.020). 1QB configs gain +0.116 to +0.127 Spearman (cross-config decision quality, top-150 scope).
Per-player scaling infrastructure shipped Session 157 Item 2 build (engine_config.league_aware_ltv.per_player_scaling block + dynasty_engine._apply_league_scarcity_to_ltv per-player branch + harness extension). Sweep verdict: DEFEND across all elasticity x anchor combinations. pos_top_vos lost ~50% of OFF improvement uniformly. pos_p75_vos was effectively equivalent to OFF (most top-150 players sit above p75 -> share clamps to 1.0). Architectural hypothesis from Item 1 was wrong-headed; harness is truth. Flag stays OFF in production.
Floor at min_multiplier=0.60 is now binding for 1QB QB (raw mult = (10/24)^1.0 = 0.417 clamps to 0.60). Future elasticity tuning beyond 1.0 requires a min_multiplier change. Filed as a follow-up.
Locked discipline: any future change to scarcity_elasticity_per_position OR per_player_scaling must re-pass scripts/validate_league_aware_ltv_decision_quality.py with SHIP verdict on the cross-config decision-quality cohort + SF baseline no-op invariant intact.
Docs-only version bump to force start.sh to pick up the v1.6.62-v1.6.84 version_history backfill committed earlier this session.
Root cause: start.sh's image-vs-volume comparison uses sort -V on _meta.version. When the image and volume share a version string (both v1.6.90), the 'keep volume' branch wins and content-only edits to engine_config.json silently get reverted on every deploy.
Same bug class as v1.6.40 (Session H DEGRADED hotfix) and v1.6.84 (Session 152 schema-validation outage). Engine math change: zero. The bump exists solely to make image > volume so start.sh copies the freshly-committed config into /app/data/.
New config knob engine_config.auction.positional_allocation_exponent (default 0.7) introduced in auction_engine.compute_auction_values. Applies a power-mean transform on the (player_ltv - replacement_ltv) terms in Step 1 (positional budget allocation), compressing steep-distribution positions (RB top-5 spike) and boosting flat-curve ones (QB clusters tightly).
Wired into routers/draft.py POST /v1/auction/values: reads engine_config.auction.positional_allocation_exponent at request time and passes to compute_auction_values; defensively defaults to 1.0 on config read failure.
Sweep harness scripts/validate_auction_allocation_exponent.py confirms directional movement: 1QB top QB price $28->$36 (+30%), 1QB QB allocation 5.2%->6.7%, SF top QB price $41->$44, SF QB allocation 17.5%->19.1%. RB share compresses from 46.1%->41.5% (1QB) and 40.0%->35.8% (SF). WR + TE shift modestly (no position breaks).
Magnitude-bounded: even at aggressive exp=0.5 (sweep extreme), SF QB allocation only reaches 19.8% vs BACKLOG-cited 25-30% target. Structural fix for full closure filed as follow-up - needs real-auction-price discipline data from MFL/FantasyPros to validate a more aggressive default or a complementary mechanism (e.g. qb_sf_bonus restore alongside the exponent).
New config block engine_config.wr_sophomore_upside (default enabled=false). When true, applies a years-to-peak multiplicative bonus on dynasty_ppg for ascending or early_peak WRs in R1_top/R1_late/R2 with 1-3 NFL seasons completed and weighted_ppg >= 8.0. Mirrors young_qb_ceiling architecture.
New method dynasty_engine._apply_wr_sophomore_upside (~115 LOC) wired into rankings pipeline at step 3c-bis (after _apply_young_qb_ceiling, before _apply_trajectory_momentum_bonus).
Schema entry added with all 9 properties + required list.
A/B harness scripts/validate_wr_sophomore_upside.py (320 LOC) - paired-observation MAE on n=110 historical WR Y1-Y3 ascending high-pedigree pairs (2016-2024).
VERDICT at default params (bonus_per_year=0.12, cap=1.30): DEFEND. Agg delta +0.605, CI95 [+0.210, +1.004] all-positive (candidate REGRESSES). R1_top + R2 + Y2 + early_peak slices all break at CI-significant level.
Parameter sweep across 9 (bonus_per_year, cap) variants (0.025-0.12 x 1.08-1.30): every variant TIE or DEFEND. No calibration earns SHIP.
Diagnostic: cohort is heterogeneous - half ascending high-pedigree WRs break out (JSN 22, Jefferson 21, A.J. Brown 20, Jaylen Waddle 22, JuJu 18), half regress to mean or get injured (Odell 16, Allen Robinson 16, Chase 23, Aiyuk 21, DK 21, A.J. Brown 21). The bonus mechanism stamps every eligible WR uniformly, can't distinguish breakouts from busts, and pulls predictions further from actuals on the bust class. Baseline (weighted_ppg x age_curve) is already MAE-tight at 2.40 ppg on this cohort.
Per locked discipline [[feedback_calibration_via_harness_only]] + [[feedback_consensus_is_signal_not_target]]: harness is the truth, engine math is statistically defensible. PlayerProfiler benchmark gap (JSN +43, Nacua +24, Nabers +119, etc.) reflects consensus heuristics that don't predict Y+1 outcomes better than our model.
Flag stays OFF in production. Infrastructure ships flag-gated as a documented 'tested, did not validate' result, mirroring Pattern 4 (v1.6.87) playbook. Future operators can re-test with different mechanism designs - e.g., add a target_share trajectory gate to distinguish role-trajectory ascenders from injury/role-disruption regressors, or use a per-player breakout-probability signal.
New config block engine_config.thin_sample_regression with dedup_with_unified_projection (default now true).
When true, _apply_thin_sample_regression in dynasty_engine.py excludes rows where apply_unified_projection (DISC-1, Session 110) already fired with w_data < 1.0. Eliminates a small-sample double-counting bug class: a 1-season non-rookie with a draft_pick was previously shrunk twice (once toward rookie_empirical bucket prior, then again toward live position median), landing partial-volume Y1 RBs ~30% below their bucket floor.
Hampton named case: weighted_ppg 9.28 -> 11.90 (raw 13.08, prior 11.40 for R1_late pick=22, w_data=0.30); committee discount layer unchanged.
A/B harness scripts/validate_thin_sample_dedup.py SHIP verdict on n=571 historical Y0->Y+1 partial-volume rookies (2014-2023). Agg MAE delta -0.182 CI95 [-0.354, -0.011] all-negative. WR n=230 SHIP-clean CI [-0.398, -0.068]. RB n=153 directionally consistent (-0.184) but CI crosses zero. No position regresses at CI-significant level. Per [[feedback_per_bucket_decomposition]]: agg + at least one position CI-clean negative + no CI-significant positive on any position satisfied.
Locked discipline: any future change to thin_sample_regression.dedup_with_unified_projection must re-pass scripts/validate_thin_sample_dedup.py with SHIP verdict.
Pattern 4 infrastructure (Session 154 ext): added engine_config.json::qb_nfl_viability_confidence block + dynasty_engine._apply_qb_viability_penalty() method + scripts/validate_qb_viability_penalty.py SHIP-gate harness. Default enabled=false; engine math BYTE-IDENTICAL to v1.6.86 until harness clears.
Math design: multiplies ltv_discounted for QBs by piecewise-linear interpolation over career pass attempts. Breakpoints [0, 100, 300, 500, 1000] -> penalties [0.55, 0.65, 0.80, 0.92, 1.00]. QBs with <4 career starts always get penalty_at_breakpoints[0] regardless of attempts. Applied AFTER enrich_age_curves and BEFORE _apply_young_qb_ceiling so the ramp + ceiling bonus stack on top of a dampened LTV.
Target cohort: McCarthy (3 career attempts, our v1.6.86 rank 50, PP 237) -> ~0.55x LTV penalty. Ty Simpson (0 attempts, college rookie) -> 0.55x. Drake Maye (~570 attempts post-Y1) -> ~0.93x, minimal change. Mahomes/Allen/Burrow class (5000+ attempts) -> 1.00x, no change.
SHIP gate: scripts/validate_qb_viability_penalty.py — paired-observation MAE on QB rookies/sophomores with <500 career attempts 2015-2024. Validates penalty closes Y+1 over-projection on McCarthy-class without regressing Mahomes-class.
Session 154 ext: Pattern 1 SHIP. Flipped engine_config.json::league_aware_ltv.enabled from false to true. The per-league scarcity multiplier (built Session 152, math-validated Session 153) now flows into ltv_discounted across the full ranking pipeline.
SHIP gate: scripts/validate_league_aware_ltv_decision_quality.py returned SHIP at default elasticity 0.40 + default scope top-150. Aggregate Spearman delta = +0.0312 across 5 configs x 6 ref years (2018-2023), 3x the +0.010 SHIP threshold. SF 10-team baseline = +0.0000 (no-op by design); SF 12t = +0.0006 (neutral); 1QB 10t = +0.0498; 1QB 12t = +0.0529; 1QB 14t = +0.0525. All three 1QB configs flipped from NEGATIVE Spearman OFF (worse than random vs true VOR) to POSITIVE Spearman ON. Top-50 hit-rate gain for 1QB users: +1.8pp consistently.
Trigger context: 2026-05-27 PlayerProfiler benchmark exposed that our engine moved players a mean of only 10.9 ranks between SF and 1QB configs vs PP’s 21.3 ranks; QBs shifted -4.0 in our engine (slightly UP in 1QB) vs PP’s +27.8 (DOWN in 1QB). Tua/Geno/Mac/Flacco class were rank-flat across configs in our engine, badly mis-ranked in 1QB. Pattern 1 closes most of that gap.
Production math change: 1QB league users will see top QBs drop ~50-80 ranks (multiplier ~0.76x at elasticity 0.40 for 1QB 12-team). SF baseline users see no change. SF 12-team / 14-team users see slight differentiation. TE Premium and scoring-format multipliers unchanged (separate code path).
Walk-forward caches must be regenerated against v1.6.86 because the engine math is changing for non-baseline configs. The harness verdict itself was computed using the cached LTV values + a post-hoc multiplier (no engine re-run needed for the verdict); but the production rankings now flow the multiplier through compute_replacement_from_slots -> _apply_league_scarcity_to_ltv at the dynasty pipeline step 5.
New SHIP-gate harness: scripts/validate_league_aware_ltv_decision_quality.py (471 LOC, supports --sweep-elasticity, --top-spearman-n, --full-spearman, --elasticity overrides). Default invocation reflects the decision-quality SHIP gate; --full-spearman emits the noisier all-player Spearman for debugging.
Session 154: closed the Session 151 P2 architectural fix. engine_config.json::age_curves is now the single source of truth for QB/RB/WR/TE age curves AND the new QB_POCKET / QB_DUAL archetype-split sub-curves.
engine_config.json::age_curves block rewritten with canonical REBUILT_* values that previously lived in age_curves_rebuilt.py. The pre-existing dead config block contained junk values (ages up to 56 for QB, 51 for RB, etc.) carried forward from an earlier auto-generated calibration pass; replaced with the actual production curves.
engine_config.schema.json::age_curves relaxed from rigid age-by-age enumeration with additionalProperties:false to patternProperties on integer-string ages. Junk ages 41/45/47/56 (QB), 42/46/48/50/51 (RB), 46/49/54 (WR), 47/49 (TE) removed from required-list. QB_POCKET + QB_DUAL added as optional position blocks.
age_curves.py refactored: module-level _CURVES, _QB_CURVE, _RB_BASE_CURVE, _WR_CURVE, _TE_CURVE, _QB_POCKET_CURVE, _QB_DUAL_CURVE now sourced from engine_config.json at module import via new _load_curves_from_config() + _load_qb_archetype_curves() helpers. Hardcoded literals preserved as defensive fallback when config load fails. FF_USE_REBUILT_CURVES env var deprecated (no-op).
age_curves_rebuilt.py converted to a thin compatibility shim: REBUILT_QB_CURVE, REBUILT_RB_BASE_CURVE, REBUILT_WR_CURVE, REBUILT_TE_CURVE, REBUILT_QB_POCKET_CURVE, REBUILT_QB_DUAL_CURVE, REBUILT_CURVES all now read from engine_config.json. Preserves backward compatibility for 7 downstream scripts (validate_curve_change, audit_calibrations, validate_archetype_mods, validate_qb_dual_curve_modern, validate_pwopr_lift, wf_age_7_ab_revalidation, recalibrate_age_curves) without touching their imports.
validate_curve_change.py::current_curve() now reads from engine_config.json directly (not via the shim). Closes the Session 151 bug class permanently: harness baseline = production source by construction.
Engine math: BYTE-IDENTICAL to v1.6.84 production. Same numeric values flow into _CURVES via the new load path. No predictions move. Walk-forward caches re-stamped to v1.6.85 metadata only (no recompute needed since math is unchanged).
Session 152: hotfix for a DEGRADED-mode outage. v1.6.83 added a required league_aware_ltv block to engine_config.schema.json without bumping the engine version past the prior volume-persisted config; start.sh's restore-from-volume path overwrote the new config with the old one, and schema validation failed at boot.
Two-part fix: (a) bump engine version to v1.6.84 so start.sh stops overwriting the new config, (b) make league_aware_ltv optional in the schema so an older volume-persisted config doesn't fail validation.
Engine v1.6.83 -> v1.6.84. Math unchanged. Filed as feedback discipline: new required schema blocks stay optional until the engine version is bumped past the volume-persisted version.
engine_config.json::age_curves was found to be dead config: loaded by dynasty_engine.py:241 but never consumed for actual curve lookups. Live production path is age_curves.py::age_curve_factor -> _CURVES[pos] -> age_curves_rebuilt.py::REBUILT_*_CURVE.
Earlier Session 151 attempted to ship 18 'SHIP'd age-curve calibration updates (v1.6.79-82). Re-tested against the live REBUILT_* baseline after reverting validate_curve_change.py to its original age_curves_rebuilt.py source: all 4 position bundles returned DEFEND or TIE. The REBUILT_* production curves are MAE-defensible. Reverted engine_config.json::age_curves to v1.6.78 values + bumped _meta.version to v1.6.83 to supersede phantom v1.6.79-82 commits.
Engine math: IDENTICAL to v1.6.78 production behavior. No curve values changed in the live production path. No predictions move. backtest_results.json metrics (MAE/r/bias) hold byte-identical between v1.6.78 and v1.6.83.
Filed P2 architectural fix: consolidate the dual config so age_curves.py reads from engine_config.json::age_curves via RankingsEngine.params, deprecating age_curves_rebuilt.py. Eliminates the bug class.
Also shipped: Task #19 last hole closed (/v1/trade/ai-recommendations now scopes verdict math to caller's league config); live overlay surfaces draft_type so auction users see 'Auction' label instead of misleading 'picks until me: 0'.
Catch-up note: version_history entries for v1.6.62 through v1.6.82 are missing (BACKLOG.md is the system-of-record for those sessions; consolidation into version_history filed as P3 hygiene).
Session 151: 18-of-18 age-curve calibration recommendations from the Session 150 backtest SHIPPED via validate_curve_change.py harness through versions v1.6.79, v1.6.80, v1.6.81, v1.6.82 (incremental version bumps in one commit).
Walk-forward caches refreshed to v1.6.82 after recalibration. RB/WR/TE age-curve values written to engine_config.json::age_curves block.
Subsequently reverted in v1.6.83 after the dual-config drift was discovered: engine_config.json::age_curves was loaded by dynasty_engine.py:241 but never consumed for actual curve lookups; production used age_curves_rebuilt.py::REBUILT_* values. Engine math change: zero in production despite the calibration ship.
Frontend Position Counts widget migrated to canonical position_health from the API payload. Removes the last consumer with its own bespoke thin/surplus computation.
Engine v1.6.77 -> v1.6.78. Closes the v1.6.76 unified-foundation migration arc.
Trade Targets migrated to the canonical _position_health foundation from v1.6.76. -19 LOC; replaced site-local thin/surplus synthesis with health[pos]['signal'] read.
Unified _position_health foundation. Three drifting roster-strength views (_weak_positions PPG-median based, _positional_grades A-F tier quality, _compute_position_targets count vs proportional) consolidated into one canonical RosterAnalyzer._position_health method.
Returns a per-position dict {count, target, lo, hi, grade, weak_by_ppg, count_status, signal} where signal synthesizes to 'thin' / 'surplus' / 'ok'. Surfaced on full_analysis() return as position_health so SPA + downstream consumers read one canonical view.
FA opportunities migrated as smoke proof. Engine v1.6.75 -> v1.6.76.
Apostrophe-class sibling fixes: League Overview and Trade Central had their own normalize() paths that needed the same apostrophe-preserving treatment as v1.6.74.
Closes the apostrophe-name-fallback bug class across all SPA surfaces.
Apostrophe name-fallback fix: Tre' Harris and D'Andre Swift class of names with embedded apostrophes were failing the name-match fallback path in _build_player_payload because the normalize() pass didn't preserve curly + straight apostrophe variants.
Unit tests added (tests/test_apostrophe_name_fallback.py) to lock the behavior. Class lockdown locks v1.6.74 against regression.
Silent-except annotation sweep across routers/. Every silent-exception site got an explicit comment explaining what failure mode it absorbs and why, plus a logger.debug breadcrumb so the silent path is observable in production.
Pair with the boot-time data-sources check (v1.6.72) so silent-degrade bug class becomes a runbook concept rather than a hidden failure mode.
Silent-degrade observability: boot-time data-sources check runs at app startup, validates that all CSV + parquet dependencies are present and parseable, and logs a one-line STATUS marker per source.
Closes the bug class where market_layer ran silently no-op for 30 days (Sessions 118-148) because FantasyPros CSVs were gitignored and never reached the Docker image. Now any missing source surfaces in Railway logs within 5 seconds of boot.
Trade Targets surface count-aware weak/surplus signals: position labeled 'thin' only when count below low watermark OR PPG-median below threshold; labeled 'surplus' when count exceeds high watermark.
Closes the inverse of the v1.6.70 fix: previously, a roster's 'trade target' suggestions could mark a position as 'thin' purely on PPG-median while the user already had 7 of that position.
FA opportunities now respect position surplus. A roster carrying 8 WRs no longer surfaces additional WR FA pickups as 'opportunities' just because they show positive LTV-delta against the open pool.
Surplus detection feeds off the per-position weak_by_ppg + count_status path that becomes the unified _position_health foundation in v1.6.76.
Trajectory bucketing fix: collapsed 6 classifier labels (ascending / early_peak / late_peak / plateau / declining / unknown) into 4 display buckets so /recs and /under-the-hood narratives match the actual classifier output without spurious bucket churn between sessions.
POS_IDEAL now derives dynamically from league config + roster size instead of a hardcoded table. Lineup slots, FLEX count, SUPER_FLEX presence, and bench depth all feed the per-position ideal count used by the recommendations engine.
Closes a class of bug where 12-team SF leagues received the same position-targets as 10-team 1QB leagues. Affects FA opportunities, Trade Targets, and roster-grade prose.
Session 149: 5-site sweep closing the last-write-wins bug class across keeper.py (/v1/keeper/optimize, /v1/keeper/evaluate, /v1/keeper/inflation), redraft.py (/v1/redraft/edge), and meta.py cache-status age.
All five sites now scope to caller config_fp with logger.warning on fallback labeled KEEPER_<NAME>_LAST_WRITE_WINS_FALLBACK / REDRAFT_EDGE_LAST_WRITE_WINS_FALLBACK per the Session 148 silent-degrade discipline.
Engine v1.6.66 -> v1.6.67. SPA already passed config_fp on /v1/keeper/optimize so no frontend change required.
Session 149: /v1/redraft/trade scoped to caller config_fp. Sister fix to v1.6.64 closing the same last-write-wins bug class for the redraft engine surface.
Session 149: Recommendations Buy Low / Sell High lists now sort by engine rank instead of arbitrage magnitude alone, and filter by team-needs signal so the targets list reflects roster fit rather than pure market dislocation.
Engine v1.6.64 -> v1.6.65. No math change; surface logic only.
Session 149: Task #19 primary close. /v1/trade and /v1/trade/ai-read now scoped to caller config_fp so trade analysis runs against the user's actual league config, not whichever was last written to the rankings cache.
Defense-in-depth: TRADE_*_DEFAULT_CONFIG_FALLBACK warnings fire on the silent-degrade path so future cache-source drift surfaces in logs instead of silently producing wrong answers.
Engine v1.6.63 -> v1.6.64. Math unchanged on a correctly-scoped request; production behavior now matches the league the user is looking at.
Session 148: market arbitrage shipped end-to-end across Trade Central + Recommendations with LTV-delta math (not just rank-delta).
arbitrage_engine.compute_signals now produces LTV-delta magnitudes alongside rank deltas. Engine LTV at market_rank becomes the implied_market_ltv; engine_ltv - implied_market_ltv = ltv_delta in actual LTV units, sorted by magnitude.
Trade Central surfaces per-player BUY/SELL chips with LTV delta on each give/receive row, plus trade-level market_verdict (roughly fair / steal / overpay), arbitrage_edge_narrative when engine and market diverge meaningfully, and an arbitrage_summary.net_ltv_edge roll-up. Recommendations: trade_targets arbitrage weight bumped 0.20 -> 0.30; buy_low / sell_high entries carry ltv_delta + engine_ltv + implied_market_ltv.
Session 142: scorched-earth removal of Best Ball as a supported league format. The format was experimental Tier-3 work (Sessions 138-141) that never reached UAT-clean status; complexity-to-payoff ratio failed an IP-risk + maintenance review on 2026-05-24.
Session 141 closes the v1.6.60 follow-up. After DISC-6-recal (v1.6.60) closed only ~1% of the +2.65 PPG QB backtest under-projection bias on the weighted_ppg signal layer, and dynasty shrinkage harness DEFENDED (3 candidates all TIE/regression), the next-tier investigation surfaced REBUILT_QB_DUAL_CURVE in age_curves_rebuilt.py as the dominant amplifier.
Original Session 94 v1.6.12 dual_threat curve assumed Vick/Cunningham-class collapse past 28: 27:0.95, 28:0.85, 29:0.72, 30:0.58, 33:0.22. Compounded across the 7-yr LTV horizon, this produced ~64% LTV haircut on Josh-Allen-class age-29 dual QBs. Modern dual-threat cohort (Hurts 27, Burrow 28, Lamar 28, Allen 29, Wilson 31-33) doesn't decline this fast — they extend the peak into the early 30s.
Built scripts/validate_qb_dual_curve_modern.py (paired-observation MAE A/B on dual-threat QB year-pairs, n=32 across 2018-2024 ref years; matches established harness pattern). 5 candidate curves tested, all SHIP per discipline. Selected flat_then_slow_decline (tightest CI; preserves dual-archetype character vs deleting it entirely): agg MAE 4.144 -> 3.345 delta=-0.799 CI95 [-1.729, -0.045] all-negative. Bias closure: -3.075 -> -2.036 PPG (closes 34% of the dual-cohort under-projection).
New curve shape: flat at peak through age 30 (27:1.000, 28:0.98, 29:0.95, 30:0.91), then gentle decline (31:0.85, 32:0.78, 33:0.70, 34:0.60, 35:0.50). Matches empirical performance of the modern dual-threat QB cohort.
Engine math: age_curves_rebuilt.REBUILT_QB_DUAL_CURVE replaced with the new shape. No other engine code changes. Pocket curve unchanged. Schema unchanged (curves are Python constants, not config-driven).
Sample affected players (single-step Y+1 projection): Josh Allen age 29 base 17.24 -> cand 22.74 vs actual 24.04 (error -5.51); Russell Wilson age 33 base 4.48 -> cand 14.24 vs actual 17.34 (error -9.77); Lamar Jackson age 28 base 18.57 -> cand 21.41 vs actual 17.60 (error +2.84 over). Aggregate net win.
Cascades across the 7-yr LTV horizon — a 5+ PPG single-step projection improvement on Allen at 29 compounds into a ~20% LTV improvement for him.
Session 141 backtest regen against v1.6.59 surfaced QB MAE +14% (test 2024) / +41% (test 2023) regression on same test data vs v1.6.19 baseline; QB systematic bias +2.65 PPG under-projection (filed as P2 in BACKLOG). Root-cause hypothesis: DISC-6 (Session 109, v1.6.21) QB recency weights calibrated to nearly-flat 0.5/0.5/0.3/0.3/0.3 on a 6-yr-back n=199 veteran-dominant cohort underweights the modern breakout-young-QB phenomenon (Hurts/Allen/Burrow/Jackson/Daniels/Stroud Y0->Y1 ascensions).
Built scripts/validate_recency_weights_modern.py (paired-observation MAE A/B isolated to metrics.py::weighted_ppg_for_player; no confounders from shrinkage / age curve / LTV downstream). Cohort: 2018-2024 ref years, n=190 QB year-pairs. Bias decomposition at baseline confirmed Session 141 hypothesis at cleanest signal level: breakout-young (age<=25) bias -1.35 PPG (n=77), plateau-vet (age>=28) bias +0.61 PPG (n=86) - same direction the backtest surfaced, ~5x smaller magnitude (full pipeline amplifies the signal-level error downstream).
Grid search (16 single-profile candidates + 9 age-conditional candidates): single-profile candidates show breakout-young improvement CI all-negative across the board, but mid-career n=27 regression eats aggregate gains; no single-profile candidate earns SHIP (best agg CI crosses zero). Age-conditional candidates with cut25 leave mid/vet untouched and earn SHIP at multiple points along the young-tilt curve.
Selected: YOUNG=1.2/0.7/0.4/0.2/0.1 cut25 (best point estimate among CI-clean candidates). Verdict: agg MAE 2.935->2.873 delta=-0.062 CI95 [-0.111,-0.018] all-negative. Breakout-young delta=-0.153 CI95 [-0.274,-0.048] all-negative. Mid-career (n=27) and vet (n=86) cohorts unchanged by construction (cut25). Bias closure: breakout-young -1.35->-1.19 (12% closure); plateau-vet bias unchanged.
Engine math: position_recency_weights.QB is now a structured object with _age_conditional=true, age_cutoff=25, young_weights and old_weights sub-dicts. metrics.py _resolve_recency_weights_for_player helper resolves per-player based on age at reference_season (sourced from age_<season>/age_at_season/birth_date columns already present in stats_df via data_loader). Defensive fallback: missing/zero age uses old_weights so unknown players are never silently treated as breakout-young. engine_config.schema.json QB sub-schema is now oneOf [legacy_flat, age_conditional]; back-compat preserved (any flat QB profile still validates).
Path forward for RB/WR/TE: same harness extensible to other positions; current calibrations DEFEND in this session by lack of audit evidence for the same modern-cohort regression. Re-test all four positions annually as cohort grows.
Session 140 v1.6.58 shipped DISC-R2 with age_min=30 across all four positions per BACKLOG spec. Variant testing during the SHIP exercise showed age-28 cliff RB/WR (TE/QB unchanged at 30) won bigger aggregate MAE. Filed as follow-up.
Harness scripts/validate_redraft_fa_discount.py re-run on the position-specific candidate (RB/WR age_min 28, TE/QB age_min 30): RB agg MAE 4.014->3.814 delta=-0.200 CI95 [-0.265,-0.136]. WR agg 3.744->3.659 delta=-0.085 CI [-0.111,-0.060]. TE + QB TIE by construction (no change for those positions). Young <30 slice (new 28-29 cohort): RB delta=-0.295 CI [-0.390,-0.202], WR delta=-0.132 CI [-0.171,-0.094]. No slice regression.
Over-discount risk check on late_sign 28-29 subset (the cohort that actually played - validating bigger discount not driven purely by y1_missing actual=0 cases): RB n=60 delta=-0.627 CI [-1.005,-0.247] all-negative, WR n=106 delta=-0.246 CI [-0.378,-0.117] all-negative. Bigger discount fits both subtypes.
Discipline rationale for stopping at age-28 vs age-27 (which won even bigger Δ but pushes onto truly prime-age 27 FAs at WR per CLAUDE.md age curves): consensus is signal, never a tuning target; 2-year drop from spec defensible, 3-year drop risks over-fit to y1_missing dominance.
Engine math: no code change needed (the dict-form resolver shipped in v1.6.58 already supports any age_min). engine_config.json only: RB age_min 30->28, WR age_min 30->28. Schema unchanged.
Locked discipline: any future change to fa_status_discount age_min must re-pass scripts/validate_redraft_fa_discount.py with SHIP verdict + late_sign subtype check (the over-discount risk cohort that doesn't show up in agg).
Session 137 deferred DISC-R2 citing 'no historical FA-roster snapshot data to harness against.' Session 140 closed the data blocker by building a proxy cohort from player_stats_season.csv: a player is 'FA-going-into-Y1' if EITHER no Y1 record exists (FA bust, ppg1=0) OR team_Y0 != team_Y1 AND games_Y1 < 14 (late-sign approximation). Cohort: 1526 pairs total (681 y1_missing + 845 late_sign).
Descriptive cut confirmed the hypothesis: aged 30+ FAs regress -2.91 to -4.87 PPG Y0->Y1 vs same-team aged players regressing -0.68 to -1.83 PPG. Excess regression: RB -1.84, WR -2.08, TE -2.23, QB -3.64 PPG attributable to FA status.
Built scripts/validate_redraft_fa_discount.py (paired-observation MAE A/B). Accepts scalar (legacy) OR dict {default, by_pos_age} candidate.
Verdict: SHIP across all four positions. QB agg MAE 6.129->5.976 delta=-0.153 CI95 [-0.222,-0.082]. RB agg 4.301->4.014 delta=-0.287 CI [-0.362,-0.213]. WR agg 3.922->3.744 delta=-0.178 CI [-0.212,-0.144]. TE agg 3.169->2.856 delta=-0.313 CI [-0.402,-0.223]. Aged-30+ slice MAE delta ranges -0.284 (QB) to -0.893 (RB), all CIs all-negative. Young-slice delta=0 by construction. Late-sign aged-30+ subset wins decisively (RB delta=-0.437 CI [-0.791,-0.080], WR delta=-0.320 CI [-0.470,-0.170], TE delta=-0.225, QB delta=-0.092).
Robustness check on adjacent variants: conservative (RT 25, W 20, Q 18) wins smaller; aggressive (RT 45, W 35, Q 30) wins agg but breaks late-sign RB at CI level (CI [-0.937, +0.107] crosses zero). Age-28 cliff variant wins more aggregate MAE but is out of BACKLOG spec - filed as follow-up.
Engine math: redraft.fa_status_discount config field changed from scalar to dict shape. redraft_engine.py __init__ accepts both forms (fa_status_discount_raw stores original, scalar surface kept for gate check). Per-player application site resolves discount via cfg lookup: dict -> by_pos_age[pos].discount if age >= age_min, else default; scalar fallback unchanged.
engine_config.schema.json updated: redraft.fa_status_discount now oneOf [number, object{default, by_pos_age}].
Locked discipline: any future change to fa_status_discount params must re-pass scripts/validate_redraft_fa_discount.py with SHIP verdict on all four positions.
Session 137 post-ship re-benchmark identified Brock Bowers engine #54 vs consensus #18 — TE2 in consensus undervalued by engine. Root cause: Bowers has seasons_data=2 (Y1 rookie 17g + Y2 12g), both elite-PPG, but Bayesian shrinkage applies uniformly to all seasons_data<3 cohorts, pulling his raw 12.6 PPG toward TE prior 5.93 -> 8.9 PPG. Engine cannot distinguish 'elite back-to-back producer' from 'thin-sample one-year wonder.'
Descriptive cut on n=1198 Y2-Y3 transitions (2015-2024): for raw_pred > 1.5 * position_prior cohort, NOT shrinking beats shrinking by Δ -0.7 to -1.1 MAE per position. TE elite delta=-1.10, RB elite delta=-0.69, WR elite delta=-0.50. Non-elite cohort unaffected (engine should still shrink them).
Built scripts/validate_redraft_y2_shrinkage.py — paired-observation MAE A/B with shrinkage-skip when raw_ppg > elite_skip_mult * prior.
Verdict at elite_skip_mult=1.5: SHIP. Agg MAE 2.970 -> 2.813, delta=-0.157, CI95 [-0.241, -0.074] all-negative AND clears strict SHIP_THRESHOLD. Elite cohort (n=298) delta=-0.633, CI [-0.961, -0.306]. TE elite (n=45) delta=-1.102 CI [-1.799, -0.403]. WR elite (n=189) delta=-0.502 CI [-0.867, -0.135]. RB elite (n=64) delta=-0.692 CI [-1.627, +0.268]. Non-elite cohort (n=900): delta=0.000 (perfect guardrail by design).
Engine math: new redraft.shrinkage.elite_skip_mult parameter (default 1.5). In _apply_shrinkage's per-player loop, skip downward-shrinkage if ppg > elite_skip_mult * effective_prior. Otherwise continue as before. Applies AFTER DISC-R4 QB rushing-prior override.
Locked discipline: any future change to shrinkage_elite_skip_mult must re-pass scripts/validate_redraft_y2_shrinkage.py with SHIP verdict.
Session 137 final tally bumped: 5 engine ships (R3-WR + R5 + R4 + R7 + R8) + 3 defends (R1, R3-TE TIE, R6) + 1 deferred (R2 no data) + 7 paired-observation harnesses live in scripts/.
Session 137 benchmark surfaced Rashee Rice engine #100-112 vs consensus #21. Root cause: engine's min_games_full_weight=10 hard threshold puts his 2025 8-game elite-PPG season (18.76 PPR) in partial_records, which are then DISCARDED entirely because his 2023 16-game season exists as a full record (line 818: `active = full_records if full_records else partial_records`). Effective seasons_data=1 -> Bayesian shrinkage at w=0.4 pulls his 2023 PPG of 10.81 toward WR prior of 6.46, projecting 7.94 PPG.
Descriptive cut on n=6590 year-pairs (2015-2024): games0=8-9 MAE 3.00 ≈ games0=10-12 MAE 3.01 (statistically identical). 8-9 game PPG is as predictive of year+1 as 10+ game PPG. The 10-game threshold is over-restrictive.
Built scripts/validate_redraft_min_games.py - paired-observation MAE A/B for the threshold change. Test: predict year+1 PPG using engine recency-weighted multi-season blend under threshold=10 vs threshold=8. Compare MAE on n=5495 total pairs, n=450 affected pairs (where threshold change matters).
Engine math: redraft.min_games_full_weight 10 -> 8 in engine_config. Affects 510 year-pairs of 8-9 game seasons (typically injury returns) that now count as full_records instead of being discarded. Rashee Rice's 2025 season now contributes to his projection; expected ranking move from #100 up to ~#60-80 range.
Top-of-position consensus check: no top-3 player at any position has a games=8-9 season in their recency window currently, so this change doesn't disrupt elite rankings. The change affects mid-tier WRs (Rice), injury-return RBs, backup-to-starter QBs.
Locked discipline: any future change to min_games_full_weight must re-pass scripts/validate_redraft_min_games.py with SHIP verdict.
Session 137 redraft benchmark surfaced young rushing-QB cluster (Maye, Daniels, Dart, Caleb) all ranking 50-70 places below consensus; Stroud (pocket passer) matched consensus as built-in control. Engine internals: shrinkage_factor=0.333 on Maye pulled him toward the QB population mean of 14.96 PPG.
Descriptive on n=650 QB year-pair transitions (2018-2024, games>=8): young (age<=25) low-rush (<15 ypg) Δ=+0.41 PPG Y0->Y1 (n=110), young mid-rush (15-30) Δ=+0.30 (n=59), young high-rush (>=30) Δ=+1.03 (n=37). Full-cohort elite-rush (>=50 ypg) Δ=-2.25 (n=15) warns against uncritical rushing-up prior — older rushing QBs decline.
Built scripts/validate_redraft_qb_rushing_prior.py — paired-observation MAE A/B. Bayesian shrinkage formula: weight = n/(n + k*pos_k_mult); pred = w*ppg0 + (1-w)*prior. Candidate replaces position-wide prior with rush_prior_ppg for QBs satisfying both (rush_ypg0 >= threshold) AND (age0 <= rush_age_max).
Tested 7 variants. Selected Variant 5 (rush_prior=18.0, threshold=25 ypg, age_max=25): agg MAE 2.769->2.741, delta=-0.028, CI95 [-0.048, -0.009] all-negative. Pocket low-rush slice unchanged (0.000 — Stroud-style control). Mid-rush slice delta=-0.055 CI [-0.104, -0.010]. High-rush 30-50 ypg slice delta=-0.253 CI [-0.426, -0.084]. Young high-rush target cohort delta=-0.283 CI [-0.549, -0.010] n=37. Older rushing QBs (age>=27) locked out at delta=0 by age_max gate. Elite-rush (>=50) slice unchanged direction-wise (+0.113 but CI [-0.265, +0.465] crosses zero, n=15).
Engine math: new redraft.qb_rushing_prior config block + per-player qb_rush_prior_map built inside _apply_shrinkage. The shrinkage target switches from uniform position prior to rush_prior_ppg for indexed rows; pocket QBs and older QBs keep the uniform prior (current behavior).
Feature flag _enabled=true. Schema entry under redraft.properties. _apply_shrinkage signature extended to accept Optional stats_df for latest-season rush_ypg lookup. Discipline pattern locked: any future change to qb_rushing_prior params must re-pass validate_redraft_qb_rushing_prior.py with SHIP verdict.
Three engine ships in one night via the harness discipline: v1.6.53 (DISC-R3 WR age), v1.6.54 (DISC-R5 aging-WOPR), v1.6.55 (DISC-R4 QB rushing-prior). Plus one DEFEND (DISC-R1 TEP-gating) and one TIE (DISC-R3 TE).
Session 137 redraft benchmark surfaced WR 28-29 cluster meanDelta=-63.8 ranks + WR wopr>=0.4 + age>=28 cluster meanDelta=-58.3. Hypothesized root cause: engine's opportunity_premium boosts a 32-yr-old high-WOPR WR the same as a 24-yr-old.
Descriptive cut on n=1721 WR year-pair transitions (2018-2024): high-WOPR (>=0.40) regression -0.51 PPG/yr at age 22-26 vs -2.02 PPG/yr at age 30+. Hypothesis empirically supported.
Built scripts/validate_redraft_opportunity_aging.py — paired-observation MAE A/B that subtracts penalty = alpha * max(0, wopr - wopr_anchor) * decay_age(age) from ppg0 prediction. Three variants tested.
Variant 1 (alpha=4.0, age_floor=27, age_cap=32): agg MAE 2.956->2.905, delta=-0.051, CI [-0.064, -0.037] all-negative. Young 22-26 flat. Prime 27-29 delta=-0.055 CI [-0.075, -0.036]. Old 30+ delta=-0.296 CI [-0.394, -0.198]. High-wopr old 30+ delta=-0.370. Verdict: SHIP.
Variant 2 (gentler alpha=2.0): agg delta=-0.028 also SHIP. Variant 3 (alpha=3.0 age_floor=28): agg delta=-0.029 also SHIP. All three variants SHIP cleanly; selected Variant 1 for biggest MAE improvement + match to empirical age curve.
Engine math: new redraft.opportunity_aging_penalty block + _aging_wopr_penalty helper in _compute_draft_score. Penalty subtracted from draft_score (not redraft_proj — pure ranking adjustment, preserves projection transparency). New per-player column aging_wopr_penalty surfaced in output.
Feature flag _enabled=true (defaults to live). Schema entry in engine_config.schema.json under redraft.properties allows the new block. Discipline pattern locked: any future change to opportunity_aging_penalty params must re-pass validate_redraft_opportunity_aging.py.
Session 137 full benchmark of redraft engine top 200 vs PlayerProfiler TEP + FantasyPros standard surfaced WR 28-29 cluster meanDelta=-63.8 ranks (engine over-rates) and WR 30+ cluster meanDelta=-56.2. Hypothesized root cause: WR age cliff_age=29 + rate=0.030/yr too gentle.
Built scripts/validate_redraft_age_curve.py — paired-observation MAE A/B for redraft age_projection_discount changes. Mirrors scripts/validate_curve_change.py (dynasty) but targets the single-season redraft discount.
Harness verdict on n=2303 WR year-pair transitions (games>=8): agg MAE 2.914 -> 2.876, delta=-0.038, CI95 [-0.062, -0.015] all-negative. Prime 27-29 slice MAE 2.799 -> 2.739, delta=-0.060, CI [-0.102, -0.019]. Old 30+ slice MAE 2.854 -> 2.756, delta=-0.098, CI [-0.194, -0.004]. Young 22-26 slice flat at 3.010 (no regression). Verdict: SHIP. CI upper bound on aggregate just misses strict SHIP_THRESHOLD of -0.020 by 0.005; cleanly directional with n=2303 and all-negative CI.
TE candidate also tested (cliff_age 30 -> 28, rate 0.022 -> 0.045). agg MAE 2.177 -> 2.154, delta=-0.023. CI95 [-0.051, +0.004] crosses zero. Verdict TIE per locked Session 112 discipline (CI upper bound must clear threshold). TE unchanged this session; deferred to future iteration with either more cohort years or a less-aggressive candidate (cliff_age 28 rate 0.035).
RB and QB blocks unchanged (not part of DISC-R3 scope this session).
Discipline pattern locked: any future change to redraft.age_projection_discount must pass scripts/validate_redraft_age_curve.py with SHIP verdict + non-regressing slices.
The 26-entry historical version_history backfill (v1.6.20-v1.6.46) shipped at v1.6.50 but did not actually deploy to prod. Root cause: start.sh:82 image-vs-volume compare only prefers the image-shipped engine_config.json when the version is STRICTLY NEWER. When versions are equal (both v1.6.50), the volume's old file wins and the new image-side file gets clobbered on every boot.
Bumping _meta.version to v1.6.51 triggers the image-prefer path, propagating the backfilled version_history entries to the live container.
Filed P3 follow-up: extend start.sh to also prefer image when file hashes differ at equal versions, so future docs-only engine_config changes propagate without a fake version bump.
Extend the H3a Sleeper-cache load to also build gsis_id -> depth_chart_order map for skill positions.
Apply multiplicative discount when rostered RB/WR/TE has depth_chart_order >= buried_depth_threshold (default 4).
New tunable knobs redraft.buried_depth_threshold (default 4) and redraft.buried_depth_discount (default 0.10).
QB intentionally skipped: dynasty engine has its own QB-specific gate (DISC-Q2 via avg_avail cap); redraft QB handling deferred to H2 follow-up.
Threshold=4 calibration: order 1-2 = real roles, order 3 = rotational players with real snaps (Christian Watson GB), order 4+ = genuinely buried (Aiyuk #4 SWR SF, Tank Dell #6 RWR HOU).
Build gsis_id -> team map from Sleeper cache (sleeper_players_cache.json) before per-player loop, applying same DISC-Q2 pattern from dynasty.
Apply multiplicative discount when player has team=None (FA-status).
Two-stage cache load with stale-cache fallback: load_sleeper_players() first (respects TTL), then direct cache-file read if that fails. Production-resilient when network is briefly down + TTL expired.
New tunable knob redraft.fa_status_discount, default 0.15.
Compounds independently with H7: Mixon (FA + missed 2025) and Amari Cooper (FA + missed 2025) get both discounts stacked.
Detect when a player has no record for the reference season in stats data (e.g., Brandon Aiyuk: 2020-2024 in data, 2025 missing entirely due to ACL recovery).
Apply multiplicative discount to weighted_proj when missed_year_0 detected.
New tunable knob redraft.missed_year_0_discount, default 0.20.
First iteration used `active` (post-filtered) for detection and falsely triggered on 237 partial-season-2025 players (Tyreek Hill 4g, Burrow injured, Diggs 17g). Fixed by checking SOURCE group instead. Second iteration: 140 legitimate fires, all spot-verified.
Capped ceiling at proj * (1 + ceiling_max_multiplier) in redraft_engine._compute_ceiling_floor.
New tunable knob redraft.ceiling_max_multiplier, default 0.50.
Identified during 2026-05-22 redraft engine evaluation. Mechanism: ceiling = max(historical_PPGs) produced unrealistic ceilings for depth players with old fluke career years; the 0.30 ceiling_weight then promoted them into top 200 via ceiling_premium contribution.
Cap fires surgically on targeted cases: Evan Engram ceiling 10.19 -> 7.48, Cole Kmet 9.64 -> 6.96, David Njoku 10.23 -> 10.06. Cap does NOT fire on McBride (14.88 < 19.62), Kittle (13.17 < 14.57), Nacua (19.41 < 24.80), Bijan, Kelce, Aiyuk, Jeudy.
Full-engine A/B: Engram #129 -> #152 (drop 23), Cole Kmet #149 -> #174 (drop 25). Top-3 preserved at all four positions.
sessionStorage is scoped per-tab. When Clerk's Google SSO opens the OAuth handshake in a popup or new tab (common with .clerk.accounts.dev redirects), the redirect-back-to-/ can land on a different tab than the one that originally wrote the intent.
New tab has its own empty sessionStorage, _checkPendingCheckout finds nothing, user lands on / instead of Stripe Checkout.
Fix: switch pending_checkout intent storage from sessionStorage to localStorage with 10-min expiry.
Consumed by app.js::ensureIdentified, not login.html (cross-tab survival).
Plus discover: rushing-QB Y0-to-Y+1 persistence (H-34, adds_information) cherry-picked + blog post live at /lab/rushing-qb-y0-100-att-y1-persistence.
Clerk's Google SSO completes off-site and redirects directly to the configured After-sign-in URL (typically /), bypassing /login entirely.
That meant login.html's _resolveRedirectTarget never runs and the sessionStorage pending_checkout intent was never consumed. User landed on / instead of Stripe Checkout.
Fix: app.js ensureIdentified() calls _checkPendingCheckout() after a real user_id resolves. If sessionStorage has a fresh (<10 min) pending_checkout entry, the watcher replays the billing call.
Closes the SSO-bypass-of-checkout-intent failure mode.
SPA: 167 em dashes in app.js + 111 in index.html replaced via context-aware Python sweep, comments preserved, 0 syntax breaks.
Pure Signal pricing page (static/pricing.html, 471 lines): three tier cards (Free / Pure Signal annual+monthly / Lifetime founder-only), 35-row comparison matrix, 4-card hygiene commitments grid, 7-question FAQ.
discover.md SKILL locked 'no em dashes anywhere' + 'specific examples sparingly' + calibration paragraph as north star.
NOTE: This commit also contained the FUSE-truncation outage that hit production for 55 minutes (Session 128 P0). Three additional files (pricing.html, methodology.html, blog md) were also silently truncated by the same FUSE write. Recovery shipped Session 128.
Harness verdict via scripts/validate_pwopr_lift.py: SHIP for WR at threshold 0.10 + lift -0.10 (aggregate MAE delta -0.025, CI -0.040 to -0.011 fully below zero, slice MAE -0.47 ppg/pair, n=121 pairs). TE remains TIE.
Engine integration: metrics_pwopr_lift.py + engine_config.pwopr_regression_lift block (default _enabled: false). Flag-gated; production rankings don't shift until operator flips after named-case review.
FINAL DISPOSITION: persistence filter + magnitude-scaled lift_bands built and tested; no configuration earns SHIP verdict that respects named-case discipline. PWOPR remains descriptive-only signal (chips + badge + filter pill).
Plus market layer revival: FP static CSV + Buy Low / Sell High in Recommendations.
Surfaces nflverse-native WOPR (1.5*target_share + 0.7*air_yards_share, Hermsmeyer 2017) as a Custom Layer enrichment column on every player payload.
Backfilled custom_layer_per_player.parquet for 2018-2025 via join from player_stats_season.csv (no nflverse re-pull required).
Provably no math change for predictions: WOPR was already flowing into the TE LASSO model (coef 0.3512, blend 0.1) via base_df; CL cache overlay rewrites the same numerical value.
Validated via paired-observation A/B harness (scripts/validate_injury_discount_change.py): SHIP verdict with QB best lift +18.1 LTV (Lamar class), WR +13.4, RB +4.1, TE 0.0.
Prime cohort stable, top-3 consensus stable across all 4 positions.
Healthy-classified players with availability_rate in [0.75, 0.90] who previously took a 19% LTV penalty from Q1 availability scaling now get the alpha-power treatment restored.
Plus fourth A/B validator shipped: scripts/validate_shrinkage_anchor_change.py.
Original DISC-8-followup QB SHIP verdict driven by 12 boundary pairs out of 225 total -- the other 213 contribute 0 delta because the mod term cancels when both pair ages are on the same side of peak_end. Aggregate '-0.027' was 12 * -0.494 / 225.
Bootstrap 95% CI on the 12 boundary pairs: [-1.249, +0.276]. CI crosses zero.
Per the discipline, no flip on noisy 12-pair evidence. Reverting archetype_v2.enabled to false (v1.6.33 -> v1.6.34).
Harness v2 changes: bootstrap CI required for all verdicts; aggregate point estimates without slice-level support flagged as suspicious.
CI failed on v1.6.29 push because audit found 5 dead blocks under CI conditions (152 tracked .py files) but local run found 4 (169 files including untracked).
Root cause: scripts/ablation_study.py + trade_simulator.py present locally but absent from origin/main -- only consumers of engine_config.rookie_model and trade_simulator.
Two fixes: acknowledge rookie_model + trade_simulator as known-dead until those scripts ship, OR ship the scripts. Chose acknowledgment.
v1.6.30 was an attempted intermediate fix that got squashed/rolled back; skipped in version sequence.
Walk-forward 2020-2024 across n=388 predictions: RB R3 bucket showed -5.81 avg bias with 5/5 years signed agreement (all 5 over-projected). Survivor-bias artifact -- cohort median inflated by 4-5 breakout backs (Aaron Jones / Tony Pollard class).
Replaced median 11.36 -> 8.50 (lower of full-cohort and recent-5y medians). Widened q25/q75 (1.64, 12.96) to surface bimodal distribution.
Q4 deploy revealed Nabers's SF270 was actually driven by the STRUCTURAL injury layer reading 1 ACL season as 0.39 avg_avail and applying it as permanent flat multiplier on ltv_discounted. Same bug class as Q4, different code path.
Pre-Q4 _apply_live_injury_adjustment dampened weighted_ppg by year_0_factor (career_bender 0.30) which propagated through all 7 LTV horizon seasons. Nabers ACL crushed to 29% of healthy LTV.
Post-Q4: weighted_ppg un-dampened. New live_injury_year_0_factor param on dynasty_lifetime_value(_v2) applies year-0-only inside horizon loop. Years 2-6 use un-injured baseline.
projected_season_ppg still dampened directly so current-year UI reflects injury. Sprint B v2 RB recompute multiplies by y0.
Nabers-class unit math recovers 2.91x (29 to 85 percent of healthy). v2 path 2.87x.
Below-baseline players get injury_discount = max(scaling_floor, avg_avail) direct linear scaling, bypassing the legacy floor=0.792 trap. Above-baseline keeps relative**alpha treatment.
Ghost-QBs dropped: Wentz SF53 to 193, Martinez 77 to 392, Slovis 87 to 415, Trey Lance 88 to 318, Mac Jones 48 to 82, Russell Wilson 34 to 60.
Elite WRs/RBs lifted organically: Nico Collins 83 to 66, CeeDee Lamb 74 to 55, Gibbs 21 to 15. Top-5 QBs held (Allen 1, Hurts 2, Mahomes 3, Maye 5).
Bundled banner UI fix: sticky offsets on tab nav + filter bar moved to .sticky-below-app-header / .sticky-below-tab-nav classes (post-banner-bump corrections).
Feature-flagged via injury_model.availability_scaling_v2.enabled.
New unified_projection.py with compute_prior + compute_posterior + apply_unified_projection.
Replaces the bipartite pipeline (rookie-only empirical model or vet-only observed PPG) with a Bayesian blend that shrinks observed PPG toward the empirical prior until career_games exceeds shrinkage_anchor[pos].
Added regression_factor_by_position config support to production_regression layer for future per-position tuning.
H-10 (TD regression) cohort EMPIRICALLY VALIDATED: 78% of 10+ TD scorers regress Y+1, mean -4.0 TDs (WR -4.4, RB -3.7, TE -4.5).
BUT generic production_regression layer applied to WR/TE failed to move backtest bias as expected (WR went from -0.02 to +0.14 even at low factor=0.05). Generic layer triggers on PPG/TD-vs-career thresholds; for WR/TE the career-mean baseline interacts differently than for RB.
Decision: keep positions=[RB] only. The per-position factor scaffolding stays as foundation for future dedicated TD-haircut layer that targets only the top-TD cohort (>= 10 TDs) with empirically-calibrated 0.32 retention factor.
Increased production_regression.regression_factor from 0.35 to 0.50. Apex Fantasy Leagues research suggested 0.50-0.70 range for one-season-only cohort.
Walk-forward validated 2021-2023 (3 ref years): RB bias improved on EVERY year (closer to zero). Average bias improvement: +0.12 ppg per year. Correlation (r) preserved across all years (small noise: -0.016 / +0.005 / -0.003).
Per-year bias: 2021 -0.86 -> -0.74, 2022 -0.42 -> -0.28, 2023 -0.17 -> -0.08. All three move strictly toward zero.
Per-year MAE essentially unchanged within walk-forward noise band. Net effect: bias correction without MAE regression.
Pre-fix: WR/RB/TE rookie buckets stored median peak season ppg, but engine consumed values as year-by-year weighted_ppg input. Same structural error as v1.6.15 QB fix.
Empirical cohort analysis 2015-2023: peak vs career-avg gap is 2.7-4.4 ppg per LTV year across every R1_top / R1_late / R2 bucket for all 3 positions.
Pre-fix: rookie_pick_bucket_baselines.json QB R1_top median_peak_ppg=18.84. Engine consumed this as weighted_ppg input -> Ty Simpson (R1_top rookie) projected at 18.45 ppg every year of LTV horizon despite never playing an NFL snap.
Empirical cohort analysis (n=25 R1_top QBs drafted 2015-2023, Half-PPR Dynasdeez scoring): median PEAK season ppg = 18.50, but median CAREER-AVG ppg = 15.30. The 3.2 ppg gap is the structural over-projection — peak is a single-season high, career-avg is the sustainable rate.
Pre-fix: REBUILT_RB_BASE_CURVE table ended at age 34 (value 0.709). _base_factor fallback in age_curves.py inherited curve[34]=0.709 for all ages 35+ — so a 39yo RB scored 71% of peak production in the LTV horizon.
Result: aging RBs like Derrick Henry (age 32.7, weighted_ppg 22.71) cleared LTV 95.2 — above elite young WRs (Ja'Marr Chase 90.5) and tied with rookie QB bucket-baseline projections.
Fix: extended curve to {35: 0.500, 36: 0.300, 37: 0.150, 38: 0.080, 39: 0.040, 40: 0.020}. Values cross MIN_FACTOR_FLOOR=0.10 at age 38, truncating the LTV horizon early (matches reality — 38yo starting RBs are statistical anomalies).
Methodology grounding: Apex Fantasy Leagues research found ZERO qualifying RB-seasons age 33+ since 2000. The pre-fix plateau was unjustified extrapolation — not empirical.
Flipped cross_position_rank_blend from ltv_weight=0.5 / vos_weight=0.5 to ltv_weight=1.0 / vos_weight=0.0 (pure LTV).
Root cause: 50/50 blend was calibrated against KTC + FantasyCalc external consensus (4-source validation). Those sources were removed for IP reasons in Sessions 89-90 (de-risk sprint). The blend weighting became orphaned tuning targeting a calibration source that no longer exists in the engine.
Symptom Ryan caught at Session 94 wrap: Josh Allen (LTV 119, age 30) ranked BELOW Davante Adams (LTV 92, age 33) in Overall tab while within-position rankings sort by pure LTV. Cross-position blend was producing inconsistent ordering vs the within-position view.
Methodology alignment: pure LTV matches the locked feedback memory 'LTV is the primary ranking metric — not dynasty_ppg' and makes Overall consistent with within-position ordering.
Walk-forward MAE unchanged — this is a presentation-layer reorder, not a projection change. No retest needed.
Added REBUILT_QB_POCKET_CURVE + REBUILT_QB_DUAL_CURVE to age_curves_rebuilt.py grounded in Apex Fantasy Leagues research (158 qualifying QB-seasons, 4.5-year peak-age gap between archetypes).
Modified age_curves.py::age_curve_factor to dispatch to archetype-specific curve when FF_QB_ARCH_SPLIT_CURVES env=1 + QB position + dual_threat/pocket_passer archetype.
Added engine_config.qb_archetype_split_curves.enabled flag (default OFF).
Pocket curve: peaks age 29-31 at 1.0; holds 0.94+ through age 34; gradual decline thereafter.
Dual-threat curve: peaks age 24-26 at 1.0; cliffs 0.85 at age 28; drops to 0.22 by age 33 (matches Apex's 'only 4.1% of dual-threat seasons age 33+' finding).
Validation surface: enable env + run wf_age_2_runbook. Engine smoke + QB diagnostic should show shape difference for breakout QBs (Lamar, Hurts, Burrow) vs aging pocket passers (Stafford, Brady late-career).
New _apply_production_regression() in dynasty_engine.py — multiplicative blend of weighted_ppg toward career-mean PPG for RBs with 3+ prior seasons AND Y-1 spike (PPG >+1.0 OR TDs >+2.0).
Triggers ~30-50 RBs/year. Mean-reversion is the dominant structural failure WF-AGE-4 identified.
Added projected_season_ppg.age_aware per-position config (default OFF for QB/WR/TE, ON for RB).
When ON, projected_season_ppg = weighted_ppg * age_factor + bias_correction[pos], replacing legacy age-blind formula.
Recalibrated bias_correction[RB] from -0.54 to -0.20 to reflect residual after age_factor does the decline-curve work.
Engine change in dynasty_engine.py runs after _apply_framework so age_factor is available; downstream injury/ceiling/momentum multipliers operate on the corrected baseline.
Sprint C accuracy ship: WR age curve flattened to 1.0 across all ages (REBUILT_WR_CURVE in age_curves_rebuilt.py). Rationale: AS-7 ablation (Session 93) showed disabling the WR age curve improves WR MAE_dyn by 0.43 ppg/year; AS-8 minimal-engine head-to-head confirmed flat-curve naive baseline beats production WR MAE_dyn by 0.44. Sprint C experiments with curve reshape (AS-2-informed lifts; naive piecewise shape) both WORSENED WR MAE (4.27, 4.91 vs baseline 2.89 on 2019). Root cause: the LTV horizon math (7-season weighted average of weighted_ppg × age_factor) interacts non-linearly with non-flat values — only TRUE flat (age_factor=1.0 at every age) reproduces AS-7's improvement. Walk-forward AB 2019-2024 aggregate vs v1.6.6: QB MAE_dyn 3.891 → 3.690 (-0.20); RB 3.216 → 3.085 (-0.13); WR 2.702 → 2.268 (-0.43); TE 2.332 → 2.205 (-0.13). vs naive baseline (AS-8): QB now beats naive by 0.74; TE by 0.05; WR ties within 0.005; RB still loses by 0.16 (next sprint target). Trade-off disclosure: flat WR curve removes dynasty age-discrimination from WR dynasty_ppg — a 30-year-old WR with weighted_ppg=10 is valued the same as a 25-year-old at 10. This is intentionally optimizing for year-1 projection MAE; multi-year dynasty value differentiation is preserved in ltv_discounted (sum, not average) and in archetype modifiers. Old REBUILT_WR_CURVE values preserved in age_curves_rebuilt.py comment block for rollback. No other curves touched.
Session 93 Option-C ramp moderation. v1.6.5 walk-forward AB returned numbers identical to v1.6.4 across all four positions to two decimal places (QB MAE 3.89 / bias +2.58 / r 0.458; TE r 0.658). Diagnosis update: the QB age curve smoothing in v1.6.4 / rollback in v1.6.5 had no engine-level effect — the actual driver of the v1.6.4 regression (vs v1.6.3 baseline QB bias +2.35, TE r 0.719) was the rookie_development_ramp.QB change from [0.88, 0.94, 1.0] to [0.50, 0.70, 0.85, 1.0]. The mechanism cascade is ramp -> rookie projections -> P13/VOS positional priors -> TE r drop. Surgical fix: bracket the ramp magnitude to find the smallest step back from [0.50, ...] that still flips the Maye/Mendoza/Simpson inversion. Sandbox bracket sweep: [0.50, 0.70, 0.85, 1.0] gives Maye QB5 / Mendoza QB9 (4-rank gap, baseline); [0.55, 0.75, 0.88, 1.0] gives Maye QB5 / Mendoza QB7 (2-rank gap, robust); [0.60, 0.78, 0.90, 1.0] gives Maye QB5 / Mendoza QB6 (1-rank gap, thin); [0.65, 0.80, 0.90, 1.0] inverts back to Mendoza QB4 / Maye QB6. v1.6.6 selects [0.55, 0.75, 0.88, 1.0] — smallest step back with a robust safety margin on the inversion fix. Predicted effect on walk-forward AB: partial recovery of QB bias toward +2.35 and TE r toward 0.719, since the ramp magnitude on year-0 (the dominant lever) is 10% less aggressive vs v1.6.5. Pending: walk-forward AB validation. QB curve stays at restored trim-25% values (no rollback of v1.6.5's curve restore).
Session 93 surgical rollback of v1.6.4 QB age curve smoothing. Walk-forward AB on v1.6.4 regressed two of the four positions: QB bias drifted further from zero (+2.35 -> +2.57 wPPG, wrong direction; decision rule was 'bias improves toward 0' -> FAILED) and TE r dropped -0.061 (0.719 -> 0.658), with TE hit@top-12 dropping -8pp (62% -> 54%) despite TE curves / ramp / config block being untouched in v1.6.4 (suspected P13 / VOS cascade from QB-side positional priors shifting). WR untouched (curves not modified). RB MAE +0.14 worse, bias slightly improved. Diagnosis: the smoothed QB curve lifted peak-age values (27: 0.880->0.97, 28: 0.935->0.99), which are the ages most QB-seasons sit in across the walk-forward cohort, so cohort-aggregate projection went UP and over-projection bias got WORSE. The rookie ramp change (rookie_development_ramp.QB = [0.50, 0.70, 0.85, 1.0]) was the surgical lever that actually flipped the Maye / Mendoza / Simpson rookie inversion -- hand-calc confirmed Mendoza year-0 multiplier 0.88->0.50 = -43% year-0 PPG hit did the bulk of the LTV drop. v1.6.5 restores REBUILT_QB_CURVE in age_curves_rebuilt.py to the original trim-25% values and KEEPS rookie_development_ramp.QB at [0.50, 0.70, 0.85, 1.0]. Predicted result: Maye lands QB5-7 (smaller relative gap to Mendoza than v1.6.4 showed, but inversion still flipped vs v1.6.3 baseline). v1.6.4 entry marked superseded_by=v1.6.5 for audit trail; not deleted. Methodology note: this ship violated the AB-harness-before-wire-in gating rule formalized 2026-05-11 -- the one-player smoke check on Maye placement was not a population-level accuracy validation. Rule re-affirmed: any age curve / ramp / projection-parameter change MUST run through walk_forward_backtest before wire-in.
Session 92 rookie ranking inversion fix. Two related changes addressing Drake Maye / Mendoza / Simpson QB ranking inversion: (1) QB age curve smoothing in age_curves_rebuilt.py REBUILT_QB_CURVE. Pre-smooth values had 4 monotonicity violations (22>23, 26>27, 32<33, and 36->37->38->39 ascending from 0.771 to 0.951 — survivor-bias inversion implying a 39-year-old QB produces 95% of peak). Smoothed values enforce strict monotonicity ascending 22->29 and descending 30->41, with late-career magnitudes aligned to CLAUDE.md framework ("Decline gradual; effectively done Late 30s"). Concretely: 23: 0.773->0.84, 27: 0.880->0.97, 32: 0.894->0.93, 36: 0.771->0.68, 38: 0.933->0.46, 39: 0.951->0.32, 40: 0.848->0.20. (2) Rookie development ramp for QB steepened from [0.88, 0.94, 1.0] to [0.50, 0.70, 0.85, 1.0]. Old ramp projected a #1-pick QB at 17-18 ppg in year 1, ~equal to a 2nd-year vet's weighted_ppg. New ramp aligns year-0 projection with empirical year-1 outcomes (Mahomes was a backup year 1; Allen/Hurts/Lawrence/Burrow/Stroud/Daniels/Williams all had material year-1 growing pains). Combined effect on top-15 dynasty QB: Maye 24 lifts QB7->QB3; Mendoza 22 (#1 pick proj) drops QB2->QB5; Simpson 22 (#13 pick proj) drops QB6->QB14. Late-career QBs (Wilson 37, Rodgers 42, Cousins 38) drop out of top-25 entirely. RB/WR/TE curves untouched. Pending: walk-forward AB validation.
Sprint C consensus age-curve tweaks applied (12 ages across RB/WR/TE). All 12 ages flagged as 'too_pessimistic' by 3/3 reference years 2021-2023 in the multi-year backtest run on 2026-05-09. RB ages 25/29/31, WR ages 25/33/34, TE ages 26/27/30/32/33/34. Updates use the standard blend_factor=0.30 conservative weight from the existing accuracy_review pipeline. QB had 0 consensus signals; QB calibration deferred to a post-Pattern-13b-flip re-run.
Pattern 13b/c flip ON. qb_use_v2_ltv: false -> true after ab_harness validation on 2021-2024 snapshots. Verdict SHIP: 3/4 seasons pass kill criterion (dMAE <= -0.10). 2022: -0.80, 2023: -0.51, 2024: -0.47, 2021: +0.40 (single regression dominated by 3 wins). Net win directly attacks the QB +2.18 bias_w from Sprint C 2015-2024 backtest. Session 77 _apply_young_qb_ceiling guard prevents the double-boost interaction with young_qb_ceiling that originally rejected Pattern 13b on 2026-05-05. v2 path uses age_curves_v2.dynasty_lifetime_value_v2 (relative-factor formula) for QB position only; other positions still use v1 absolute or te_use_v2_ltv path.
Per-format parameter architecture. Format-sensitive redraft params (recency_weights, td_regression, shrinkage, position_recency_weights) moved under redraft.formats.{format_key}. Engine resolves format from LeagueConfig.scoring_format at runtime. half_ppr_te_premium is fully optimized (v1.3.5); other formats seeded and pending MC runs.
YoY production delta as trajectory momentum signal for RB and WR. production_delta = ppg_most_recent_full_season - ppg_prior_full_season computed in weighted_ppg_for_player() (metrics.py) from full_records sorted by season desc. Exposed as production_delta column in enrich_production_metrics(). New _apply_trajectory_momentum_bonus() method in rankings_engine.py — parallel to _apply_young_qb_ceiling(), applied immediately after. Eligible: RB and WR, ascending/early_peak trajectory, ≥4.0 weighted_ppg, ≥2 full seasons. Ascending (delta ≥ +1.5 ppg): bonus_pct = delta × 0.012, capped at +18% dynasty_ppg. Falling (delta ≤ -1.5 ppg): penalty_pct = |delta| × 0.008, capped at -12% dynasty_ppg. Config-driven in trajectory_momentum block. momentum_bonus_applied column added for accuracy_review tracking.
Position-specific recency weights. Bug fix: global recency_weights from engine_config.json were NOT flowing to weighted_ppg_for_player() in metrics.py — hardcoded module constant (3.0/2.0/1.0) was used instead of Monte Carlo-optimized values (2.966/0.9/0.279). Fixed by threading recency_weights param through weighted_ppg_for_player() -> enrich_production_metrics() -> build_player_rankings_base() -> _build_base() in rankings_engine.py. position_recency_weights added to engine_config.json: RB (4.5/0.65/0.15 — heavy current-season, least autocorrelated), QB (2.2/1.3/0.60 — flatter, most autocorrelated), WR (3.5/0.85/0.22 — moderate), TE (2.6/1.1/0.40 — sticky once established). Per-position override picked in enrich_production_metrics() before calling weighted_ppg_for_player(). Falls back to global weights if position absent. ModelParameters.position_recency_weights loaded from config. Pre-backtest calibration — accuracy_review.py will refine annually.
College school quality multiplier in rookie_model. Dominator rating at Alabama (P4_strong, ×1.0) ≠ NDSU (FCS, ×0.78). 5-tier system: P4_strong (1.0), P4_avg (0.95), G5 (0.87), FCS (0.78), unrated (0.90, default). Multiplier applied to raw college production stats BEFORE baseline subtraction in project_peak_ppg() — so a 35% target share at FCS becomes effectively 27.3% before the 20% baseline is subtracted, compressing the contribution from 15pp to 7.3pp. Applies to: college_target_share, college_rush_share, college_completion_pct, college_pass_ypa. Config-driven in rookie_model.school_quality. School lookup is case-insensitive; handles nflverse abbreviated formats (Penn St., Ohio St., North Dakota St.). school_name parameter added to project_peak_ppg(); get_rookie_rows() passes p.get('school'). school_quality_mult and school_quality_tier exposed in output rows.
Bayesian shrinkage on weighted_ppg for thin-sample players. Formula: w = n/(n+k), shrunk_ppg = w*player_ppg + (1-w)*position_prior. k=3.0, min_seasons_no_shrink=4. Prior = mean weighted_ppg of above-replacement players at each position. Only applied to above-replacement players (below-replacement are not shrunk toward starter mean). Rookies (seasons_included=0 or is_rookie=True) are always skipped. Key corrections: McCaffrey 21.4->16.3 (1 qualifying season / injury history), Daniels 20.9->19.0, Nabers 14.6->11.4, Bowers 15.0->13.0, Gibbs 18.5->14.9. 81 above-replacement players affected out of 825. Mean delta: +0.32 ppg. raw_weighted_ppg column preserved for transparency.
Rookie development ramp in LTV calculation. Adds per-year additional multipliers applied ON TOP OF the age curve factor for is_rookie=True rows in dynasty_lifetime_value(). Corrects survivor bias: age-21 WR factor (0.72) was derived from all 21-year-olds including experienced ones; true year-1 rookies produce ~55-65% of career peak (implied factor ~0.61), not 72%. Ramps: WR [0.85, 0.92, 1.0], TE [0.80, 0.88, 0.95, 1.0], RB [0.92, 0.97, 1.0], QB [0.88, 0.94, 1.0]. Config-driven in rookie_development_ramp block. Implementation: _load_development_ramps() in age_curves.py; dynasty_lifetime_value() accepts development_ramp parameter; _ltv_row() in enrich_age_curves() detects is_rookie and passes position ramp. No effect on non-rookie players.
Injury type granularity: structural ceiling discount applied to weighted_ppg BEFORE LTV calculation. Distinct from existing injury_discount (post-LTV availability factor). Structural injuries (seasons with <5 games = ACL proxy) now permanently bend the peak ceiling, propagating through the full 7-season LTV projection. New InjuryProfile fields: structural_ceiling_factor, n_structural_injuries, healthy_seasons_since_structural. Formula: base_ceiling[pos] - age_penalty(age_at_injury - cliff_age) + recovery_credit(healthy_seasons_since) × compound_multiplier^(n_structural-1). Config block: injury_model.structural_injury in engine_config.json. Pipeline: rankings_engine._apply_structural_ceiling() runs after rookie injection, before enrich_age_curves(). Availability discount (injury_model.alpha/floor) unchanged and still applied post-LTV.
Superflex QB dynasty corrections. (1) young_qb_ceiling.min_weighted_ppg: 10.0 → 4.0 — was blocking ascending QBs with limited samples (Maye 7.49, Williams 8.24, Stroud 8.11). (2) young_qb_ceiling.bonus_per_year_to_peak: 0.0362 → 0.18 — previous value produced ~11% max bonus for a 23yo QB; new value produces ~30-55% for ascending QBs 3 years from peak. (3) young_qb_ceiling.cap: 1.1 → 1.55 — allows meaningful upside for franchise QBs with thin sample. (4) superflex_qb_dynasty_premium multiplier: 1.12 — applies to all QB dynasty_ppg in superflex dynasty leagues to reflect structural scarcity (20 starter slots vs ~16 reliable starters). Known remaining issue: 30-32yo QBs (Mayfield, Goff, Murray) still overranked relative to PP due to flat QB age curve 31-34 in engine_config; will partially self-correct with 2025 data load. (5) young_qb_ceiling.archetypes_eligible: added dual_threat — Maye and Williams were miscategorized as dual_threat based on rookie scrambling stats, blocking their ceiling bonus. Trajectory filter (ascending/early_peak) already prevents veteran dual-threat QBs like Jackson from qualifying. (6) young_qb_ceiling.trajectories_eligible: [ascending, early_peak] → [ascending] only. early_peak included established starters (Daniels 25.7, Purdy 26.7, Nix 26.5) who were being over-boosted. Developmental ceiling bonus should only apply to true ascending QBs with thin samples.
Reference season advanced to 2025 after full 2025 season data loaded via Sleeper fetch. Recency weight index 0 now maps to 2025. Former-starter discount now correctly identifies QBs who backed up in 2025. ACTIVE_THRESHOLD unchanged at 2024 (broad pool). Backtest re-run and Monte Carlo optimization pending.
Backtest-driven corrections using 2019 reference year (2015–2019 training, 2020–2024 evaluation, n=320 players). Four targeted fixes: (1) RB age curve recalibrated at ages 26–33 — original cliff was too steep, causing systematic -1.9 to -3.8 ppg underranking of 26–30 year old RBs; ages 26/27/28/29 factors raised from 0.94/0.83/0.70/0.55 to 0.97/0.92/0.84/0.72. (2) RB peak window extended from [22,26] to [22,28] — data shows productive prime runs through age 28. (3) QB pocket_passer post_peak_modifier raised from 1.10 to 1.15 — veteran pocket QBs (Brady, Brees, Rodgers, Roethlisberger) were underranked because age cliff was too aggressive after 35; base QB curve also flattened at 36–39. (4) confidence_flags.age_uncertainty_rb_over lowered from 28 to 27 — flagging uncertainty one year earlier to match actual production cliff. Overall backtest: r=0.769 overall, RB bias improved by recalibration. WR model was most accurate (r=0.754, bias +0.18). QB had highest variance (stdev 4.92) driven by backup QBs with inflated starter-season wPPG.
Initial parameters. Baseline from POC build. All values theory-grounded, not yet back-tested against actual outcomes. Includes: young_qb_ceiling block (ascending pocket QB bonus), trade_model block (trade analyzer constants).
This page is server-rendered from engine_config.json at request time.
The version history is append-only and version-controlled. To audit historical changes:
git log -- engine_config.json.