Skip to content

fix(mpc): cost every challenger with Core's own model - #1036

Open
frahlg wants to merge 3 commits into
masterfrom
fix/shadow-costed-by-core
Open

fix(mpc): cost every challenger with Core's own model#1036
frahlg wants to merge 3 commits into
masterfrom
fix/shadow-costed-by-core

Conversation

@frahlg

@frahlg frahlg commented Aug 31, 2026

Copy link
Copy Markdown
Member

What this fixes

The shadow's verdict could not be checked. Production said Python beat Core by ~97 öre on 55 of 55 replans; the replay bench, on a snapshot pulled from the same box the same day, said Python cost 32 öre more. Same plans, opposite signs, no way to tell which to believe.

The bench was wrong, and the field number was right. The bench's ExternalOptimizerConfig literal omits CVaRWeight, while the box runs cvar_weight: 0.15, cvar_alpha: 0.9. The two were asking Python different questions. Sweeping it:

bench config py terminal-corrected py − dp
as-is (cvar 0) 2955.8 +32.2
cvar 0.15 @ 0.9 2822.5 −101.1
box recorded 2822.469 −101.14

Exact reproduction. dp_corr − rec_corr = 0.0, so Core's side was already identical in both.

Why the comparison still needed work

The numbers stand, but nothing forced them to. plan.TotalCostOre arrives from the optimizer and is only checked by ValidatePlan (±0.05 öre per slot); the comparison never recomputed it from Core's own arithmetic. And Python's real objective — Solver.ObjectiveOre, 5003 öre against the 5453 the plan costs — was never in the comparison at all, which is exactly what made the mismatch unreadable.

evaluatePlanOre now costs a given action sequence with the DP's own forward pass. The DP and the evaluator share stepPlanSlot / stepLoadpointSoC — one copy of the cost arithmetic, so a later change to one cannot silently invalidate every measurement taken with the other. It refuses rather than guesses: wrong action count, non-finite values, slot misalignment, SoC out of band, power past limits.

The shadow costs both plans this way, warns when Core's plan disagrees with Core's own bookkeeping, and records the self-reported figures beside the honest one. The bench takes the box's solver settings via FTW_MPC_BENCH_CVAR_WEIGHT / _CVAR_ALPHA / _MIP_GAP.

Measured across 13 snapshots, the evaluator's drift from the plans' recorded totals is 0.000000000 öre, freshly solved and box-recorded alike. The production verdict is unchanged to within 0.05 öre — it is now checkable rather than merely correct.

A second finding, surfaced not fixed

annotateCurtailment sets PVLimitW after TotalCostOre is summed, so a curtailing DP plan is costed as if it exported everything, while ValidatePlan costs Python's plan with its curtailment. On a negative-spot day that biases the comparison toward Python. None of these 13 snapshots has a negative spot slot, so it is not behind the gap above. Both sides are now costed at full PV — symmetric, and it keeps Core's own number self-consistent — and pv_curtailment_slots counts the case instead of leaving it silent. Worth its own look.

Also noted: planFromDiagnostic drops p.Storages and slot.Limits, so the bench replays a fleet box as a single aggregate battery with no fuse. Immaterial for these snapshots (one storage, limits never binding), but a snapshot is not yet a complete replay.

What this means for the Core champion

The −97 öre gap is real, correctly measured, and not an artifact: Python genuinely beats the Go DP by 30–100 öre per plan on these snapshots, ~1.7% of plan cost. The plan to delete the Python optimizer should wait until that gap is understood — the most likely source is structural, not numerical: Core plans deterministically against a haircut PV forecast while Python solves against a scenario tree.

make verify green.


Note

Medium Risk
Changes how Python shadow and replay metrics are computed (not live dispatch), but mistakes could misread champion vs challenger economics or hide real plan infeasibility; extensive new tests mitigate regression risk.

Overview
Python shadow and replay bench no longer trust each solver's self-reported TotalCostOre. Both sides are scored by walking the action sequence through Core's shared stepPlanSlot / evaluatePlan path (same arithmetic as the DP), then applying terminal correction. Challenger claims are stored separately as self_reported_ore and self_reported_objective_ore; infeasible plans get evaluation_refused_reason with no diff. Core re-costing its own champion triggers active_evaluation_drift_ore and a warning when it disagrees with what the DP reported.

Diagnostics and benches gain parity and knobs. Shadow blocks add PV curtailment slot counts. Replay bench adds FTW_MPC_BENCH_CVAR_WEIGHT, _CVAR_ALPHA, _MIP_GAP, optional terminal scaling, and columns for Python self-reported vs Core-evaluated costs. A new forecast-value bench re-solves snapshots three ways (as recorded, forecast flattened to horizon mean, forecast truncated) and compares first-slot battery power to see whether the price twin changes what hardware does now.

Tests lock evaluator ↔ DP cost agreement, refusal paths, shadow behavior, and forecast-bench helpers (including the zero-confidence coercion trap).

Reviewed by Cursor Bugbot for commit 2a8686f. Bugbot is set up for automated code reviews on this repo. Configure here.

…nch ask Python the same question

The shadow's verdict could not be checked. Production said Python beat
Core by ~97 öre on 55 of 55 replans; the replay bench, on a snapshot
pulled from that same box the same day, said Python cost 32 öre MORE.
Same plans, opposite signs, and no way to tell which number to believe.

The bench was wrong. Its ExternalOptimizerConfig omits CVaRWeight while
the box runs cvar_weight 0.15 at alpha 0.9, so the two were asking
Python different questions. Setting the box's value in the bench
reproduces the recorded figure exactly: -101.14 öre against the box's
-101.14. The field measurement was right all along.

So the numbers stand, but nothing forced them to. plan.TotalCostOre
arrives from the optimizer and is only checked by ValidatePlan; the
comparison never recomputed it from Core's own arithmetic, and Python's
real objective (Solver.ObjectiveOre, 5003 öre against the 5453 the plan
costs) was never in the comparison at all, which is what made the
mismatch so hard to read.

evaluatePlanOre now costs a given action sequence with the DP's own
forward pass -- one copy of the arithmetic, shared through stepPlanSlot
and stepLoadpointSoC, so a later change cannot silently invalidate the
measurements. It refuses rather than guesses: wrong action count,
non-finite values, slot misalignment, SoC out of band, power past
limits. The shadow costs both plans this way, warns when Core's plan
disagrees with Core's own bookkeeping, and records the self-reported
figures beside the honest one. The bench takes the box's solver
settings through FTW_MPC_BENCH_CVAR_WEIGHT / _CVAR_ALPHA / _MIP_GAP, so
it can be pointed at the question production actually asks.

Measured across 13 snapshots: the evaluator's drift from the plans'
recorded totals is 0.000000000 öre, freshly solved and box-recorded
alike, so the production verdict is unchanged to within 0.05 öre.

Also surfaces a curtailment asymmetry found on the way:
annotateCurtailment sets PVLimitW after TotalCostOre is summed, so a
curtailing DP plan is costed as if it exported everything while
ValidatePlan costs Python's with its curtailment. None of these
snapshots has a negative spot slot, so it is not behind the gap above.
Both sides are now costed at full PV and pv_curtailment_slots counts
the case instead of leaving it silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T11:51:09.437221Z c3393c6 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3393c6a85

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +122 to +126
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return fallback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject malformed benchmark solver settings

When any FTW_MPC_BENCH_* value contains a typo or non-numeric value, ParseFloat fails and this helper silently substitutes the fallback. In particular, a malformed CVaR weight becomes zero, recreating the configuration mismatch that this benchmark is intended to detect and potentially reversing the reported Python-vs-DP result while the log presents the fallback as the chosen setting. Fail the benchmark with the parse error instead of producing a misleading measurement.

Useful? React with 👍 / 👎.

}
if next > ceiling+planEvaluationLoadpointSoCTol {
return planEvaluation{}, fmt.Errorf(
"slot %d drives loadpoint %s to soc %.4f, above %.4f", i, lp.ID, next, ceiling)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loadpoint band rejects valid Core plans

Medium Severity

evaluatePlan refuses a loadpoint walk that lands more than 0.0005 past SoCMax. The DP looks its EV policy up on a coarse SoC grid and applies that action to a continuous SoC, so a legal Core plan can overshoot the ceiling by up to half an EV grid step — typically far more than planEvaluationLoadpointSoCTol. The battery path already grants one DP grid step via socBandTolerance for this reason; the loadpoint path does not. On a site with a plugged-in EV, Core can then decline to cost its own published plan and the shadow comparison is dropped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c3393c6. Configure here.

SoCMax: 0.9, TargetSoC: 0.8, TargetSlotIdx: 20, Levels: 11,
MaxChargeW: 11000, ChargeEfficiency: 0.9, SurplusOnly: true,
}
return p, evaluationSlots(24)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identity tests skip active loadpoints

Low Severity

The identity cases named with an active loadpoint and loadpoint on surplus only never set PluggedIn. LoadpointSpec.active() stays false, so both Optimize and evaluatePlan take the battery-only path. The sharing proof the PR relies on does not actually cover flex-load costing or the loadpoint SoC walk.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c3393c6. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Not approved: Cursor Bugbot finished as skipped and reported 2 potential issues that still need human review. HuggeK was assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested review from Leitet and erikarenhill August 31, 2026 11:55
The shadow gap had to be attributable before anyone acted on it. The
bench now scales each snapshot's TerminalSoCPrice through
FTW_MPC_BENCH_TERMINAL_SCALE, applied before anything solves so the DP,
Python and the correction all price stored energy the same way, and
prints raw totals and end SoC for both plans beside the corrected ones.

That is what the sweep needed, and the sweep settles the question: at a
common 201x401 grid with no terminal credit, python - dp is -7.0
öre/plan across 13 snapshots, and on the only snapshot recorded under
today's Core it is +39.4 in Core's favour. The -51.5 öre mean measured
at each blob's own recorded resolution was the old 41x81 grid the
Python era wrote, replayed faithfully.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Not approved: Cursor Bugbot finished as skipped and two unresolved findings still need human review. Reviewers were already assigned, so none were added.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

Spot prices publish around 13:00. Before that the box holds roughly 12
hours of real day-ahead price; the rest of the 48 h horizon is an ML
twin, blended toward the horizon mean by confidence. The owner asked
whether guessing tomorrow earns anything. MPC dispatches only the first
slot, so the question is not whether the guess moves a plan 30 hours
out — it is whether it moves the watt that reaches hardware now.

Two bench tests re-solve recorded /api/mpc/diagnose snapshots three
ways on one grid: as recorded, with every forecast slot flattened to
the horizon mean, and with the forecast slots deleted. They report the
first slot's battery power, how many hours the plans stay together, and
cost over the KNOWN window only, so all three are scored on the same
real prices instead of one being judged on 48 h of guesses.

The flattened variant uses confidence 1e-9, not 0. Core reads
confidence <= 0 as "caller did not fill this in" and coerces it to 1.0,
twice, so a variant written with a zero would silently measure total
trust — the opposite of what it claims. A fixture test pins that
coercion so the epsilon is not mistaken for fussiness.

A second bench shortens the known window by hand — 2, 4, 6, 8, 12 h —
leaving prices untouched, so it isolates the confidence rule from the
twin's accuracy and finds where the guess starts to matter.

Bench only; no planner code moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2a8686f. Configure here.

exactAB, n, benchActionDiffW, sameAB, n, sumAbsAB/float64(n), maxAbsAB)
t.Logf(" first-slot W, C vs A: identical(<1W) %d/%d, within %.0f W %d/%d, mean |Δ| %.1f W, max |Δ| %.1f W",
exactAC, n, benchActionDiffW, sameAC, n, sumAbsAC/float64(n), maxAbsAC)
t.Logf(" mean hours before the plans part: A/B %.2f h, A/C %.2f h", sumDivAB/float64(n), sumDivAC/float64(n))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary treats agreement as a split

Medium Severity

summarize always adds divABH / divACH to the “mean hours before the plans part” line, including rows where divABOK / divACOK is false. On agreement benchFirstDivergence returns the comparison length, so A/B becomes the full horizon (~48 h) and A/C the known window (~12 h). The per-row table prints none, but the summary — which this PR already says the three variants agree on — reports those sentinels as the time they parted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a8686f. Configure here.

sumDiffAC += float64(r.diffSlotsAC)
sumKnownN += float64(r.knownN)
sumSoCAC += r.cSoC - r.aSoC
sumSweepSoC += math.Abs(r.sweepSoC[1.5] - r.sweepSoC[0.5])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary mixes refused rows as zero

Low Severity

summarize never consults costable. A refused known-window cost leaves aCorr/bCorr/cCorr and the SoC fields at 0, so those rows enter the mean B−A / C−A öre and end-of-window SoC lines as exact agreement. A failed terminal sweep stores NaN in sweepSoC, and math.Abs of that NaN poisons the SoC-spread summary for every snapshot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a8686f. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved: Cursor Bugbot finished as skipped and two unresolved findings still need human review. Reviewers were already assigned, so none were added.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both champion and shadow plans in the Python-shadow diagnostic are now costed through Core's own forward pass (evaluatePlan, shared with the DP's own Optimize), instead of trusting each solver's self-reported total. Closes a real bug: the challenger's TotalCostOre is its own objective's opinion (CVaR-shaped, scenario-weighted) which isn't comparable to Core's DP cost — subtracting one from the other measured the objectives, not the plans. Adds refusal-as-a-result for infeasible/non-finite challenger plans and a self-consistency check on Core's own plan (ActiveEvaluationDriftOre), plus two new manually-run benches gated behind FTW_MPC_SNAPSHOT_DIR (not part of CI).

Findings:

  • CI is redcore (Go) / go test + vet both fail on TestTheAppCannotMakeAnotherOwner in internal/api (sql: database is closed). This looks unrelated to the diff: only internal/mpc is touched, and internal/mpc's own suite passes clean (ok ... internal/mpc 3.556s). Worth confirming it's flaky/pre-existing before merging on red CI — I can't tell from here whether it reproduces on main.
  • Minor — field semantics changed silently. ShadowPlan.TotalCostOre (json:"total_cost_ore", on the /api/mpc/diagnose payload) used to be the Python solver's self-reported cost; it's now Core's own evaluation of that plan. The old meaning moved to the new self_reported_ore field. If anything trends python_shadow.total_cost_ore over time (dashboard/alert), expect a step-change at this deploy that isn't a real regime shift — just a metric redefinition. Might be worth a heads-up to whoever watches that number.
  • Security screen: clean. No secrets, no new dependencies (stdlib only), no injection surfaces, no authz changes, no new network destinations.
  • Liked: sharing stepPlanSlot between the DP's forward pass and the new evaluator is the right move — a second copy of that arithmetic would've silently invalidated every comparison the day one of them drifted. Test coverage is thorough (cost-equivalence to the DP's own report, every refusal path, plan-ranking sanity, and a dedicated test that a challenger can't buy a better verdict by lying about its own cost).

Needs a human decision on the CI failure before merge — my read of the diff itself is clean, but red CI is red CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants