Skip to content

Commit 39acbbb

Browse files
owgreen-devclaude
andcommitted
Add the static scorer, which answers only where the data can (M12, ADR-015)
Precomputes the flagship model's decision surface into results/scorer_grid.json; the page picks a cell. No server, no inference at request time. The naive version of this idea lies, and finding that out reshaped it. The raw grid is 60% empty -- 258 of 432 iso x resource x service combos have zero projects -- and the emptiest cells score HIGHEST. The first build gave "ERCOT | Nuclear | Other | 5 MW" a 99.1% chance of being built off zero projects: the model extrapolating a one-hot combination it never saw jointly. Shipping that would be the shareable-but-fictional artifact ADR-013 refuses, sitting on top of the repo whose whole pitch is refusing them. So a cell carries a score only where the data supports one -- 957 of 6,480. Extrapolation turned out to have two dimensions, and fixing one left the other: with the categorical floor in place the model still answered "5 MW coal plant in ERCOT" with 89.6%, when real ERCOT coal runs a 660 MW median and is 45.9% built. It had learned "smaller completes more" and was asked about a size that does not exist. Each combo now also carries the size range it actually spans (p5-p95), and out-of-range sizes are unscored. The two rejections say different things -- "no projects like this" vs "none this size; they run 17-885 MW" -- because the difference is the interesting part. Also fixed while building it: - service NA became an explicit "Not specified" level. ERCOT and NYISO report service for ZERO projects, so requiring a value silently dropped two whole ISOs -- the same reporting-artifact trap as ADR-012's developer field. - base rate was computed on all cohorts (11.2%) where the published figure is the eval cohorts' (8.3%). Same statistic, different population: ADR-014 again. It now reads the committed blob. Honesty constraints, all tested: completion only (no P(withdraw) control ever -- AUC 0.58 is a coin flip dressed as a number); is_hybrid derived from the resource, never picked (verified 100% agreement, 0 mismatches); congestion pinned at each ISO's median and published; no aggregate over cells, since the grid is unweighted. Gotcha worth the comment it got: p:null came from a pandas float column, which stores None as NaN -- and json.dumps emits NaN, which is invalid JSON. The live page died at parse time while all tests passed, because Python's json.loads ACCEPTS NaN. Round-tripping cannot catch it; both writers now use allow_nan=False. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 4b66315 commit 39acbbb

13 files changed

Lines changed: 624 additions & 4 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: setup lint test demo benchmark viz viz-assets
1+
.PHONY: setup lint test demo benchmark viz viz-assets scorer
22

33
# All targets run offline and keyless against committed sample fixtures.
44

@@ -23,3 +23,6 @@ viz: ## render the figures to build/queue.html (set GRIDQUEUE_LBNL_PATH for ful
2323

2424
viz-assets: ## regenerate the committed README figures (needs: uv sync --extra assets)
2525
uv run python -m gridqueue.viz.export
26+
27+
scorer: ## precompute the model decision surface (set GRIDQUEUE_LBNL_PATH for full data)
28+
uv run python -m gridqueue.viz.scorer

docs/assets/drift-composition.png

101 Bytes
Loading

docs/assets/null-decile-line.png

-11 Bytes
Loading

docs/decisions.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,49 @@ explicit `require_fips` switch rather than filtering unconditionally.
345345
**Guard.** `tests/unify/test_resources.py` asserts the two publishers agree at the precision the repo
346346
actually prints (4dp — `build_headline` rounds for the payload, so a tighter tolerance fails on the
347347
rounding, not on a disagreement). Any future battery-drift consumer imports the same fold.
348+
349+
---
350+
351+
## ADR-015 — The scorer answers only where the data can answer
352+
353+
**Decision.** `viz/scorer.py` precomputes the flagship model's decision surface into
354+
`results/scorer_grid.json`; the page picks a cell. No server, no inference at request time. **A cell
355+
carries a score only where the data supports one** — 957 of 6,480 cells. The rest are emitted with
356+
`p: null` and the page says *why*.
357+
358+
**Why the floor exists.** The naive version of this idea lies. The raw grid is **60% empty** (258 of
359+
432 `iso × resource × service` combos have zero projects; 72% have <10), and **the emptiest cells
360+
score highest**: the first build gave `ERCOT | Nuclear | Other | 5 MW` a **99.1%** chance of being
361+
built off **zero** projects — the model extrapolating a one-hot combination it never saw jointly. A
362+
picker shipping that would be the shareable-but-fictional artifact ADR-013 refuses, sitting on top of
363+
the repo whose whole pitch is refusing them.
364+
365+
**Extrapolation has two dimensions, and fixing one leaves the other.** The categorical floor (n ≥ 30)
366+
still let the model answer *"5 MW coal plant in ERCOT"* with **89.6%** — real ERCOT coal runs a
367+
**660 MW median** and is 45.9% built; the model had learned "smaller completes more" and was asked
368+
about a size that does not exist. So each combo also carries the size range it actually spans
369+
(p5–p95, not min/max, so one outlier cannot widen it), and out-of-range sizes are unscored too. The
370+
two rejections carry **different messages** — "no projects like this" vs "none this size, they run
371+
17–885 MW" — because the difference is the interesting part.
372+
373+
**Consequences.**
374+
- **It is the benchmarked model, not a refit** — same features and same train cohorts as
375+
`benchmark/run.py`, so the surface explored is the one whose AUC 0.72 is published (ADR-014's
376+
one-source rule). A test spot-checks that cells *are* `score_entry`'s output, not an approximation.
377+
- **`is_hybrid` is derived, never picked** — it is `type_2.notna()`, exactly `"+" in resource_type`
378+
(verified: 100% agreement, 0 mismatches on 38k rows). A free control would generate impossible
379+
states.
380+
- **Congestion is pinned, never picked**`log_mw_ahead` is "MW ahead of you in your ISO on filing
381+
day", computed from the whole queue; a filer cannot supply it. Pinned at each ISO's median, and the
382+
pinned values are published so the assumption is inspectable.
383+
- **`service` gained an explicit "Not specified" level.** ERCOT and NYISO report it for **zero**
384+
projects, so requiring a value silently dropped two ISOs — the same reporting-artifact trap as
385+
ADR-012's developer field. NA is a state the model saw (`_design` encodes unknowns as all-zeros).
386+
- **No withdrawal control, ever** (AUC 0.58 — a coin flip dressed as a number), and no aggregate over
387+
cells: the grid is unweighted (as many nuclear cells as solar, though solar has 13,479 projects and
388+
nuclear 211), so a "median cell" would be meaningless. The comparator is the published base rate.
389+
390+
**Gotcha.** `p: null` came from a pandas float column, which stores None as **NaN** — and
391+
`json.dumps` emits `NaN`, which is **invalid JSON**. The live page died at parse time while every
392+
test passed, because Python's `json.loads` *accepts* NaN. It cannot be caught by round-tripping; both
393+
writers now pass `allow_nan=False` and refuse it at build time.

results/figures_full.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

results/scorer_grid.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/gridqueue/paths.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
RESULTS = ROOT / "results"
5353
BENCHMARK_FULL = RESULTS / "benchmark_full.json"
5454
FIGURES_FULL = RESULTS / "figures_full.json"
55+
# The model's precomputed decision surface for the static scorer (ADR-015).
56+
SCORER_GRID = RESULTS / "scorer_grid.json"
5557

5658
# The through-year of the LBNL edition backing the labels. Drives the
5759
# horizon-observability gate (a cohort is eligible only if cohort + H <= this).

src/gridqueue/viz/build.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ def render(payload: dict) -> str:
5656
tpl = TEMPLATE.read_text(encoding="utf-8")
5757
if PLACEHOLDER not in tpl:
5858
raise ValueError(f"template is missing the {PLACEHOLDER} placeholder")
59-
blob = json.dumps(payload, separators=(",", ":"))
59+
# allow_nan=False: json.dumps happily emits NaN, which is invalid JSON and kills
60+
# the page at parse time. Python's json.loads accepts it, so this cannot be caught
61+
# by round-tripping in a test - it has to be refused at write time.
62+
blob = json.dumps(payload, separators=(",", ":"), allow_nan=False)
6063
# </script> inside the JSON would close the host <script> tag early.
6164
blob = blob.replace("</", "<\\/")
6265
return tpl.replace(PLACEHOLDER, blob)
@@ -100,7 +103,9 @@ def main() -> None:
100103
if args.emit_figures:
101104
fig_path = Path(args.emit_figures)
102105
fig_path.parent.mkdir(parents=True, exist_ok=True)
103-
fig_path.write_text(json.dumps(split_figures(payload), separators=(",", ":")))
106+
fig_path.write_text(
107+
json.dumps(split_figures(payload), separators=(",", ":"), allow_nan=False)
108+
)
104109
print(f"wrote {fig_path} ({fig_path.stat().st_size / 1e3:.0f} KB, geometry excluded)")
105110

106111
html = render(payload)

src/gridqueue/viz/figures.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ def build_flagship() -> dict:
199199
}
200200

201201

202+
def build_scorer_card() -> dict:
203+
"""The precomputed scorer grid, if it has been built (`make scorer`).
204+
205+
Read rather than computed here for the same reason as build_flagship: it needs a model
206+
fit, and `make viz` is a 2-second render. Absent -> the card stays hidden.
207+
"""
208+
if not paths.SCORER_GRID.exists():
209+
return {}
210+
return json.loads(paths.SCORER_GRID.read_text())
211+
212+
202213
def build_payload(df: pd.DataFrame | None = None, source_label: str = "") -> dict:
203214
"""Assemble the full figure payload. ``df`` defaults to the canonical loader."""
204215
from gridqueue.viz.geo import load_county_paths
@@ -212,6 +223,7 @@ def build_payload(df: pd.DataFrame | None = None, source_label: str = "") -> dic
212223
payload.update(build_drift(prepared))
213224
payload["headline"] = build_headline(all_entries)
214225
payload["flagship"] = build_flagship()
226+
payload["scorer"] = build_scorer_card()
215227
payload.update(build_null(prepared))
216228
payload["meta"] = {
217229
"source": source_label or str(paths.LBNL_SAMPLE),

0 commit comments

Comments
 (0)