Skip to content

feat: v0.7.0 — statistical diagnostics + global/panel + classical/multivariate models - #254

Merged
sipemu merged 80 commits into
mainfrom
feat/v0.7.0-diagnostics-model-coverage
Aug 23, 2026
Merged

feat: v0.7.0 — statistical diagnostics + global/panel + classical/multivariate models#254
sipemu merged 80 commits into
mainfrom
feat/v0.7.0-diagnostics-model-coverage

Conversation

@sipemu

@sipemu sipemu commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

v0.7.0 — Close the Crate→Extension Gap (Diagnostics + Model Coverage)

Exposes previously SQL-unreachable anofox-forecast crate capabilities through the established Rust FFI → C++ → SQL-macro pattern. 3 phases, 9 plans, all verified against the built extension.

Phase 1 — Statistical Diagnostics (STAT-01..03, RESID-01..04)

  • ts_adf(_by), ts_kpss(_by), combined ts_stationarity(_by) (four-way verdict)
  • ts_ljung_box_by, ts_durbin_watson_by, ts_jarque_bera_by, combined ts_residual_diagnostics_by
  • statsmodels-cross-checked

Phase 2 — Global / Panel Models (GLOB-01..03)

  • ts_forecast_panel_by — GlobalETS / GlobalTheta / GlobalCroston (fit-once-emit-many native table function, ragged-panel alignment)
  • statsforecast M4 parity: GlobalETS +1.8%, GlobalTheta −0.7%, GlobalCroston −6.9%

Phase 3 — Classical & Multivariate Models (CLAS-01..03)

  • ts_forecast_by methods 'GARCH' (conditional volatility = √variance) and 'Kalman' (state-space; local-level / local-linear-trend)
  • new multivariate ts_forecast_var_by (VAR, value_cols LIST → long-format per-variable forecasts)
  • arch/statsmodels parity: GARCH 0.897, Kalman 1.000/0.992, VAR 1.000 (exact)

Notes

  • ForecastOptions FFI ABI extended additively (garch_p/garch_q/kalman_model) — backward-compatible; cbindgen header regenerated.
  • New non-globbed C++ sources listed in CMakeLists.txt: diagnostics.cpp, ts_forecast_panel_native.cpp, ts_forecast_var_native.cpp.
  • arch added to benchmark/.venv (comparison group) for GARCH parity.
  • Rebased/merged onto current main (DuckDB 1.5.5, feedback-banner feat: add feedback banner and guard the table-function entry points #252, smoke-test ci: smoke-test the shipped artifact on every platform #253); full make release builds clean and all examples/*.sql run green locally.
  • Docs added under docs/api/ + docs/reference/models/; anofox-forecast-models skill updated (33→36 models).

Deferred (intentional): INTER-01 intermittent-demand classification; prediction intervals for the new surfaces (route via existing conformal path); VAR auto-order selection; per-panel VAR.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

sipemu and others added 30 commits August 20, 2026 22:32
Confirms crate API surface, 5-layer exposure recipe, STRUCT return pattern,
and per-requirement implementation notes for STAT-01..03 + RESID-01..04.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-01..04 residual diagnostics

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire ts_adf through all five layers: Rust core validation module + adf()
wrapper, FFI AnofoxStationarityResult struct + anofox_ts_adf export (cbindgen
header regenerated), C++ TsAdfFunction STRUCT scalar in diagnostics.cpp,
extension registration, and ts_adf_by macro. Adds examples/diagnostics,
docs/api/10-diagnostics.md, and a statsmodels cross-check harness in
benchmark/diagnostics. Establishes the shared scaffolding 01-2/01-3 extend.

Landed on main from a completed executor run (worktree isolation surfaced
edits into the main tree; reconciled here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add KPSS level-stationarity test (ts_kpss / ts_kpss_by, reusing the
StationarityOut/AnofoxStationarityResult shape) and the combined ADF+KPSS
four-way verdict (ts_stationarity / ts_stationarity_by) with a new
AnofoxCombinedStationarityResult FFI struct (verdict char[32]) and
TsStationarityFunction STRUCT scalar.

Four-way verdict truth table (corrected from the plan's draft, which swapped
the trend/difference labels):
  (adf_stat, kpss_stat) -> stationary | trend_stationary |
                           difference_stationary | non_stationary

Verified: 9/9 core cargo tests, 39/39 SQL assertions, example runs clean,
7/7 statsmodels KPSS/stationarity cross-checks. Docs + example + cross-check
complete the Definition of Done for STAT-02/STAT-03.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ts_ljung_box, ts_durbin_watson, ts_jarque_bera, and combined
ts_residual_diagnostics (+ _by macros) across all five layers. Combined
report gates adequacy on Ljung-Box (p > alpha, default 0.05); Durbin-Watson
and Jarque-Bera are advisory. New FFI structs AnofoxLjungBoxResult,
AnofoxDurbinWatsonResult, AnofoxJarqueBeraResult, AnofoxResidualDiagnosticsResult.

Verified: 14/14 core cargo tests, 51/51 SQL assertions, residuals.sql runs
clean, 9/9 statsmodels cross-checks (Jarque-Bera exact parity, Durbin-Watson
within 1e-6). Completes Phase 1 (all of STAT-01..03, RESID-01..04).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add PanelForecastResult to types.rs (flat [n_series*n_horizon] buffer,
  model_name[64]) with Default impl mirroring ForecastResult
- Add anofox_ts_forecast_panel FFI export with catch_unwind, null-checks,
  NaN-as-gap imputation via fill_nulls_interpolate before fit
- Add anofox_free_panel_forecast_result (frees forecast buffer, nulls ptr)
- Add forecast_panel_impl inner helper (testable without FFI ptr marshalling)
- Add PanelForecastError wrapper (InvalidModel + Upstream) for clean ? usage
- Map period=0 to period=1 to avoid t%0 panic in ETS update loop
- 3 unit tests (happy path, NaN imputation, unknown-method error) all pass
- Add anofox-forecast as direct dep (was dev-dep only); update cbindgen.toml
  export list; header re-generated with all new symbols

Closes GLOB-01 FFI layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ecast_panel_by macro

- ts_forecast_panel_native.cpp: TABLE-in/TABLE-out function with Finalize barrier,
  ragged-panel alignment to shared date grid, drop rule (< 10 valid → DROPPED rows),
  and single anofox_ts_forecast_panel FFI call (fit-once-emit-many)
- ts_forecast_panel_native.hpp: forward declaration of RegisterTsForecastPanelNativeFunction
- ts_macros.cpp: ts_forecast_panel_by macro (+ anofox_fcst_ alias) using subselect
  pattern for TABLE arg (query_table direct-arg causes silent parse failure)
- anofox_forecast_extension.cpp: include + registration call after RegisterTsTableMacros
- CMakeLists.txt: add ts_forecast_panel_native.cpp to source list

Deviation [Rule 1 - Bug]: macro SQL used query_table(source::VARCHAR) directly as TABLE
arg; DuckDB silently rejects this pattern at macro parse time. Fixed to subselect:
(SELECT group_col, date_col, target_col::DOUBLE FROM query_table(source::VARCHAR)).
…d-to-end)

- examples/forecasting/global_panel_forecasting_examples.sql: 3-section SQL script
  covering ragged-panel GlobalETS (non-seasonal), seasonal GlobalETS (period=7),
  and the DROPPED: too_short drop rule for short series
- All sections verified against built extension: 14 rows per series (42 total),
  correct model_name output, DROPPED rows for < 10 valid observations
- Includes [02-2] marker for GlobalTheta + GlobalCroston sections to be appended
…GLOB-03)

- Add GlobalTheta::new() match arm in forecast_panel_impl (no period needed)
- Add GlobalCroston::new()/sba() match arm; variant_str="SBA" selects SBA bias correction
- Add variant_str: Option<&str> parameter to forecast_panel_impl for Croston variant dispatch
- Fix model_name in PanelForecastResult to reflect actual method string (not hardcoded GlobalETS)
- Pass variant_str from FFI outer wrapper to forecast_panel_impl
- Add Tests 4-6 (GlobalTheta happy path, Croston Classic, Croston SBA ≤ Classic)
- Import anofox_forecast::models::theta::GlobalTheta and ::intermittent::GlobalCroston
- Use GlobalCroston::new()/sba() instead of with_variant() (CrostonVariant not re-exported)

All 6 panel_ffi_tests pass (Tests 1-3 from 02-1 updated to new 8-arg signature).
…-02, GLOB-03)

- Replace [02-2] placeholder with four new sections (4-7)
- Section 4: GlobalTheta panel forecast (3 series, horizon=14) — verified end-to-end
- Section 5: GlobalCroston Classic + SBA on intermittent panel (3 series, horizon=6)
  - Flat-forecast check: all horizon steps equal per series (FLAT confirmed)
  - SBA <= Classic check: bias correction confirmed for all series
- Section 6: GlobalETS vs GlobalTheta method comparison on same panel

All three methods verified against built extension (build/release/duckdb -unsigned).
Exit code 0, all sections return rows, Croston output non-negative and flat.
… skill update

- docs/reference/models/exponential-smoothing/global_ets.md (new)
  GlobalETS: pooled ETS with auto spec selection, seasonal_period + model_pool params,
  DROPPED row behavior, point-forecasts-only note, cross-links to sibling global models
- docs/reference/models/theta/global_theta.md (new)
  GlobalTheta: pooled Theta, no seasonal params, trend+level per-series states, comparison example
- docs/reference/models/intermittent/global_croston.md (new)
  GlobalCroston: Classic + SBA variants, flat-forecast property, non-negativity guarantee,
  all-zero protection note, Classic vs SBA comparison example
- docs/api/07-forecasting.md: add "Panel / Global Forecasting (ts_forecast_panel_by)" section
  covering fit-once-emit-many concept, full signature, all 3 methods, ragged alignment +
  drop rule, point-forecasts-only note, 4 verified SQL examples, links to reference pages
- .claude/skills/anofox-forecast-models/SKILL.md: add ts_forecast_panel_by surface
  with panel methods table, 6 gotchas, quick examples for all 3 methods

All SQL snippets copied from end-to-end-verified examples/forecasting/global_panel_forecasting_examples.sql
(PR #230 rule — no invented signatures).
- benchmark/configs/global_ets.py: BENCHMARK_NAME='global_ets', MODELS list
  with GlobalETS (seasonal_period), GlobalTheta, GlobalCroston; FUNCTION_NAME
  set to TS_FORECAST_PANEL_BY to trigger panel path
- benchmark/configs/statsforecast_global.py: reference models AutoETS (for
  GlobalETS), AutoTheta (for GlobalTheta), CrostonOptimized (for GlobalCroston);
  INCLUDE_PREDICTION_INTERVALS=False (CrostonOptimized does not support them)
- benchmark/src/common/anofox_runner.py: additive function_name='TS_FORECAST_BY'
  parameter; when TS_FORECAST_PANEL_BY, emits panel query shape with
  ts_forecast_panel_by — all existing benchmarks unaffected (default preserved)
- benchmark/src/common/benchmark_runner.py: reads FUNCTION_NAME from anofox
  config module (getattr default 'TS_FORECAST_BY') and passes to runner
- benchmark/m4/global_benchmark/run.py: fire entry point; header documents
  venv run command (cd benchmark && uv run python ...)
- benchmark/m4/global_benchmark/results/.gitkeep: track results dir

Verify: configs_ok global_ets ['GlobalETS', 'GlobalTheta', 'GlobalCroston']
…ly, 500 series)

Parity results (M4 Daily, 500-series subset, horizon=14, seasonality=7):
  GlobalETS    MASE=0.963 vs AutoETS    MASE=0.947  gap=+1.8% (within 5%)
  GlobalTheta  MASE=0.956 vs AutoTheta  MASE=0.963  gap=-0.7% (anofox better)
  GlobalCroston MASE=0.963 vs CrostonOptimized MASE=1.035 gap=-6.9% (anofox better)

All three global models meet the behavioral/approximate parity criterion (D-Area4).
GlobalTheta and GlobalCroston outperform their statsforecast per-series analogs,
expected: cross-series pooling benefits Theta/Croston on this dataset.

Infrastructure improvements in anofox_runner.py:
- CLI subprocess path for TS_FORECAST_PANEL_BY (avoids venv v1.5.1 / extension
  v1.5.4 version mismatch); uses build/release/duckdb -unsigned flag
- Per-series date re-alignment: panel function aligns all series to a shared
  date grid; we restore correct per-series horizon dates using forecast_step
  (last_train_date[series] + step_days) so evaluation date-joins work
- Fixed extension_path fallback: try build/release/extension/... first
- _find_duckdb_cli helper resolves CLI binary from extension path or repo root

benchmark_runner.py: MAX_SERIES cap applied to both anofox and statsforecast
sides for fair comparison; getattr default 0 = no cap (backward compat)

global_ets.py: MAX_SERIES=500 (GlobalETS Reduced pool x 500 series = ~18s;
full 4,227 series would be ~6 min per run — documented in run.py header)

Committed results (all under benchmark/m4/global_benchmark/results/):
  anofox-global_ets-Daily.parquet          (7000 rows, 3 model columns)
  anofox-global_ets-Daily-metrics.parquet  (timing per model)
  statsforecast-statsforecast-global-Daily.parquet (7000 rows)
  statsforecast-statsforecast-global-Daily-metrics.parquet
  global_ets-evaluation-Daily.parquet      (MASE/MAE/RMSE per model)

Closes success criterion 3: GlobalETS/GlobalTheta/GlobalCroston parity verified.
sipemu and others added 28 commits August 22, 2026 00:38
…ultivariate sections

- docs/reference/models/classical/garch.md (new dir): GARCH(p,q); garch_p/garch_q params;
  explicit statement that forecast_value is VOLATILITY (std-dev = sqrt(variance)), NOT variance;
  min-obs p+q+10; designed for returns not raw prices; benchmark results (ratio=0.897, PASS)
- docs/reference/models/state-space/kalman.md: local_level (default) + local_linear_trend;
  kalman_model param; fixed vs MLE variance note; benchmark results (ratios 1.000/0.992, PASS)
- docs/reference/models/multivariate/var.md (new dir): ts_forecast_var_by; value_cols VARCHAR[];
  p named param; LONG output format; single-panel v1; pitfalls table; benchmark (ratio=1.000, PASS)
- docs/api/07-forecasting.md: Classical Models section (GARCH + Kalman) + Multivariate section (VAR)
  inserted after Panel section; model count updated 33 -> 36
- all SQL snippets verified end-to-end against built extension (PR #230 rule)
…VAR surface

- model count: 33 -> 36 in description and catalogue header
- GARCH: Classical volatility section with volatility-not-variance warning, min-obs note, returns-only pitfall, params garch_p/garch_q, benchmark ratio=0.897
- Kalman: Added to state-space section; local_level + local_linear_trend specs; kalman_model param; fixed variance note; benchmark ratios 1.000/0.992
- ts_forecast_var_by: new dedicated multivariate section with full signature, value_cols VARCHAR[], p named param, long-format output, pitfalls table, benchmark ratio=1.000
…ervals

GARCH point forecasts are conditional standard deviations; wrapping them
with ±z×σ_historical synthesises meaningless bounds. Kalman v1 likewise
has no prediction intervals. Both models already return empty lower/upper
from their forecast functions, but the dispatch in forecast() always
called calculate_confidence_intervals, discarding the model result.

Gate the CI call on model type: GARCH and Kalman now emit (vec![], vec![])
matching the documented v1 behaviour. All other models are unaffected.
Rust's dealloc() requires the exact Layout used by alloc(). The previous
WASM free() stubs passed a hardcoded Layout (size=8 or size=1) regardless
of the actual allocation size — undefined behaviour for all FFI buffers
including the new VARForecastResult array (k_vars * horizon * 8 bytes).

Fix: both lib.rs and allocation.rs WASM malloc stubs now prepend a usize
header storing the data size, returning a pointer to the data portion
(base + sizeof(usize)). The WASM free stubs recover the base pointer,
read the stored size, reconstruct the exact Layout, and call dealloc
correctly. The C ABI is unchanged — callers still pass the data pointer.
The header format is identical in both files so cross-file alloc/free
pairings remain correct.
list_models() was not updated in Phase 3. ts_list_models() SQL callers
querying available models would not see GARCH or Kalman even though both
are fully functional. Add both to the Classical Models section and update
the doc comment count from 32 to 34.
…proxy

calculate_fitted_values had a _ catch-all that computed α=0.3 SES-smoothed
values for any model not explicitly handled, including GARCH and Kalman.
Residuals derived from these SES fits have no relationship to actual GARCH
conditional variance residuals or Kalman filter innovations.

Fix: add explicit GARCH|Kalman arm returning vec![] in calculate_fitted_values.
In the caller (forecast()), treat an empty fitted vec as "unavailable" and
emit None for both fitted and residuals rather than continuing with the empty
vec (which would have produced an empty but non-null result). Users now get
SQL NULL for fitted/residuals on GARCH and Kalman, matching v1 intent.
The C++ under-determination guard counted pre-imputation NaN rows, which
can disagree with the post-fill_nulls_interpolate effective observation
count that Rust actually uses for fitting. This makes the guard either
too conservative (rejecting valid inputs after interpolation fills gaps)
or insufficient (passing inputs that Rust truncates due to leading NaN).

Document the divergence clearly and note that the Rust-side InsufficientData
error is the authoritative guard. Update the error message to say
"pre-imputation count" so users understand the advisory nature of the
early check. The Rust path at line ~507 remains the reliable backstop.
Consequence of the CR-01 Rust fix: GARCH/Kalman now return empty lower/upper
vecs from forecast(), which the FFI serialises as null pointers in ForecastResult.
Two callsites dereferenced these null pointers unconditionally, causing segfaults:

  - ts_forecast_scalar.cpp:531-532 (ts_forecast_by macro path)
  - ts_forecast_native.cpp:748-749 (native table function path)

Both now check for null before indexing and substitute a NaN sentinel or
Value(LogicalType::DOUBLE) (SQL NULL), so callers receive NULL for yhat_lower
and yhat_upper when the model has no prediction intervals. Verified with
examples/forecasting/classical_forecasting_examples.sql — GARCH and Kalman
produce forecasts without crashing; intervals are NULL as documented.
Apply the same ModelType::GARCH | ModelType::Kalman guard that was
added to forecast() in iteration 1 to the sibling forecast_with_exog()
function. Without this gate those models could reach the else-branch
(forecast_with_model) and return spurious historical-volatility-based
confidence intervals on the exog path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The doc comment still read "34 models" after GARCH and Kalman were
added to the body in iteration 1. Update to 35 to match the actual
return-vec entry count (verified by count).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stream)

Corrects the milestone version after discovering origin already ships a
v0.6.0 release tag. Renames archive files/dir and all planning references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI runs cargo fmt --all --check and clippy --all-features -- -D warnings.
- Apply rustfmt to Phase 1-3 code that was never formatted.
- Suppress newer clippy lints (toolchain drift): manual_div_ceil,
  manual_is_multiple_of (keep MSRV 1.86 — is_multiple_of is 1.87+),
  too_many_arguments (idiomatic FFI exports), doc_overindented_list_items.
No behavior change; 295 tests pass, clippy/fmt clean locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v1.4-andium defaults vcpkg to ce613c41, whose OpenSSL port fetches
msys2-runtime-3.5.4-2 — now 404 on all msys2 mirrors (rolling-release
pruning), failing the windows_amd64 build + deploy. Pin vcpkg_commit to
84bab45d (the commit the green v1.5.5 lane already builds Windows with).
Build-config only; no extension code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sipemu
sipemu merged commit 8519d75 into main Aug 23, 2026
83 of 87 checks passed
@sipemu
sipemu deleted the feat/v0.7.0-diagnostics-model-coverage branch August 23, 2026 12:23
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.

1 participant