Ekho-Labs / ton-app · pull request #4

M3 Forecast engine
a champion per series, measured

Normalized demand history becomes forecasts that can defend themselves: SBC-routed candidate batteries, rolling-origin cross-validation, a finite-only championship, and a CI gate that fails the build if the median stops beating seasonal-naive on 787 real-shaped series.

branchmilestone/m3-forecast
basemilestone/m2-ingestion
headcf87e02
commits2
files17 changed
diff+20,049 −6
merged2026-08-14
benchmark gate PASS · 0.745 vs 1.043
00

What M3 actually built

M2 ends at a confirmed mapping. M3 is everything between that click and a per-series forecast that can defend itself: a complete panel, a routed battery of candidate models, a rolling-origin backtest, and a champion chosen by measurement rather than by preference.

01 · panel

A complete monthly panel

Every month between the first and the last is reconstructed and zero-filled, so a gap means “no demand” rather than “no row”. ADI and CV² are computed on that panel and each (sku, customer) lands in an SBC quadrant.

series.pyADI ≥ 1.32 CV² ≥ 0.49zero-filled
02 · championship

Measured, per series

Rolling-origin cross-validation scores every candidate by MASE with WAPE alongside; the best finite candidate wins, is refit on the full history, and carries its whole candidate list into the payload so the choice stays auditable.

MASEWAPE 6-month horizonfinite-only
03 · wiring

The stage, end to end

Confirming a mapping spawns the Modal forecast job; the signed callback persists series, forecasts and dq_report, flips the job to complete and deletes extracted.json — the second half of the retention promise.

Modal proxy auth§0.3 callback idempotentCI benchmark
battery
11 candidate models
statsforecast, routed by quadrant
horizon
6 months
with 80% intervals per series
fixture
787 series × 24 mo
committed, deterministic
gate
median MASE ≤ 0.9
and ≤ seasonal-naive, every CI run
retention
extracted.json
deleted when forecasts land
01

The forecast stage, end to end

Same two-stage shape as ingestion, same signing primitive, a different job. Hover any node to isolate its edges.

call chain authenticated crossing response state / fixture hover a node to isolate · scroll horizontally if clipped
tier 1Browser
screen
MappingPage · confirm
The user accepts or corrects the proposed columns. That click is the only thing that starts a forecast — nothing runs on upload.
tier 2Cloudflare Worker
route
POST …/mapping
Stores mapping_json, opens a stage='forecast' job and invokes Modal. A failed invocation marks both rows failed rather than leaving a job hanging.
guard
GET …/extracted
§0.3 signature only. Hands back the extracted.json M2 wrote — the single input contract of this stage.
401
callback
POST …/forecast-result
Writes the three payloads, then the same state-conditional transition M2 uses. Replays change nothing.
retention
extracted.json
R2.delete() runs once the forecasts are durable, so the intermediate parse artifact is destroyed too. After this only derived data exists.
The forecast job never touches a spreadsheet

Its input is extracted.json plus the columns the user confirmed — not the workbook, which no longer exists by then. Re-forecasting a dataset therefore never needs the original file, which is exactly what makes the retention promise survivable as a product decision.

02

SBC routing — the quadrant picks the battery

Average demand interval on one axis, squared coefficient of variation on the other. The thresholds are the classical Syntetos-Boylan-Croston cut-offs, and the two batteries deliberately overlap.

ADI — intermittency ↑
intermittentADI ≥ 1.32 · CV² < 0.49

Orders arrive with gaps but in similar sizes. Sparse battery: CrostonClassic, CrostonSBA, TSB(0.2, 0.2), ADIDA, IMAPA, plus Naive and HistoricAverage.

intervals: empirical CV-residual quantile — never Gaussian
lumpyADI ≥ 1.32 · CV² ≥ 0.49

Gaps and wildly varying sizes — the hardest quadrant and the one where a normal interval is most confidently wrong. Same sparse battery.

on the benchmark fixture, intermittent + lumpy = 48.8% of series
smoothADI < 1.32 · CV² < 0.49

Regular orders, stable sizes. Dense battery: AutoETS(12), AutoTheta(12), a constrained AutoARIMA (max_p/q=2, max_P/Q=1, season 12, nmodels=20, approximation on), plus CrostonSBA and HistoricAverage.

intervals: the model's own 80% band, refit per model group
erraticADI < 1.32 · CV² ≥ 0.49

Orders every month, sizes all over the place. Same dense battery — the volatility is in the magnitude, not in the arrival process.

SeasonalNaive(12) joins both batteries only above 12 months
CV² — size volatility →
The overlap is the point

CrostonSBA sits in the dense battery and Naive/HistoricAverage sit in the sparse one, so an intermittent series can still be won by a smooth-family model and the reverse. The quadrant narrows the search — it never decides the answer. Below 12 months of history the dense battery collapses to CrostonSBA + HistoricAverage and no season-12 model runs at all.

03

How a champion is actually chosen

Every candidate is backtested on the same folds, scored, then filtered by a rule that has nothing to do with accuracy: it must produce finite numbers everywhere.

Rolling-origin windows

Chosen by panel length; shorter histories get shorter, fewer folds.
monthshwindowsstepMASE season
≥ 2464312
≥ 186331
≥ 123321
< 12no cross-validation — every series takes the named fallback

Below 24 months the MASE denominator is the one-step difference rather than the 12-month one, so scores stay comparable inside a panel but not across panels of different lengths. If a seasonal difference is identically zero — a perfectly flat series — the scale falls back to the mean absolute first difference, so the series is still scored instead of dividing by zero.

The selection rule, in order

forecast.py · _score then _run_battery
1Sort candidates by mean MASE across folds; ties break toward the simpler model via MODEL_PREFERENCE.
2Walk that order and take the first candidate whose CV predictions are all finite and whose full-history forecast is all finite.
3No candidate survives → named fallback with an explicit fallback_reason.
4Champion is refit on the whole history; means are clamped at zero.
5Intervals: the model's own 80% band for AutoETS/AutoTheta/AutoARIMA, otherwise the 0.8 quantile of absolute CV residuals, clamped so lo ≤ mean ≤ hi.
6The full candidate list, both scores, the interval method and the seasonal-naive comparison ship inside the payload.
Why “finite” is a selection criterion and not an assertion

A season-12 model on a barely-longer-than-a-season panel returns NaN for the folds it cannot fill. Asserting finiteness at the end would fail the whole job for one bad series; making it part of the championship simply moves that series to its next-best candidate. The regression test locks the behaviour, and the payload is guaranteed strictly-finite JSON — which is what lets M4 render charts and M5 write a spreadsheet without defensive checks. See finding R1.

04

The measured claim

“The model adapts to their data” is a marketing sentence until something measures it. The benchmark job runs the engine directly against a committed 24-month fixture on every CI run and fails the build outside the band.

787
series in the fixture
24
months of history
48.8%
intermittent + lumpy
~102 s
wall clock, whole panel
6
month horizon, 80% bands

Median MASE across the 787 series — lower is better

MASE 1.0 means “no better than the seasonal-naive benchmark on the same folds”. Bars are scaled to 1.20.
champion · current 0.745
champion · as reported at merge 0.720
gate ceiling · CI fails above 0.900
seasonal-naive baseline 1.043

The champion median is roughly 29% better than the seasonal-naive baseline measured on the same folds. The two champion rows are the same engine before and after the honesty fix in #5: the baseline used to be credited with a fabricated 0.00 wherever SeasonalNaive was excluded from the battery, and removing that lie moved the reported median from 0.720 to 0.745. See finding R2.

benchmark gatepass

Three assertions, every CI run

modal-app/tests/test_benchmark.py runs in its own CI job (uv run pytest -q tests/test_benchmark.py -s) and is excluded from the fast suite via -k "not benchmark", so a 100-second engine run never blocks the unit tests.

tests/test_benchmark.py — the band
# asserted on the summary dict run_forecast() returns assert median_champion_mase <= 0.9 assert median_champion_mase <= median_seasonal_naive_mase assert 0.40 <= intermittent_lumpy_share <= 0.60 band holds — median 0.745 vs seasonal-naive 1.043, share 48.8%

The second assertion is the one that matters: an absolute ceiling can be met by a lucky fixture, but beating the seasonal baseline on the same folds cannot.

m3 gatepass

Fixture 3, all the way through

scripts/qa/m3.sh drives upload → confirm → forecast against a real Worker with the real Python jobs, then asserts every series carries a champion, a backtest score or a named fallback, and a 6-month horizon — and that the intermediate artifact is gone.

scripts/qa/m3.sh — the assertions it prints
PASS: parse -> awaiting_mapping PASS: mapping confirmed -> forecast job <id> PASS: forecast -> complete PASS: N series, all with champion + backtest + 6-month forecast PASS: extracted.json deleted after forecast M3 QA gate PASS
quoted from the PR body median champion MASE 0.720 (gate ≤ 0.9) vs seasonal-naive 1.043, intermittent+lumpy share 48.8% (gate 40–60%), ~102 s wall. scripts/qa/m3.sh (fixture 3 end-to-end incl. extracted.json deletion) PASSES against a real local Worker + the real Python jobs.
05

What landed, by area

17 files, +20,049 −6 — of which 18,889 lines are the committed benchmark fixture. The engine itself is 203 lines.

engine3 files · 361
  • ton_app/forecast.py203
  • ton_app/series.py85
  • ton_app/jobs.py73
tests3 files · 159
  • tests/test_forecast.py91
  • tests/test_series.py35
  • tests/test_benchmark.py33
worker3 files · 126
  • routes/internal.ts69
  • routes/mapping.ts36
  • routes/datasets.ts21
gate & ci3 files · 94
  • scripts/qa/m3.sh67
  • .github/workflows/ci.yml19
  • .github/workflows/deploy-dev.yml8
fixture2 files · 18,980
  • fixtures/demo-24mo-long.csv18,889
  • scripts/gen-fixture-24mo.mjs91

Committed rather than generated so the benchmark measures the engine and nothing else. See finding N5.

deps3 files · 329
  • modal-app/uv.lock302
  • ton_app/app.py25
  • modal-app/pyproject.toml2
06

Findings & design notes

Two defects the milestone found in itself — both about numbers that looked fine — and five decisions that explain the engine. Click any row to expand.

resolved open notes
07

Where this leaves the plan

M3 makes the product's central claim falsifiable

Every series now carries a champion chosen by a backtest, the candidates it beat, both error measures, the interval method, and a seasonal-naive comparison — and CI refuses to go green if the median stops beating that baseline. Two of the milestone's own findings are cases where a number looked right and was not; both were caught by running the thing rather than by reading it, and both are now locked by tests.

deliberately not here
  • Nothing renders the forecasts — no screen, no chart, no narrative. That is M4.
  • No payload caching contract; the routes exist but every read hits R2.
  • No export and no deletion path — M5.
  • Deployed-preview gates still shared the R2 enablement blocker with #3.
what M4 inherits
  • Strictly finite JSON — charts and spreadsheets need no defensive checks.
  • The completed job id as the dataset revision, which becomes the cache key.
  • Per-series candidates and interval methods, which become the drill-down drawer.
  • A numbers-only payload the narrative rule can be validated against.