feat: v0.7.0 — statistical diagnostics + global/panel + classical/multivariate models - #254
Merged
Merged
Conversation
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>
… (STAT-01..03, RESID-01..04)
- 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
…ADMAP, REQUIREMENTS
…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.
…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>
This reverts commit 45c4ccf.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0.7.0 — Close the Crate→Extension Gap (Diagnostics + Model Coverage)
Exposes previously SQL-unreachable
anofox-forecastcrate 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), combinedts_stationarity(_by)(four-way verdict)ts_ljung_box_by,ts_durbin_watson_by,ts_jarque_bera_by, combinedts_residual_diagnostics_byPhase 2 — Global / Panel Models (GLOB-01..03)
ts_forecast_panel_by— GlobalETS / GlobalTheta / GlobalCroston (fit-once-emit-many native table function, ragged-panel alignment)Phase 3 — Classical & Multivariate Models (CLAS-01..03)
ts_forecast_bymethods'GARCH'(conditional volatility = √variance) and'Kalman'(state-space; local-level / local-linear-trend)ts_forecast_var_by(VAR,value_colsLIST → long-format per-variable forecasts)Notes
ForecastOptionsFFI ABI extended additively (garch_p/garch_q/kalman_model) — backward-compatible; cbindgen header regenerated.CMakeLists.txt:diagnostics.cpp,ts_forecast_panel_native.cpp,ts_forecast_var_native.cpp.archadded tobenchmark/.venv(comparison group) for GARCH parity.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); fullmake releasebuilds clean and allexamples/*.sqlrun green locally.docs/api/+docs/reference/models/;anofox-forecast-modelsskill 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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.