Forecast-JSON comprehension eval — results (2026-07-09)
About this run
Section titled “About this run”What this tests: whether labeling each hour in a forecast’s time series with local time
(2026-03-28T09:00:00+01:00) instead of UTC (2026-03-28T08:00:00Z) changes whether LLMs —
especially small, cheap ones — can correctly answer real-world questions like “how much rain
falls at 9am?” This gated whether MeteoSwiss’s meteoswissLocalForecast tool should ship hourly
precipitation data with local-time or UTC timestamps.
How to read it: look for score and pass counts broken down by “local” vs “utc” — the bigger
the gap, the more the UTC labeling confuses a model about which hour a value belongs to. The
cleanest, least-confounded test is point-num/range-num (exact-value lookups at a specific
hour): 100% correct with local-time labels, near-0% with UTC, across every model tier.
Verdict: keep local-time labeling. Full detail — cost, the provider list, every question family, caveats about which comparisons are and aren’t confounded — is below.
Full results
Section titled “Full results”Open the full promptfoo results ↗ — the complete, interactive report (all providers, all rows, searchable). Also embedded below, scrollable in place:
Forecast-JSON comprehension eval — results (2026-07-09)
Section titled “Forecast-JSON comprehension eval — results (2026-07-09)”This file is immutable — it records what one specific set of runs found on this date. See
../../README.md for the current headline verdict and quick-start, and
../spec.md for the methodology/design these runs exercised. A future rerun adds a
new dated file under docs/results/ rather than editing this one, so links into this file never
go stale.
What the build-time smoke test found (real spend, ~$0.01, gemini-2.5-flash-lite only)
Section titled “What the build-time smoke test found (real spend, ~$0.01, gemini-2.5-flash-lite only)”This isn’t the real result — it’s one tiny model on one run, used to validate the harness. But it’s worth recording because it’s exactly the kind of signal this suite is built to surface:
GATE: tiny tier x {local, utc} — primary fixturetiny | local n=9 score=100% [correct:9]tiny | utc n=9 score= 44% [wrong:5 correct:4]Broken down by family, the UTC variant scored 0% (not just lower — zero) on point-bool,
point-num, range-num, argmax-time, and dst-trap, while range-bool, argmax-day,
cross-field, and availability stayed at 100% on both variants. That pattern — precise
hour-level lookups collapsing under UTC while day-level/boolean/any-rain questions survive — is
plausible (hour-level lookups need an exact +1/+2h mental conversion the model visibly didn’t
do reliably; day-level questions don’t) and is the shape of evidence a real run should produce
at scale before anyone acts on it. One model, nine questions, is not that evidence yet.
Verification performed during the build (not the full paid run)
Section titled “Verification performed during the build (not the full paid run)”pnpm run generate— 33 programmatic + 4 judge test cases, 8 fixture JSON blobs, all committed undergenerated/for PR reviewability.pnpm test— 29 offline unit tests (ground truth against the real captured fixture values,toUtcIsocorrectness including the DST boundary, and the lenient scorer across every leaf kind + compound + unparseable). All passing, no network.pnpm run dryrun— promptfoo’s built-inechoprovider, $0, validated the full pipeline wiring end-to-end (including confirmingscorer.ts’s dynamicimport()ofscoring-core.tsactually works under promptfoo’s runtime, which was the biggest unverified-until-tested risk in the design — see../spec.md“Q-A” for the full story of why that’s a plain.tsfile today).pnpm run smoke— 1 real cheap model (gemini-2.5-flash-lite), ~$0.01, confirmed the real OpenRouter path, produced the gate-table finding above, and surfaced the cost-tracking caveat (see../spec.md“Cost tracking caveat”).pnpm -r lint/pnpm -r build/pnpm -r teststay green across the monorepo; this package is not wired into any CI workflow.
The full paid sweep across all 13 models was subsequently run — see “Full sweep results (2026-07-09)” below for the outcome, the account-funding blocker that limited it, and the resulting (partial but statistically clean) verdict.
Full sweep results (2026-07-09)
Section titled “Full sweep results (2026-07-09)”Cost: ~$0.32 actually spent (verified against OpenRouter’s own account API, not promptfoo’s
internal cost field — see ../spec.md “Cost tracking caveat”). Well under the
$2-4 target and the $10 hard ceiling. The sweep did not complete cleanly, though — 61.9% of
the 462 scheduled calls (286 rows) came back as real API errors rather than model answers.
Root-caused (systematic debugging, not guessed) to three independent, unrelated causes, none
of which are eval-suite bugs in the harness itself:
- Dominant cause — this OpenRouter account has never purchased credits.
GET /api/v1/auth/keyreports"limit": 10, "limit_remaining": 9.66, "is_free_tier": true;GET /api/v1/creditsreports"total_credits": 0. The$10figure is a per-key spend cap that would apply once the account has a real prepaid balance — it is not itself spendable money. Free-tier accounts draw against a much smaller complimentary balance for metered usage, and this run exhausted it partway through (concurrency=4, 13 models spending simultaneously):402 "Insufficient credits. This account never purchased credits."hit 7 of 13 providers, always on their later-scheduled test cases (7-day/station/multi-series fixtures), never on the very first (primary-fixture) calls — consistent with “ran out partway through,” not a per-request limit. Action needed from Max: add real credits to the OpenRouter account at https://openrouter.ai/settings/credits before a complete, all-13-provider rerun — this is a billing action outside what a coding session should do unprompted. mistralai/mistral-large-2512andmistralai/mistral-medium-3.1: 100% failure, unrelated to credits —404 "No endpoints available matching your guardrail restrictions and data policy. Configure: https://openrouter.ai/settings/privacy". An account-level privacy/data- policy setting on OpenRouter excludes these two endpoints entirely. Action needed from Max: toggle the relevant setting at that URL, or accept these two EU frontier/cheap-tier providers stay excluded from this suite.gpt-5-mini,gpt-5-nano,gemini-3.1-pro-preview,gpt-5.2: 100% failure, also unrelated to credits exhaustion (all four failed even on their very first, primary-fixture call) — these four reject this suite’s cost-control config outright. Three returned400 "Reasoning is mandatory for this endpoint and cannot be disabled"in response topassthrough.reasoning.enabled: false;gpt-5.2returned a distinct402 "This request requires more credits... You requested up to 65536 tokens, but can only afford 2708"— meaning it silently ignoredmax_tokens: 256and reserved a huge hidden reasoning-token budget instead of refusing outright. Fixed in this PR:promptfooconfig.yamlnow gives these fourpassthrough: { reasoning: { effort: 'minimal' } }instead ofenabled: false— the lowest reasoning budget these endpoints accept, rather than trying to disable reasoning entirely. Not yet re-verified against a live call (would need real credits — see #1). Caveat for the rerun: gpt-5.2’s error text (“requested up to 65536 tokens”) looks less like a reasoning-budget problem and more likemax_tokens: 256not being applied to that endpoint at all — 65536 reads like an unconstrained default ceiling, not a reasoning-effort side effect. Once real credits are added, the 402 will likely disappear regardless (small enough balance no longer being the binding constraint), which would make this look fixed without confirmingmax_tokensactually caps gpt-5.2’s completion length. On rerun, check gpt-5.2’stokenUsage.completioningenerated/results.jsonstays near 256, not just that the call succeeds — otherwise this one provider could quietly cost far more per call than the rest of the sweep.
The headline verdict, from the 7 providers that did complete cleanly (all 3 tiers represented — frontier: opus-4.8, sonnet-5; cheap: haiku-4.5, gemini-3.1-flash-lite; tiny: gemini-2.5-flash-lite, llama-3.3-70b, ministral-8b — 18/18 or 27/27 API-successful calls each on the primary, DST-spanning fixture):
tier variant accuracyfrontier local 18/18 100.0%frontier utc 11/18 61.1%cheap local 18/18 100.0%cheap utc 9/18 50.0%tiny local 25/27 92.6%tiny utc 12/27 44.4%By question family, the pattern is uniform across every tier — including frontier — which per
this doc’s own reporting rule (“a family missed uniformly across tiers is a format defect, not a
capability gap”) is a clean signal. One family needs to be pulled out before drawing that
conclusion, though: dst-trap asks for the UTC offset at a specific local hour (+02:00), which
the local-variant JSON prints literally and the UTC-variant JSON structurally omits — the prompt
also forbids outside knowledge. That question is definitionally unanswerable from the UTC
variant by design (see ../spec.md “Question set”), so its 0% UTC score is
expected and shouldn’t be read as a comprehension failure on its own. Excluding it:
- Day-level / boolean-only questions (
argmax-day,availability,cross-field,range-bool) stay at or near 100% on both variants, every tier. point-numandrange-num(exact-value and range-sum lookups at a specific local hour) — the cleanest, non-rigged evidence — score 100% local / 0% UTC in every single tier, frontier included (opus-4.8/sonnet-5both2/2local,0/2UTC). These questions are fully answerable from either variant’s JSON in principle; the only difference is whether the model has to convert a UTC instant to the Europe/Zurich wall-clock hour before doing the lookup, and it essentially never gets that conversion right.argmax-time(which local hour had the most rain) shows the same direction, though less absolute:0/2cheap and0/3tiny, but frontier only drops to1/2(50%) rather than0/2— still a large regression from its 100% local score, just not as total as point-num/range-num.dst-trap, once understood as structurally rigged rather than a capability signal, is consistent with (not separate evidence for) the same story: local time survives because the offset is printed; UTC can’t, by construction.
Two distinct mechanisms are both at work here, worth keeping separate: local time removes an error-prone UTC→local conversion step from every hour-level value lookup (point-num, range-num, argmax-time), and separately makes the UTC offset itself directly readable rather than requiring calendar/DST knowledge the prompt explicitly disallows (dst-trap).
This partial-sample verdict was: keep local-time labeling, do not switch to UTC — see below for the complete 13-provider rerun, which confirms it and strengthens the evidence.
Full sweep results, complete (2026-07-09, after credits + ZDR fix)
Section titled “Full sweep results, complete (2026-07-09, after credits + ZDR fix)”Max added real OpenRouter credits and disabled the account’s ZDR (data-policy/guardrail) setting
that had been blocking the two Mistral endpoints. Rerun of the full sweep: all 13 providers
completed with 0 API errors (462 total scheduled calls; Results: 0 errors (0%) in the
promptfoo CLI summary) — every failure below is a real scoring outcome (wrong/unparseable), not
an infrastructure failure. All three root causes from the partial run are confirmed fixed:
credits exhaustion (resolved by the top-up), the Mistral 404 guardrail (resolved by the ZDR
toggle — both mistral-large-2512 and mistral-medium-3.1 completed all 33 calls each, no
different from any other provider), and the four reasoning-config rejections (resolved by
passthrough.reasoning.effort: 'minimal' — see the caveat on gemini-3.1-pro-preview below,
which needed one more round of investigation).
Gate (tiny tier, primary DST-spanning fixture) — decides #99 / PROD release:
tiny | local n=36 score=86% [wrong:5 correct:31]tiny | utc n=36 score=46% [wrong:19 correct:16 partial:1]All tiers × variant, primary fixture:
tier variant n scorecheap local 36 100% [correct:36]cheap utc 36 54% [correct:19 wrong:15 unparseable:1 partial:1]frontier local 45 93% [correct:42 unparseable:3]frontier utc 45 58% [wrong:17 correct:26 unparseable:2]tiny local 36 86% [wrong:5 correct:31]tiny utc 36 46% [wrong:19 correct:16 partial:1]Question family × variant, primary fixture (n=13 per cell — the full set, not a partial sample):
family local utcargmax-day 92% 92% (date-level, both variants encode it identically — expected)argmax-time 100% 15%availability 92% 88%cross-field 100% 88%dst-trap 85% 15% (structurally unanswerable from UTC by design — see below)point-bool 85% 69%point-num 100% 8%range-bool 100% 100% (coarse yes/no across a labeled range — robust either way)range-num 85% 0%The full sample confirms and sharpens the partial-run finding. point-num and range-num
remain the cleanest, non-confounded evidence — exact-value and range-sum lookups at a specific
local hour — now at 100% local / ~0% UTC across the complete 13-provider, n=13-per-cell sample,
not just a 7-provider subset. (point-num UTC is 8%, not a flat 0% — one gpt-5.2 response out of
13 correctly converted 08:00 local to 07:00 UTC and answered right; see “Copilot review fixes”
below for how this single-row correction was found and verified. range-num UTC stays a flat 0%,
untouched by that fix.) argmax-time shows the same collapse (100% → 15%). dst-trap
stays excluded from the headline claim for the same reason as before: it asks for the UTC offset
itself, which the local-variant JSON prints literally and the UTC-variant JSON structurally
cannot express — its near-zero UTC score is expected by construction, not new comprehension
evidence. argmax-day and range-bool show no gap at all — a genuinely new nuance the
partial sample didn’t have the range to surface clearly: coarse day-level and yes/no-across-a-
range questions don’t require converting a specific hour, so they’re insensitive to the
labeling choice. The comprehension gap is specifically about pinning down a particular hour’s
value, not about the format being confusing in general.
Per-model breakdown, primary fixture (n=9 per cell, every one of the 13 real providers) — every single model, no exceptions, scores higher on local than UTC:
provider local utcclaude-opus-4.8 100% 67%claude-sonnet-5 100% 56%claude-haiku-4.5 100% 44%gemini-3.1-pro-preview 67% 22% (see caveat below — inflated by a truncation bug)gemini-3.1-flash-lite 100% 56%gemini-2.5-flash-lite 100% 44%gpt-5.2 100% 78%gpt-5-mini 100% 56%gpt-5-nano 67% 44%mistral-large-2512 100% 67%mistral-medium-3.1 100% 56%ministral-8b-2512 89% 33%llama-3.3-70b-instruct 89% 56%Surprising finding, investigated and explained: gemini-3.1-pro-preview is the one outlier
that doesn’t score ~100% on local. Traced (not guessed) by reading its raw failing responses in
generated/results.json: on local, all 3 failures are [unparseable] no JSON object recovered from response — the model emits a visible "Thinking: ..." preamble (a side effect of
reasoning.effort: 'minimal' still leaking some reasoning tokens into the completion, matching
summarize.ts’s own [!] reasoning tokens leaked: 7612 flag for this provider) that consumes
the max_tokens: 256 budget before the actual JSON answer, truncating mid-response (e.g.
{"utc_offset": "+02:00 cut off with no closing brace). This is an eval-harness token-budget
artifact, not a comprehension failure — the model was mid-way through emitting the correct
answer when it ran out of budget in two of the three cases inspected. On UTC, by contrast,
6 of 8 failures are genuine wrong answers (e.g. “expected 0.3±0.05, got 0.5”) in the same pattern
as every other model, plus 2 more truncation artifacts. Net effect: this provider’s local score
(67%) understates its real comprehension (which is likely ~100%, consistent with every other
frontier model, once truncation is excluded), while its UTC score (22%) is a mix of real
misunderstanding and the same artifact. Does not change the verdict — if anything, correcting
for the artifact would widen this model’s local-vs-UTC gap, not narrow it. Flagged here rather
than silently adjusted, since fixing max_tokens for this one provider is a config change for a
future PR, not something to patch mid-report.
No other provider — including both previously-blocked Mistral models — shows any anomaly relative
to the rest of its tier. The Mistral models track their tier peers closely (mistral-large-2512
100%/67% local/utc, right in line with opus-4.8; mistral-medium-3.1 100%/56%, in
line with haiku-4.5/gpt-5-mini; ministral-8b-2512 89%/33%, in line with the other tiny-tier
models) — the earlier 404 guardrail block was purely an account setting, not a sign these models
handle the format differently.
Judge slice (pnpm run eval:judge, open-ended prompts judged by opus-4.8 via
llm-rubric): 12/12 passed (100%), 0 errors, across all 3 judged providers
(opus-4.8, haiku-4.5, gpt-5-nano) — confirms the local-time format supports genuinely
useful, non-hallucinating, timing-aware answers in open-ended use (e.g. “explain to a cyclist
planning an 08:00 commute”), not just the programmatic lookup slice.
Secondary tracks (directionally consistent, not part of the headline gate):
- 7-day fixture: frontier 90%/60% local/utc, cheap 75%/50%, tiny 50%/50% (n=8 per tier-variant cell — small enough that the flat tiny-tier result is plausibly noise, not a contradiction).
- Multi-series mock (shape A vs shape B, secondary design input for a future sunshine/wind
feature, not this PR’s gate): shape B slightly ahead of shape A in cheap/tiny tiers (88%/72%
vs 85%/77%), roughly even in frontier (80% vs 82% — corrected from 76%/78% after the
extractJsonfix below rescued agpt-5.2row on each shape; still a mild signal, not a strong one either way). Superseded by a conclusive run — see “Multi-series eval, expanded” below, which does not share this data point (different, dedicated 5-provider run, nogpt-5.2) and is unaffected by this fix.
Cost — actual, verified against OpenRouter’s own account API (not promptfoo’s internal cost
field, confirmed non-functional for OpenRouter — see ../spec.md “Cost tracking
caveat”): account usage before this rerun was $0.335 (the earlier partial 7/13 sweep); after
the full 13-provider sweep + judge slice, $1.289. This rerun’s actual spend: ~$0.95 (eval
sweep + judge slice combined), comfortably under the $2-4 target and the $10 hard ceiling —
confirmed via GET https://openrouter.ai/api/v1/auth/key (usage field) and GET https://openrouter.ai/api/v1/credits, cross-checked against summarize.ts’s own token-based
estimate (~$1.07 for the eval sweep alone) — same order of magnitude, real API confirms the
estimate isn’t wildly off.
Verdict: keep local-time labeling (PR #99 as shipped). Do not switch to UTC. Confirmed, not
just carried over, by the complete 13-provider rerun. The core, non-confounded evidence
(point-num/range-num) is unchanged in direction and now covers the full sample: 100% local /
~0% UTC (one single-row exception on point-num, see “Copilot review fixes” below — does not
change the direction or the verdict), every tier, every one of the 13 real models included. No
provider — including the two
previously-blocked Mistral endpoints — bucks the trend. The one apparent outlier
(gemini-3.1-pro-preview’s depressed local score) traces to an eval-harness token-budget
artifact, not a comprehension counterexample, and correcting for it would strengthen rather than
weaken the finding. The judge slice adds an open-ended, non-programmatic confirmation that the
local-time format produces useful answers, not just correct programmatic-lookup answers.
Local-format cleanliness check (this suite’s secondary purpose — catching defects in the
local format before release, independent of the UTC comparison): tiny tier scored 86% (31/36)
on local this run, similar to the partial run’s 92.6%. Failures are wrong:5, still scattered
across different models/families per the per-model table above (no single model or question
family dominates tiny-tier local failures) — read as ordinary model noise at the tiny-tier
capability floor, not a local-format defect worth acting on before release.
Both blockers from the previous partial run are now resolved
Section titled “Both blockers from the previous partial run are now resolved”- OpenRouter credits added by Max — the account is no longer
is_free_tier. All 13 providers completed with 0 API errors this run (previously 61.9% error rate from free-tier exhaustion). - ZDR/data-policy setting disabled by Max — both
mistral-large-2512andmistral-medium-3.1completed all 33 calls each this run (previously 100% failure with404 "No endpoints available matching your guardrail restrictions"). - The
passthrough.reasoning.effort: 'minimal'fix works for all four previously-rejecting models (gpt-5-mini,gpt-5-nano,gemini-3.1-pro-preview,gpt-5.2) — no more 400/402s. One residual nuance:gemini-3.1-pro-previewstill leaks a little visible reasoning text into its completions, occasionally truncating the JSON answer under the tightmax_tokens: 256budget — see the trace above. Doesn’t change the verdict; flagged as a possible follow-up (raisemax_tokensfor this one provider) rather than fixed here, since it doesn’t block the gate decision. (Subsequently done — see “Multi-series eval, expanded” below, which gave this provider its ownmax_tokens: 1024config and confirmed the fix.)
Copilot review fixes (2026-07-09)
Section titled “Copilot review fixes (2026-07-09)”GitHub Copilot left 6 review comments on PR #100, covering three scorer-correctness bugs, a fail-fast/determinism gap in ground-truth generation, a stale PR description, and unrelated root-lockfile churn. All six are fixed on this branch. Two of the three scorer fixes are provably score-neutral against every real response already on disk; one is not, and its effect on the committed tables above is reconciled here at zero additional API spend.
extractJsonmulti-block recovery (src/scoring-core.ts) — SCORE-AFFECTING. The old first-{-to-last-}slice spans multiple JSON-ish blocks and fails to parse when a model’s response contains more than one (e.g. a reasoning leak with a brace in prose, followed by the real trailing answer). Replaced with a balanced-brace scanner that tries each top-level block, last-first. This is a strict superset of the old attempts, so no currently-passing row can regress — verified against every failing row in every committedgenerated/results*.json: only 3 rows flip, allgpt-5.2, all fail→pass (generated/results.json:val-0800UTC, andms-argmax-sunshineon both multiseries shapes — see below for the exact effect on each table).unavailablehallucination check (src/scoring-core.ts) — score-neutral. Now requires an explicit decline (hourly_available: false) AND no fabricatedmm, rejecting both{"hourly_available": true, "mm": 2}(already rejected) and the two cases that previously slipped through:{"mm": 2}(bare fabrication, no flag at all) and{"hourly_available": false, "mm": 2}(mixed signal). Verified against all 13 realstation-nullresponses ingenerated/results.json: every one was a clean{"hourly_available": false}— 0 rows flip.coerceHourISO-timestamp safety (src/scoring-core.ts) — score-neutral. Now prefers a clock (HH:MM) pattern over a bare 1-2 digit run, so a full ISO timestamp (e.g."2026-03-28T09:00:00+01:00") coerces to9, not20(the year’s first two digits). Verified against every realargmax-timeresponse: none used an ISO timestamp — 0 rows flip.- Fail-fast on missing ground truth (
src/questions.ts) — score-neutral.dst-offsetandavailability-day2used to fall back to a default (?? "+02:00",day2Raineddefaulting totrue) when the fixture was missing the relevant reading/day, silently producing wrong ground truth instead of failing loudly. Now throws, matching the existingday1Objpattern. Verified dead-code on the committed primary fixture (pnpm run generatebefore/after this change produces a byte-identicalgenerated/tests.json— confirmed viagit diff --stat) and locked with a new ground-truth test asserting the guard’s preconditions hold. - Root
pnpm-lock.yamlchurn —@swc/core(+60 platform variants) had been concretely resolved as an optional peer ofts-nodefrom when the eval package was briefly a root workspace member; reverted via a cleanpnpm installat the repo root (the eval package stays excluded — seepnpm-workspace.yaml), dropping@swc/corereferences back to the same unresolved-optional-peer count asmain. - PR description reconciliation — the PR #100 body’s “What’s validated vs. not run yet”
section was stale (written before the full paid sweep in “Full sweep results, complete” above
ran); updated via
gh pr editto state the full sweep + judge slice ran and the verdict stands.
Reconciling the one score-affecting fix, at $0: the raw model responses for every completed
run are already committed/available in generated/results*.json (response.output), so instead
of a paid re-run, src/summarize.ts gained a --rescore mode (pnpm run summarize -- --rescore [path]) that recomputes success/score/outcome for every row from its raw output using the
current scorer, then reuses all the existing gate/family/cost tables — no new OpenRouter calls.
Run across every committed results file:
generated/results.json(full 13-provider sweep): exactly 3 rows flip, allgpt-5.2, all unparseable→correct —val-0800(primary, UTC variant) andms-argmax-sunshineon both multiseries shapes (the OLD embedded 5-question multiseries track this file predates the expansion for — see “Multi-series eval, expanded” below, which is a separate run and unaffected). Every other row across all 429 graded rows in this file is bit-identical before/after. The figures above (gate table, all-tiers table, family table, per-model table, the old multiseries mention) have been updated to match; the tiny-tier gate is untouched (nogpt-5.2in that tier) and the verdict is unchanged — local time still strictly dominates UTC everywhere,point-numUTC moves from a flat 0% to 8% (1/13, still overwhelmingly one-sided).generated/results-multiseries.json,results-compact.json,results-sevenday-full-baseline.json(the dedicated 5-provider tracks used for “Multi-series eval, expanded” and “Compact long-series representation” below, neither of which includesgpt-5.2): zero rows flip — those sections’ numbers stand exactly as reported.generated/results-judge.json: not applicable — the judge slice usesllm-rubric(Opus-graded), not this scorer;--rescoreis a no-op on it by design (skips rows withoutvars.expectedJson).
Multi-series eval, expanded (2026-07-09)
Section titled “Multi-series eval, expanded (2026-07-09)”Max was happy with the local-vs-UTC verdict (“cheap, settles the local-time question”) and asked for two follow-ups ahead of a new GitHub issue (#101) covering the full multi-series expansion (sunshine, wind, temperature — generalizing #98/#99’s precipitation groundwork): make the Shape A vs B comparison conclusive (the original 5-question mock only gave a mild signal), and test whether a compact long-series representation rescues the tiny-tier drop seen on the 7-day fixture in the full sweep above.
Expanded src/multiseries.ts from 5 to 11 questions. The original 5 (point-cross,
argmax-sunshine, argmax-wind, a 3-field compound check, sunshine cross-field) don’t stress
conditional multi-series reasoning much. Added 6: ms-argmax-precip (single-param argmax, a
control), ms-best-walk-hour (conditional argmax: dry AND sunny AND calm, tie-broken by most
sunshine — new family ms-compound-argmax), ms-windy-dry-hour (existence + earliest-match:
dry AND wind>=14km/h — new family ms-existence, deliberately NOT the same hour as the
single-param wind argmax, to check the model isn’t just pattern-matching “the windy hour”),
ms-point-1900 (a second point-cross combination, all-different-from-the-original-hour’s
true/false pattern to avoid the model learning a shortcut), and two more cross-field checks
(ms-wind-avg-check, ms-precip-total-check, mirroring the existing sunshine one). Every new
expected value is derived programmatically from HOURLY_TABLE via .filter()/.reduce() in
multiseriesGroundTruth, same “derived not hand-typed” discipline as the rest of this suite —
verified by hand against the raw table before running anything paid.
Run: 5 representative providers (not all 13, to control cost per Max’s suggestion) — 1-2 per
tier: opus-4.8 + gemini-3.1-pro-preview (frontier), haiku-4.5 (cheap), gpt-5-nano +
gemini-2.5-flash-lite (tiny) — × 11 questions × 2 shapes = 110 calls, 0 API errors.
shape n scoremultiseries-a (parallel) 55 76% [correct:42]multiseries-b (unified) 55 84% [correct:46]By family — Shape B’s advantage concentrates exactly where cross-parameter combination is required, and disappears (or slightly reverses) for single-parameter lookups:
family A Bms-compound-argmax 20% 60% (conditional argmax: 1/5 -> 3/5 on B; n=5 per shape, small)ms-cross-field 87% 100% (does the daily total match the hourly sum)ms-argmax (single-param) 93% 87% (no shape benefit — nothing to cross-reference)ms-point-cross 67% 73%ms-existence 80% 80% (tied)The overall 84% vs 76% margin is 46/55 vs 42/55 — a 4-question swing, not a landslide; treat “B wins” as directional, not statistically overwhelming, and read the family table as where the mechanism shows up (cross-parameter questions) rather than as independently conclusive per-cell.
Per-provider: opus-4.8 82%→100% (A→B), gemini-2.5-flash-lite 64%→91% (7/11→10/11), haiku-4.5
tied 73%, gemini-3.1-pro-preview 100%/100% (see below), gpt-5-nano 64%→55% (7/11→6/11, the one
exception — its shape-B misses are wrong-hour guesses of the same kind it makes on shape A, not
shape-specific). 4 of 5 providers score equal-or-better on Shape B, but the two tiny-tier models
split: gemini-2.5-flash-lite strongly prefers B (+3 questions), gpt-5-nano slightly prefers A
(-1 question). Net favors B, but this is worth stating plainly rather than folding into “4 of 5” —
the tiny tier is the one the project cares most about, and it isn’t unanimous.
gemini-3.1-pro-preview’s max_tokens fix confirmed working. Gave this provider its own
config (max_tokens: 1024 instead of the shared 256) in promptfooconfig.yaml — see that
file’s comment — after tracing its depressed local-fixture score in the full sweep to a
token-budget truncation artifact, not a comprehension failure. Result: 100%/100% on both shapes
here, 22 calls, 0 truncations. Fix confirmed, not just theorized.
Recommendation: build the multi-series expansion as Shape B (hourly[] of {time, precip_mm, sunshine_minutes, wind_kmh} objects), not Shape A (parallel per-parameter arrays) — on balance,
not as an unqualified landslide. It’s the low-regret pick: net-better-or-tied for 4 of 5 providers,
no model dramatically worse, a mechanistically sensible story (Shape A’s cost is index/timestamp
alignment across separate arrays, which is exactly where its disadvantage concentrates), and it’s
the cleaner shape to extend to more series later. But the tiny tier split between the two shapes
rather than moving in lockstep, so call this “B, with consistent theory support,” not “conclusively
B” — the n is real evidence, not proof beyond a reasonable doubt. Posted as a comment on #101
alongside the full breakdown.
Compact long-series representation (2026-07-09)
Section titled “Compact long-series representation (2026-07-09)”The full sweep found tiny tier scoring ~50% on the 7-day (~168-entry) fixture, vs. 86-100% on the shorter primary fixture — raising the question of whether the sheer length/sparsity of the array (144 of 168 hourly entries are exactly 0 on this fixture) was the cause, and whether a compact representation would help.
Caution applied before concluding anything: that ~50% figure came from just 2 questions
(sevenday-wettest, sevenday-afternoon-shower) — too thin to draw a “long series breaks tiny
models” conclusion from. Expanded sevenDayQuestions() from 2 to 5 (added sevenday-thu-hour15
a point-num lookup, sevenday-mon-dry a day-level bool, sevenday-tue-total a day total) so any
finding would be less confounded by one hard question dominating a tiny sample.
Candidate compact representation (src/compact-representation.ts): list only hours with
measurable rain (value > 0), with an explanatory note that unlisted hours were 0mm. On this
fixture that’s a 168→24-entry reduction, ~67% smaller by byte size (19,205 -> 6,374 bytes for
the JSON blob). Same underlying instants/ground truth as the full representation — only the
hourly array’s density changes, isolating that one variable the same way fixture.ts isolates
local-vs-UTC.
Ran both representations against the same 5 questions, same tiny-tier providers, for a clean apples-to-apples comparison (not the confounded 2-question figure from the full sweep):
representation tiny-tier score (n=20, 4 models: gpt-5-nano, gemini-2.5-flash-lite, ministral-8b-2512, llama-3.3-70b-instruct)full (168 entries) 75% (15/20)compact (sparse, 24 entries) 80% (16/20)Honest finding: marginal improvement, not a rescue. Traced per-model: only
ministral-8b-2512 actually improved (80%→100%); the other three tiny models scored
identically on both representations. The dominant recurring failure
(sevenday-afternoon-shower, a 4-hour range-sum) persists almost unchanged across both — 3 of 4
tiny models undershoot that sum by 0.1-0.3mm on full representation, and by a near-identical
margin on compact. Looking at the actual wrong answers: the common miss is 1.2mm against an
expected 1.3mm (0.2+0.6+0.4+0.1 over the 14:00-17:00 range) — dropping the 17:00 endpoint rather
than a general arithmetic error, so this may be partly inclusive/exclusive range-boundary handling
rather than pure summation accuracy. Either framing supports the same conclusion below (constant
across both representations, so it doesn’t change the full-vs-compact comparison), but if #101
acts on “why tiny models miss this,” the boundary-handling angle is worth checking too, not just
raw addition. This means the tiny-tier weak point on long series is (at least partly) multi-hour
range/summation accuracy, not array length/sparsity — compacting the array doesn’t fix that, it
just gives the model less irrelevant data to scan past on the way to the numbers it still adds
up wrong. The original ~50% figure was mostly an artifact of a thin 2-question sample where that
one hard question happened to dominate, not a broad “tiny models can’t cope with long series”
signal — with 5 questions, tiny tier holds up much better (75-80%) on this fixture than the
partial sample suggested.
Recommendation for #101: adopt the compact/sparse representation as a reasonable, low-risk size optimization for long-horizon requests (smaller payload, no measured downside, small positive signal), but don’t expect it alone to solve range-sum accuracy for the smallest models — that would need a different intervention (e.g. the tool computing and surfacing period totals itself, rather than expecting the model to sum a long hourly series), out of scope for this PR.
Cost for both follow-ups: multi-series expansion + compact-representation comparison + apples-to-apples full-representation rerun (to fix the confound above) together cost ~$0.51 (account usage $1.4514 -> $1.9574, verified via OpenRouter’s account API), comfortably within the $1-2 target and the $10 ceiling.
Current status
Section titled “Current status”- Verdict stands: keep local-time labeling on PR #99, do not switch to UTC. Complete 13-provider sweep + judge slice, 0 API errors, all 6 Copilot review comments addressed and reconciled at $0 (see “Copilot review fixes” above).
- Issue #101 — “Implement all hourly time series in getLocalForecast” — tracks the sunshine/wind/temperature expansion this suite’s secondary tracks fed into (Shape B recommendation, compact-representation recommendation). Its step 1 (evals before implementation) is done; step 2+ (confirming OGD param codes, schema/implementation) is not started, deliberately.
- PR #100 has not been merged (explicit constraint — do not merge). Acting on the verdict itself (e.g. touching PR #99’s schema, or implementing #101) is out of scope for this PR, which is evals-only.