Skip to content

Commit 9e576df

Browse files
mstrathmanclaude
andcommitted
feat: auto is the forecast default when no model is named
A bare forecast(ts, value, h) now means "the best model available in this database": rolling-origin selection over the bundled statistical models plus every eligible registered forecast student, per series, deterministically. Combined with implicit student discovery, the whole distillation loop needs zero options: distill once, and every plain forecast call everywhere starts considering your student. Measured cost of the richer default: ~0.1 ms at 96 points, ~3 ms at 480, ~60 ms at the 4096-point context cap (vs ~2 ms pinned); the escape hatches are pinning a model or lowering context_limit, both documented. detect_anomalies keeps theta-classic (no auto selection exists for scoring) and backtest keeps theta-classic (it evaluates a named model); predict keeps knn5-incontext. The RFC's reserved default-ts alias machinery is replaced by these concrete defaults. Consequence made coherent: bare `candidates` now narrows the default pool instead of erroring with "requires model=auto"; candidates plus a pinned model remains a contradiction and still errors. Fixed a latent bug this exposed: when an aggregate step raised (bad horizon, query-string misuse), xFinal still ran the full pipeline with unvalidated arguments, and the auto path's horizon-sized scratch then hit malloc(0) and surfaced as a spurious MemoryError. Steps now set an errored flag and the finals short-circuit; every step-phase error path sets it. Tests: default==auto byte-equality, bare-candidates narrowing plus the pinned-model contradiction, and the updated default-model assertions. 183 pass on the plain build, 218 on onnx, ASan clean. Docs, README, options reference, auto guide, RFC, and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53927e2 commit 9e576df

7 files changed

Lines changed: 68 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010

11+
- **`auto` is the forecast default.** A `forecast(ts, value, h)` call with
12+
no `model` option now selects the best model per series instead of pinning
13+
theta-classic, at a cost of single-digit milliseconds on typical series
14+
(pin a `model` or lower `context_limit` on latency-critical long-series
15+
paths). `candidates` no longer requires naming `model:"auto"` first, since
16+
bare `candidates` narrows the default pool; combining `candidates` with a
17+
pinned model remains an error. `detect_anomalies` (no auto exists) and
18+
`backtest` (evaluates a named model) keep `theta-classic` as their
19+
default. Also hardened: a step-phase aggregate error (bad horizon, query
20+
string misuse) now short-circuits the final, instead of running the
21+
pipeline with unvalidated arguments.
1122
- **Registered students compete under `auto` by default.** With
1223
`'{"model":"auto"}'` and no `candidates` list, the pool is the bundled
1324
statistical models plus every eligible registered forecast student:

‎README.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,13 @@ trailing JSON object (`'{"confidence_level":0.9}'`).
140140

141141
### Calibrated intervals, auto-selection, and backtesting
142142

143-
- **Auto-selection.** `'{"model":"auto"}'` picks the model with the lowest
144-
rolling-origin error per series, deterministically, over the bundled
145-
statistical models plus every eligible registered forecast student: distill
146-
once and your model competes with the baselines automatically, at every
147-
call site. `'{"model":"auto","candidates":[...]}'` narrows the pool. The
148-
result document's `model` field reports the winner.
143+
- **Auto-selection is the default.** A call with no `model` option picks the
144+
model with the lowest rolling-origin error per series, deterministically,
145+
over the bundled statistical models plus every eligible registered forecast
146+
student: distill once and your model competes with the baselines
147+
automatically, at every call site. `'{"candidates":[...]}'` narrows the
148+
pool; `'{"model":"theta-classic"}'` pins one. The result document's
149+
`model` field reports the winner.
149150
- **Conformal intervals.** `'{"interval_method":"conformal"}'` replaces the
150151
default Gaussian band with a distribution-free one calibrated on out-of-sample
151152
residuals. On smooth data the default band is overconfident; conformal lands

‎predict-forecast.c‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,8 @@ static int fc_discover_candidates(sqlite3 *db, const ForecastOpts *opts,
15241524
static int fc_resolve_models(sqlite3 *db, const ForecastOpts *opts,
15251525
int horizon, FcModels *mc, char **errmsg) {
15261526
memset(mc, 0, sizeof(*mc));
1527-
mc->is_auto = opts->model && strcmp(opts->model, "auto") == 0;
1527+
/* no model named means "the best available": auto is the default */
1528+
mc->is_auto = !opts->model || strcmp(opts->model, "auto") == 0;
15281529
mc->model = mc->is_auto ? NULL : resolve_ts_model(opts->model);
15291530
if (!mc->is_auto && !mc->model) {
15301531
/* not a bundled model: maybe a registered forecast student (PSFCST) */
@@ -2821,6 +2822,7 @@ typedef struct {
28212822
i64 horizon; /* forecast only */
28222823
int have_horizon;
28232824
int started;
2825+
int errored; /* a step raised: the final must not run the pipeline */
28242826
} AggSeries;
28252827

28262828
static void agg_series_clear(AggSeries *a) {
@@ -2869,19 +2871,22 @@ static void agg_step(sqlite3_context *ctx, int argc, sqlite3_value **argv,
28692871
PREDICT_ERR_SCHEMA, fname, fname, horizon_arg >= 0 ? ", horizon" : "");
28702872
sqlite3_result_error(ctx, msg ? msg : PREDICT_ERR_SCHEMA, -1);
28712873
sqlite3_free(msg);
2874+
a->errored = 1;
28722875
return;
28732876
}
28742877
if (horizon_arg >= 0) {
28752878
if (sqlite3_value_type(argv[horizon_arg]) != SQLITE_INTEGER) {
28762879
sqlite3_result_error(
28772880
ctx, PREDICT_ERR_HORIZON ": horizon must be an integer", -1);
2881+
a->errored = 1;
28782882
return;
28792883
}
28802884
a->horizon = sqlite3_value_int64(argv[horizon_arg]);
28812885
a->have_horizon = 1;
28822886
if (a->horizon < 1 || a->horizon > FORECAST_MAX_HORIZON) {
28832887
sqlite3_result_error(
28842888
ctx, PREDICT_ERR_HORIZON ": horizon out of range 1..1000", -1);
2889+
a->errored = 1;
28852890
return;
28862891
}
28872892
}
@@ -2903,6 +2908,7 @@ static void agg_step(sqlite3_context *ctx, int argc, sqlite3_value **argv,
29032908
sqlite3_result_error(
29042909
ctx, PREDICT_ERR_HORIZON ": horizon must be constant within a group",
29052910
-1);
2911+
a->errored = 1;
29062912
return;
29072913
}
29082914
int opt_i = horizon_arg >= 0 ? horizon_arg + 1 : 2;
@@ -2916,6 +2922,7 @@ static void agg_step(sqlite3_context *ctx, int argc, sqlite3_value **argv,
29162922
sqlite3_result_error(
29172923
ctx, PREDICT_ERR_OPTIONS ": options must be constant within a group",
29182924
-1);
2925+
a->errored = 1;
29192926
return;
29202927
}
29212928
}
@@ -2954,6 +2961,7 @@ static void agg_step(sqlite3_context *ctx, int argc, sqlite3_value **argv,
29542961
if (a->n >= FORECAST_MAX_TOTAL_ROWS) {
29552962
sqlite3_result_error(ctx, PREDICT_ERR_RESOURCE ": too many input rows",
29562963
-1);
2964+
a->errored = 1;
29572965
return;
29582966
}
29592967
if (a->n == a->cap) {
@@ -2968,6 +2976,7 @@ static void agg_step(sqlite3_context *ctx, int argc, sqlite3_value **argv,
29682976
a->cap = 0;
29692977
a->n = 0;
29702978
sqlite3_result_error_nomem(ctx);
2979+
a->errored = 1;
29712980
return;
29722981
}
29732982
a->ts = nts;
@@ -3045,6 +3054,11 @@ static void agg_fc_final(sqlite3_context *ctx) {
30453054
sqlite3_result_null(ctx);
30463055
return;
30473056
}
3057+
if (a->errored) { /* a step already raised; the result is discarded */
3058+
agg_series_clear(a);
3059+
sqlite3_result_error_code(ctx, SQLITE_ERROR);
3060+
return;
3061+
}
30483062
sqlite3 *db = sqlite3_context_db_handle(ctx);
30493063
int horizon = (int)a->horizon;
30503064

@@ -3148,6 +3162,11 @@ static void agg_an_final(sqlite3_context *ctx) {
31483162
sqlite3_result_null(ctx);
31493163
return;
31503164
}
3165+
if (a->errored) { /* a step already raised; the result is discarded */
3166+
agg_series_clear(a);
3167+
sqlite3_result_error_code(ctx, SQLITE_ERROR);
3168+
return;
3169+
}
31513170
sqlite3 *db = sqlite3_context_db_handle(ctx);
31523171

31533172
ForecastOpts opts;

‎tests/test_aggregate.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,12 @@ def test_sqlalchemy_composes_the_aggregate(db):
348348
assert len(out) == 1
349349
d = json.loads(out[0].doc)
350350
assert d["status"] == "ok" and len(d["rows"]) == 4
351+
352+
353+
def test_no_model_defaults_to_auto(db):
354+
rows, _ = syn.trend_season(n=96, seed=57)
355+
syn.load_into(db, rows)
356+
bare = db.execute("SELECT forecast(ts, value, 6) FROM series").fetchone()[0]
357+
auto = db.execute("SELECT forecast(ts, value, 6,"
358+
" '{\"model\":\"auto\"}') FROM series").fetchone()[0]
359+
assert bare and bare == auto # byte-identical documents

‎tests/test_backtest.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,12 +273,20 @@ def test_candidates_restrict_and_select_pool(db):
273273
assert both == tsb_only # auto picks tsb (the better)
274274

275275

276-
def test_candidates_requires_auto(db):
276+
def test_candidates_narrows_the_default_and_rejects_named_models(db):
277277
series, _ = syn.trend_season(n=100, seed=1)
278278
syn.load_into(db, series)
279+
# auto is the default, so bare candidates narrows the default pool
280+
doc = json.loads(db.execute(
281+
"SELECT forecast(ts, value, 6, ?) FROM series",
282+
(json.dumps({"candidates": ["theta-classic"]}),)).fetchone()[0])
283+
assert doc["status"] == "ok"
284+
assert doc["model"] == "theta-classic"
285+
# but candidates alongside a pinned non-auto model is a contradiction
279286
with pytest.raises(sqlite3.OperationalError) as e:
280287
db.execute("SELECT forecast(ts, value, 6, ?) FROM series",
281-
(json.dumps({"candidates": ["theta-classic"]}),)).fetchone()
288+
(json.dumps({"model": "tsb",
289+
"candidates": ["theta-classic"]}),)).fetchone()
282290
assert "PREDICT_ERR_OPTIONS" in str(e.value)
283291

284292

‎website/src/content/docs/guides/auto-and-conformal.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ description: Let sqlite-predict pick the model, and get calibrated prediction in
55

66
## Auto-selection
77

8-
You don't have to hand-pick a model. `'{"model":"auto"}'` runs a rolling-origin
9-
backtest of each candidate on the series and forecasts with the lowest-error one.
10-
The choice is deterministic: the same rows pick the same winner.
8+
You don't have to pick a model at all: **auto is the default**. A call with no
9+
`model` option runs a rolling-origin backtest of each candidate on the series
10+
and forecasts with the lowest-error one. The choice is deterministic: the same
11+
rows pick the same winner.
1112

1213
```sql
13-
SELECT forecast(ts, value, 12, '{"model":"auto"}') FROM readings;
14+
SELECT forecast(ts, value, 12) FROM readings; -- auto, implicitly
1415
```
1516

17+
Selection backtests every candidate per call, which costs single-digit
18+
milliseconds on typical series and tens of milliseconds at the 4096-point
19+
context cap. On a latency-critical path over long series, pin a `model` or
20+
lower `context_limit`.
21+
1622
The default pool is the bundled statistical models (`theta-classic`,
1723
`stub-seasonal-naive`, `tsb` for intermittent demand) **plus every eligible
1824
registered forecast student**: distill a model once and `auto` competes it

‎website/src/content/docs/reference/options.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ rather than being ignored.
1111

1212
| Key | Type | Default | Meaning |
1313
| --- | --- | --- | --- |
14-
| `model` | string | `theta-classic` | Forecast model, or `auto`. |
14+
| `model` | string | `auto` | A model id to pin, or `auto` (the default: best of the stats plus eligible registered students, per series). |
1515
| `confidence_level` | number (0,1) | 0.9 | Nominal coverage of the interval. |
1616
| `interval_method` | `residual` \| `conformal` | `residual` | Interval construction. |
1717
| `folds` | integer | 20 | Rolling origins used by `auto` / conformal. |

0 commit comments

Comments
 (0)