omen-agentic-forecasting 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omen_agentic_forecasting-0.0.1/.claude/scheduled_tasks.lock +1 -0
- omen_agentic_forecasting-0.0.1/.claude/settings.local.json +21 -0
- omen_agentic_forecasting-0.0.1/.github/workflows/ci.yml +33 -0
- omen_agentic_forecasting-0.0.1/.gitignore +40 -0
- omen_agentic_forecasting-0.0.1/AGENTS.md +106 -0
- omen_agentic_forecasting-0.0.1/LICENSE +21 -0
- omen_agentic_forecasting-0.0.1/PKG-INFO +781 -0
- omen_agentic_forecasting-0.0.1/README.md +730 -0
- omen_agentic_forecasting-0.0.1/blog-posts/introducing-omen.md +74 -0
- omen_agentic_forecasting-0.0.1/blog-posts/ts-analyst-gets-a-statistics-degree.md +57 -0
- omen_agentic_forecasting-0.0.1/blog-posts/ts-deploy-ships-it.md +68 -0
- omen_agentic_forecasting-0.0.1/blog-posts/ts-forecaster-shows-its-work.md +57 -0
- omen_agentic_forecasting-0.0.1/blog-posts/ts-monitor-learns-to-doubt-good-news.md +60 -0
- omen_agentic_forecasting-0.0.1/blog-posts/ts-retrain-checks-its-papers.md +63 -0
- omen_agentic_forecasting-0.0.1/book/about_the_author.md +3 -0
- omen_agentic_forecasting-0.0.1/book/ai_use_statement.md +3 -0
- omen_agentic_forecasting-0.0.1/book/appendix-a-glossary.md +91 -0
- omen_agentic_forecasting-0.0.1/book/appendix-b-tool-reference.md +74 -0
- omen_agentic_forecasting-0.0.1/book/appendix-c-further-reading.md +35 -0
- omen_agentic_forecasting-0.0.1/book/chapter-01-introducing-omen-and-agentic-ai.md +110 -0
- omen_agentic_forecasting-0.0.1/book/chapter-02-installing-omen.md +177 -0
- omen_agentic_forecasting-0.0.1/book/chapter-03-first-contact.md +123 -0
- omen_agentic_forecasting-0.0.1/book/chapter-04-is-this-thing-stable.md +108 -0
- omen_agentic_forecasting-0.0.1/book/chapter-05-the-rhythm-of-evil.md +169 -0
- omen_agentic_forecasting-0.0.1/book/chapter-06-echoes.md +90 -0
- omen_agentic_forecasting-0.0.1/book/chapter-07-spikes-and-regime-changes.md +130 -0
- omen_agentic_forecasting-0.0.1/book/chapter-08-the-floor-is-naive.md +132 -0
- omen_agentic_forecasting-0.0.1/book/chapter-09-smoothing-things-over.md +85 -0
- omen_agentic_forecasting-0.0.1/book/chapter-10-sarima-and-the-order-selection-ritual.md +84 -0
- omen_agentic_forecasting-0.0.1/book/chapter-11-teaching-trees-to-predict-the-future.md +82 -0
- omen_agentic_forecasting-0.0.1/book/chapter-12-statistically-better-or-just-different.md +90 -0
- omen_agentic_forecasting-0.0.1/book/chapter-13-trustworthy-across-time.md +65 -0
- omen_agentic_forecasting-0.0.1/book/chapter-14-deploying-for-real.md +121 -0
- omen_agentic_forecasting-0.0.1/book/chapter-15-strength-in-numbers.md +69 -0
- omen_agentic_forecasting-0.0.1/book/chapter-16-watching-the-watchtower.md +143 -0
- omen_agentic_forecasting-0.0.1/book/chapter-17-when-to-sound-the-alarm.md +195 -0
- omen_agentic_forecasting-0.0.1/book/chapter-18-should-you-even-redeploy.md +115 -0
- omen_agentic_forecasting-0.0.1/book/chapter-19-the-suspicious-foreman.md +142 -0
- omen_agentic_forecasting-0.0.1/book/chapter-20-prompting-omen-like-you-mean-it.md +45 -0
- omen_agentic_forecasting-0.0.1/book/chapter-21-when-to-trust-the-agent.md +47 -0
- omen_agentic_forecasting-0.0.1/book/chapter-22-conclusion.md +38 -0
- omen_agentic_forecasting-0.0.1/book/dedication.md +3 -0
- omen_agentic_forecasting-0.0.1/book/examples/README.md +49 -0
- omen_agentic_forecasting-0.0.1/book/examples/assemble_book.py +254 -0
- omen_agentic_forecasting-0.0.1/book/examples/generate_book_datasets.py +372 -0
- omen_agentic_forecasting-0.0.1/book/examples/generate_book_plots.py +363 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/ch08_naive_backtest.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/ch08_seasonal_naive_backtest.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/ch10_sarima_search.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/ch13_rolling_origin.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/deathray_revenue_rival_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/deathray_revenue_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/deathray_revenue_slow_month_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_acf_pacf.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_decomposition.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_periodogram.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_steep_periodogram.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/drycleaning_bills_steep_trend_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/grumbling_level_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_attention_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_attention_shifted_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_attention_train_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_drift_plain.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_drift_shifted.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/interpol_rolling_drift.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mad_degenerate_edge_case_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/minion_overtime_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_forecast_vs_actuals.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_inventory_clean_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_inventory_constant_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_inventory_month2_actuals_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_inventory_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/mojito_sarima_forecast.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/power_consumption_anomalies.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/power_consumption_changepoints.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/power_consumption_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/examples/images/self_destruct_timer_series.png +0 -0
- omen_agentic_forecasting-0.0.1/book/outline.md +1063 -0
- omen_agentic_forecasting-0.0.1/book/title-page-image.png +0 -0
- omen_agentic_forecasting-0.0.1/graphify-out/.graphify_labels.json +1 -0
- omen_agentic_forecasting-0.0.1/graphify-out/GRAPH_REPORT.md +363 -0
- omen_agentic_forecasting-0.0.1/graphify-out/graph.html +307 -0
- omen_agentic_forecasting-0.0.1/graphify-out/graph.json +24423 -0
- omen_agentic_forecasting-0.0.1/graphify-out/manifest.json +497 -0
- omen_agentic_forecasting-0.0.1/openclaw.config.snippet.jsonc +91 -0
- omen_agentic_forecasting-0.0.1/prompts/testing-and-learning-prompts.md +123 -0
- omen_agentic_forecasting-0.0.1/pyproject.toml +94 -0
- omen_agentic_forecasting-0.0.1/src/omen/__init__.py +31 -0
- omen_agentic_forecasting-0.0.1/src/omen/analyst/__init__.py +0 -0
- omen_agentic_forecasting-0.0.1/src/omen/analyst/analysis_tools.py +807 -0
- omen_agentic_forecasting-0.0.1/src/omen/analyst/plot_tools.py +223 -0
- omen_agentic_forecasting-0.0.1/src/omen/analyst/server.py +498 -0
- omen_agentic_forecasting-0.0.1/src/omen/data_prep.py +89 -0
- omen_agentic_forecasting-0.0.1/src/omen/deploy/__init__.py +0 -0
- omen_agentic_forecasting-0.0.1/src/omen/deploy/forecast_tools.py +767 -0
- omen_agentic_forecasting-0.0.1/src/omen/deploy/plot_tools.py +73 -0
- omen_agentic_forecasting-0.0.1/src/omen/deploy/server.py +335 -0
- omen_agentic_forecasting-0.0.1/src/omen/forecaster/__init__.py +0 -0
- omen_agentic_forecasting-0.0.1/src/omen/forecaster/model_tools.py +916 -0
- omen_agentic_forecasting-0.0.1/src/omen/forecaster/plot_tools.py +146 -0
- omen_agentic_forecasting-0.0.1/src/omen/forecaster/server.py +529 -0
- omen_agentic_forecasting-0.0.1/src/omen/monitor/__init__.py +0 -0
- omen_agentic_forecasting-0.0.1/src/omen/monitor/monitor_tools.py +631 -0
- omen_agentic_forecasting-0.0.1/src/omen/monitor/plot_tools.py +149 -0
- omen_agentic_forecasting-0.0.1/src/omen/monitor/server.py +324 -0
- omen_agentic_forecasting-0.0.1/src/omen/plotting.py +66 -0
- omen_agentic_forecasting-0.0.1/src/omen/retrain/__init__.py +0 -0
- omen_agentic_forecasting-0.0.1/src/omen/retrain/retrain_tools.py +478 -0
- omen_agentic_forecasting-0.0.1/src/omen/retrain/server.py +295 -0
- omen_agentic_forecasting-0.0.1/src/omen/skills/ts-analyst/SKILL.md +214 -0
- omen_agentic_forecasting-0.0.1/src/omen/skills/ts-deploy/SKILL.md +173 -0
- omen_agentic_forecasting-0.0.1/src/omen/skills/ts-forecaster/SKILL.md +203 -0
- omen_agentic_forecasting-0.0.1/src/omen/skills/ts-monitor/SKILL.md +174 -0
- omen_agentic_forecasting-0.0.1/src/omen/skills/ts-retrain/SKILL.md +216 -0
- omen_agentic_forecasting-0.0.1/tests/test_analyst_plot_tools.py +99 -0
- omen_agentic_forecasting-0.0.1/tests/test_analyst_tools.py +375 -0
- omen_agentic_forecasting-0.0.1/tests/test_data_prep.py +26 -0
- omen_agentic_forecasting-0.0.1/tests/test_deploy_plot_tools.py +55 -0
- omen_agentic_forecasting-0.0.1/tests/test_deploy_tools.py +241 -0
- omen_agentic_forecasting-0.0.1/tests/test_forecaster_plot_tools.py +96 -0
- omen_agentic_forecasting-0.0.1/tests/test_forecaster_tools.py +294 -0
- omen_agentic_forecasting-0.0.1/tests/test_monitor_plot_tools.py +68 -0
- omen_agentic_forecasting-0.0.1/tests/test_monitor_tools.py +227 -0
- omen_agentic_forecasting-0.0.1/tests/test_retrain_tools.py +408 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"c82c4159-4861-4059-81f7-a574f0928db1","pid":3232001,"procStart":"144302250","acquiredAt":1784755779759}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(git add *)",
|
|
5
|
+
"Bash(git commit -m ' *)",
|
|
6
|
+
"Bash(git push *)",
|
|
7
|
+
"Bash(git checkout *)",
|
|
8
|
+
"Bash(gh pr *)",
|
|
9
|
+
"Bash(gh repo *)",
|
|
10
|
+
"Bash(git pull *)",
|
|
11
|
+
"Bash(python3 assemble_book.py --skip-pdf --out /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/book-dist2)",
|
|
12
|
+
"Bash(git branch *)",
|
|
13
|
+
"Bash(python3 assemble_book.py --skip-pdf --out /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/test-pdf)",
|
|
14
|
+
"Bash(cd *)",
|
|
15
|
+
"Bash(pdfinfo /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/verify-full/omen-book.pdf)",
|
|
16
|
+
"Bash(pdftotext -layout /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/verify-full/omen-book.pdf -)",
|
|
17
|
+
"Bash(pdftoppm -png -f 30 -l 30 -r 100 /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/verify-full/omen-book.pdf /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/verify-full/pg)",
|
|
18
|
+
"Bash(/tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/venv/bin/python3 assemble_book.py --out /tmp/claude-1000/-home-emily-Desktop-projects-omen-agentic-time-series-forecasting/c82c4159-4861-4059-81f7-a574f0928db1/scratchpad/final-regen)"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
lint-and-typecheck:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
cache: pip
|
|
17
|
+
- run: pip install -e ".[all,dev]"
|
|
18
|
+
- run: ruff check .
|
|
19
|
+
- run: mypy
|
|
20
|
+
|
|
21
|
+
test:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
strategy:
|
|
24
|
+
matrix:
|
|
25
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
cache: pip
|
|
32
|
+
- run: pip install -e ".[all,dev]"
|
|
33
|
+
- run: pytest
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
wheels/
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Testing
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
htmlcov/
|
|
22
|
+
|
|
23
|
+
# Editors / OS
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
.DS_Store
|
|
27
|
+
|
|
28
|
+
# Local scratch data generated while testing the tools
|
|
29
|
+
*.csv
|
|
30
|
+
!tests/**/*.csv
|
|
31
|
+
deployment_manifest*.json
|
|
32
|
+
|
|
33
|
+
# graphify: machine-specific bookkeeping and re-buildable cache, not
|
|
34
|
+
# durable artifacts -- graph.json/GRAPH_REPORT.md/graph.html/manifest.json
|
|
35
|
+
# and .graphify_labels.json ARE committed (see AGENTS.md or ask before
|
|
36
|
+
# re-running with --update).
|
|
37
|
+
graphify-out/.graphify_python
|
|
38
|
+
graphify-out/.graphify_root
|
|
39
|
+
graphify-out/cost.json
|
|
40
|
+
graphify-out/cache/
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Omen
|
|
2
|
+
|
|
3
|
+
Five layers of agentic time series tooling. Each layer is a FastMCP server
|
|
4
|
+
(typed tools) plus a companion OpenClaw skill (markdown playbook telling an
|
|
5
|
+
agent how to sequence those tools and what to report). Built to be driven
|
|
6
|
+
by OpenClaw + an LLM agent (developed against GLM-5.2 on Ollama Cloud, but
|
|
7
|
+
nothing is model-specific).
|
|
8
|
+
|
|
9
|
+
- `analyst` -> `ts-analyst` : explore a series, recommend an approach. No fitting.
|
|
10
|
+
- `forecaster` -> `ts-forecaster` : fit + backtest candidate models on a held-out window.
|
|
11
|
+
- `deploy` -> `ts-deploy` : retrain the chosen model on full data, forecast forward.
|
|
12
|
+
- `monitor` -> `ts-monitor` : compare deployed forecast vs. reality, detect drift, recommend retraining.
|
|
13
|
+
- `retrain` -> `ts-retrain` : on retrain_now, re-run analyst+forecaster and decide deterministically whether the new candidate beats what's deployed; redeploys only through the gated `execute_redeploy(confirmed=True)` tool, either human-approved (default) or via an explicitly authorized autonomous mode (`confirmed=True, autonomous=True`, code-checked against a standing `authorize_autonomous_mode` record, not just a prose contract).
|
|
14
|
+
|
|
15
|
+
Full architecture/setup is in README.md — don't duplicate it here.
|
|
16
|
+
|
|
17
|
+
**Project naming**: the project/package/repo directory is `omen` (formerly
|
|
18
|
+
`agentic-ts-toolkit` — renamed, not rebranded from scratch). The five
|
|
19
|
+
layers deliberately KEPT their existing `ts-analyst`/`ts-forecaster`/
|
|
20
|
+
`ts-deploy`/`ts-monitor`/`ts-retrain` server names, skill names, and
|
|
21
|
+
MCP-qualified tool names (e.g. `ts-analyst__basic_stats`) unchanged —
|
|
22
|
+
that's an already-established, separately-branded layer referenced
|
|
23
|
+
everywhere (every `SKILL.md`, every test, every docstring, every blog
|
|
24
|
+
post), and renaming it alongside the project name would have been a much
|
|
25
|
+
larger, more disruptive change than what was asked for. If you're adding
|
|
26
|
+
new tools, keep using the `ts-*` naming convention for them; don't start
|
|
27
|
+
mixing in `omen-*`-prefixed names for consistency with the project name,
|
|
28
|
+
since that would just create two competing conventions. **The PyPI
|
|
29
|
+
distribution name is `omen-agentic-forecasting`, not `omen`** — plain
|
|
30
|
+
`omen` was already taken on PyPI by someone else. This is a PACKAGING-
|
|
31
|
+
LEVEL name only (`pyproject.toml`'s `[project].name`, i.e. what
|
|
32
|
+
`pip install` takes); it does NOT touch the importable module, which
|
|
33
|
+
stays plain `omen` (`import omen`, `src/omen/`), nor any of the
|
|
34
|
+
`ts-*` server/skill/tool names above. Don't let the PyPI listing name
|
|
35
|
+
leak into code, imports, or the `ts-*` convention.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[all,dev]" # everything, including test deps
|
|
41
|
+
pytest # run the suite
|
|
42
|
+
ts-analyst-server # run a layer's MCP server standalone (Ctrl+C to stop)
|
|
43
|
+
python -m build # build sdist/wheel for publishing
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Per-layer installs: `.[analyst]`, `.[forecaster]`, `.[deploy]`, `.[monitor]`, `.[retrain]` if you don't want every dependency.
|
|
47
|
+
|
|
48
|
+
## Non-obvious conventions
|
|
49
|
+
|
|
50
|
+
- **One shared `data_prep.py`** at the package root (`omen.data_prep`). Every subpackage imports it — never re-add a per-layer copy.
|
|
51
|
+
- **Within a subpackage, tools return plain functions taking a DataFrame; `server.py` wraps them as `@mcp.tool()` and does the CSV loading.** Keep that split — it's what makes the tool logic unit-testable without spinning up MCP (see `tests/`, which import the tools modules directly, not through `server.py`).
|
|
52
|
+
- **`recommend_retraining` in `monitor_tools.py` is deliberately deterministic/rule-based**, not an LLM judgment call — that's intentional, not a missed opportunity to "let the agent decide." Keep new decision-style tools (accept/reject, threshold-based verdicts) rule-based for the same reason: reproducibility given the same inputs. `retrain_tools.py`'s `compare_candidate_to_deployed` follows the same rule.
|
|
53
|
+
- **Every `SKILL.md` references its tools by their MCP-qualified name** (`ts-analyst__basic_stats`, etc.), not by file path — these are OpenClaw skills, not exec-based scripts. If you add a tool, update the matching `SKILL.md`'s tool list too.
|
|
54
|
+
- **Tests use `pytest.importorskip("statsmodels")` / `"sklearn"`** in the layers that need them (`analyst`, `forecaster`, `deploy`), so `pip install -e .` (core deps only) doesn't break the whole suite. `monitor` and `data_prep` tests have no such guard — keep it that way, they're meant to run everywhere. `retrain` is mostly in that camp too (`record_deployment`/`load_deployment_manifest`/`compare_candidate_to_deployed`/`authorize_autonomous_mode`/`revoke_autonomous_mode`/`check_autonomous_mode` all need nothing beyond core — plain `json`/`os`/`datetime`), but every `test_retrain_tools.py` test that actually calls `execute_redeploy` with `confirmed=True` (its real dispatch path retrains a model) uses a per-test (not module-level) `importorskip`, since `execute_redeploy` alone needs the `deploy` extra — don't move that guard to the top of the file, it would skip tests that don't need it.
|
|
55
|
+
- **`ts-retrain` is the one place in the toolkit with durable state — now TWO separate files, not one.** `record_deployment`/`load_deployment_manifest` read and write a JSON manifest (`deployment_manifest.json` by default, next to the series CSV; see `_manifest_path_for` in `retrain_tools.py`). `authorize_autonomous_mode`/`revoke_autonomous_mode`/`check_autonomous_mode` read and write a SEPARATE JSON file (`autonomous_mode.json` by default, see `_autonomous_mode_path_for`) recording whether autonomous retraining is authorized for that series. These were deliberately kept as two files, not one field added to the manifest — authorization and deployment are different concerns with different lifecycles (an authorization can outlive many deployments; revoking it shouldn't touch what's currently deployed, and redeploying shouldn't touch the authorization). Every other tool in every other layer is a pure function of its explicit inputs — don't add more file-based state elsewhere without a similarly strong reason, and don't merge these two files into one without re-examining why they were split.
|
|
56
|
+
- **`ts-retrain` only ever changes a deployment through `execute_redeploy`, which refuses to run without `confirmed=True`** (no default performs the action). Two paths reach that confirmation, both documented in `SKILL.md`: human approval in-conversation (default, `confirmed=True` alone), or an explicitly authorized autonomous mode (`confirmed=True, autonomous=True`). **As of this addition, the autonomous-mode authorization check is a REAL CODE-LEVEL check, not just a prose contract** — when `autonomous=True`, `execute_redeploy` calls `check_autonomous_mode(csv_path)` internally and refuses to act (even with `confirmed=True`) unless a standing record exists, persisted via `authorize_autonomous_mode` (a small JSON file, `autonomous_mode.json` by default, next to the series CSV — deliberately a SEPARATE file from the deployment manifest, since authorization and deployment are different concerns with different lifecycles: an authorization can outlive many deployments, or be revoked without touching what's currently deployed). This closes a gap the project's own introductory blog post named as unfinished business ("make autonomous-mode authorization inspectable, not just conversational"). `authorize_autonomous_mode` itself performs NO judgment about whether granting authorization is appropriate — it purely persists a decision already made by a human or a standing project instruction, same as `record_deployment` persists a deployment decision rather than making one; the skill's prose instructions (still) govern WHEN it's appropriate to call `authorize_autonomous_mode` in the first place. Don't weaken the skill's "default to human mode when ambiguous" language, and don't add a code path that sets `confirmed=True` by default anywhere, or that lets `autonomous=True` bypass `check_autonomous_mode`.
|
|
57
|
+
- Human-confirmed example: after `ts-retrain` reports `should_redeploy: true` and stops, the user says *"Go ahead and redeploy the candidate you just recommended"* — that follow-up is what authorizes the `execute_redeploy(..., confirmed=True)` call (no `autonomous=True`, no authorization record needed).
|
|
58
|
+
- Autonomous-mode example: authorization is scoped per series, stated explicitly, e.g. *"For the `daily_demand.csv` series specifically, you're authorized to redeploy automatically whenever ts-retrain finds a candidate that beats the current deployment — no need to ask me first. Everything else still needs my confirmation as usual."* That grant is what should get turned into a persisted record via `authorize_autonomous_mode(csv_path, authorized_by="user, in conversation on <date>")` — not just remembered for the rest of the session. Absent wording this explicit and this scoped (and, now, absent an actual persisted record), treat autonomous mode as not authorized; `execute_redeploy(autonomous=True)` will refuse on its own even if the skill's judgment is wrong about this.
|
|
59
|
+
- **`plot_tools.py` (one per layer, `analyst`/`forecaster`/`deploy`/`monitor` — `retrain` deliberately has none, nothing there is visual) hold the 13 `plot_*` MCP tools, each returning a real inline image via a SHARED `omen.plotting.render_plot`.** This is a genuine cross-layer import, NOT another instance of the `_aicc`/`compute_metrics_with_ci`-style deliberate per-layer duplication documented elsewhere in this file — `render_plot` is pure rendering plumbing (Figure -> PNG bytes -> FastMCP `Image` content block + optional `out_path` write + a small status dict), with no modeling logic to diverge across layers, so there's no forking risk duplication guards against. Every `plot_*` function follows the same rule: reuse the layer's own JSON-producing tool's computation (either by calling it directly, e.g. `plot_anomalies` calls `detect_anomalies_robust_zscore`, or via a shared private helper, e.g. `plot_acf_pacf` and `acf_pacf_summary` both calling `_acf_pacf_raw`) rather than recomputing a finding independently — the picture must never be able to silently disagree with the numbers. `forecaster`'s and `deploy`'s plot functions take already-computed arrays/lists (e.g. `plot_backtest(actuals, predicted, ...)`, `plot_forecast(df, forecast, ...)`) rather than a DataFrame + params, so plotting never triggers an unwanted refit of an expensive model. Same `tools`-module/`server.py`-wrapper split as every other tool in this project (`server.py` still does the CSV loading and `@mcp.tool()` wrapping) — matplotlib's `Agg` backend is forced in `plotting.py` (headless, no display available), and `render_plot` always closes the figure after rendering to avoid leaking memory across a long agentic session. `matplotlib` itself is a CORE dependency (`pyproject.toml`), not gated behind any per-layer extra — but a given layer's `plot_tools.py` can still transitively need that layer's extra when it imports a JSON tool that does (e.g. `analyst/plot_tools.py` needs the `analyst` extra via `analysis_tools.py`'s statsmodels/scipy imports, same as every other `analyst` tool). Every `SKILL.md` documents its layer's `plot_*` tools the same MCP-qualified-name way as every other tool (e.g. `ts-analyst__plot_series`) — if you add a new `plot_*` tool, update both `server.py` and the matching `SKILL.md`, same rule as any other new tool.
|
|
60
|
+
- **Both `SARIMAX(...)` call sites (`forecaster/model_tools.py::fit_sarima`, `deploy/forecast_tools.py::forecast_sarima`) pass a single-column DataFrame (`df[["value"]]`), not a Series (`df["value"]`).** This isn't stylistic -- statsmodels does an in-place `ndarray.shape=` reshape internally, but only for 1-D endog, and NumPy 2.5+ deprecated in-place shape mutation, so a Series triggers a `DeprecationWarning` on every fit. A single-column DataFrame is already 2-D, so statsmodels skips that code path -- verified identical AIC/BIC, residuals, and forecast values/shapes either way. If you add another `SARIMAX(...)` call site, pass a DataFrame the same way.
|
|
61
|
+
|
|
62
|
+
## Known modeling caveats (intentional, not bugs)
|
|
63
|
+
|
|
64
|
+
- `forecaster/model_tools.py`'s gradient-boosted-trees backtest is **one-step-ahead** (true lagged values); `deploy/forecast_tools.py`'s gradient-boosted-trees forecast is **recursive** (predictions feed back in as lags). These are not directly comparable to each other or to ETS/SARIMA's genuine multi-step forecasts — both modules' docstrings and return values flag this explicitly. Don't "fix" this asymmetry without understanding why it exists.
|
|
65
|
+
- `monitor/monitor_tools.py`'s `detect_data_drift` cannot distinguish an ongoing trend/seasonal transition from a genuine regime change — confirmed empirically that it flags `drift_detected=True` on trending synthetic data with zero injected anomaly. This is by design (a coarse, fast check); read `interpretation`, don't treat the boolean as ground truth.
|
|
66
|
+
- It now reports `mean_shift_cohens_d` (pooled-SD Cohen's d for the Welch's-t mean-shift test — pooled SD is the standard choice for the effect size even though the test itself doesn't assume equal variances) plus the raw `ttest_statistic`/`ks_statistic`, which is a REAL BUG FIX, not just an enrichment: both statistics were already being computed (`ttest_stat, ttest_pvalue = ttest_ind(...)`, `ks_stat, ks_pvalue = ks_2samp(...)`) and then silently discarded before the function returned — only the p-values made it into the result. Confirmed on the project's own synthetic data that the effect size is what actually distinguishes the two "drift" cases the trend-only-false-positive caveat above conflates at the boolean level: the unmodified trending series alone comes back `mean_shift_cohens_d≈1.58` (already "large" by the conventional textbook bins), while injecting an obvious +200 level shift on top of it pushes that to `≈9.67` (more than six times larger still) — both flag `drift_detected=True`, only the effect size's actual magnitude, read comparatively rather than against a fixed cutoff, tells you which is which. `ks_statistic` needed no separate effect-size derivation (unlike KPSS's coarse p-value elsewhere in this project) since the KS statistic itself already IS a natural 0-to-1 magnitude (max distance between the two windows' empirical CDFs).
|
|
67
|
+
- `monitor/monitor_tools.py` gained `_bootstrap_metric_ci`/`compute_metrics_with_ci`, duplicated verbatim (same field names: `mae_ci_lower/upper`, `rmse_ci_lower/upper`, `mape_pct_ci_lower/upper`, `ci_confidence_level`, `ci_n_bootstrap`) from `forecaster/model_tools.py`'s versions rather than imported cross-layer — same precedent as `deploy/forecast_tools.py`'s `_aicc`/`_build_lag_features` duplication. `compare_forecast_to_actuals` now calls this instead of the bare `compute_metrics`; this is additive, not a rename, matching the same pattern documented for `forecaster/model_tools.py`'s `fit_*` CI additions — every pre-existing field name (`mae`, `rmse`, `mape_pct`) is unchanged, only new keys were added. `compute_metrics` itself (the plain, CI-less version) is UNCHANGED and still exported/tested directly — don't remove it or fold its logic into `compute_metrics_with_ci` only, other code may still want the cheap point-estimate-only path.
|
|
68
|
+
- `monitor/monitor_tools.py`'s `recommend_retraining` gained optional `mape_now_ci_lower`/`mape_now_ci_upper` params (fed from `compare_forecast_to_actuals`'s new `mape_pct_ci_lower/upper`). When both are supplied, it computes `pct_degradation_ci_lower/upper` by applying the SAME linear transform used for the point estimate (`100 * (mape_now - mape_backtest) / mape_backtest`) to each CI bound — valid because that transform is monotonic increasing in `mape_now` for a fixed positive `mape_backtest`, so the bounds map through directly without needing their own resampling. `mape_backtest` itself is treated as a FIXED constant throughout — no CI is combined in for it, even if the caller has one, since Layer 2's own backtest CI isn't threaded through this far and combining an uncertain `mape_backtest` would require its own variance-combination logic (see `deploy/forecast_tools.py::forecast_ensemble`'s equivalent problem) that wasn't judged worth the complexity here. `degradation_threshold_within_ci` flags when `error_degradation_threshold_pct` falls inside `[pct_degradation_ci_lower, pct_degradation_ci_upper]` — when true, a sentence is appended to `reasoning` (not a separate field to remember to check) saying the degraded/not-degraded verdict is sensitive to sampling noise in `mape_now`. All three new fields are `None` (not `0` or `False`) when the CI bounds aren't supplied — same "missing means None, not a fabricated default" convention used everywhere else in this project (e.g. `_check_forecast_plausibility`'s insufficient-history fields).
|
|
69
|
+
- `monitor/monitor_tools.py` gained `_wilson_score_interval` (binomial Wilson score CI, `scipy.stats.norm.ppf`-based) applied to `compare_forecast_to_actuals`'s `interval_coverage.empirical_coverage_pct` — chosen over the normal approximation specifically because coverage here is often computed from a SMALL number of matched dates (early in a monitoring cycle) and/or a proportion near 0% or 100%, both regimes where the normal approximation is known to behave badly (can produce bounds outside [0,1]) and Wilson doesn't. **This is deliberately supplementary, not a replacement for `well_calibrated`'s existing threshold logic** — `well_calibrated` stays exactly `abs(coverage_pct - nominal_pct) <= 15`, unchanged, because that same 15-percentage-point threshold is INTENTIONALLY mirrored in `forecaster/model_tools.py::_interval_coverage` (see below/above) and changing the definition here without also changing there would break a deliberate cross-layer consistency this project has already documented. If you ever do want a CI-aware `well_calibrated` (e.g. "well-calibrated if nominal falls inside the Wilson CI"), that's a bigger design change spanning both `compare_forecast_to_actuals` and `_interval_coverage` together, not a one-line edit here.
|
|
70
|
+
- `monitor/monitor_tools.py` gained `_residual_outliers`, called from `compare_forecast_to_actuals` and returned as its new `residual_outliers` field. Reuses the exact modified-z-score formula (median + MAD, Iglewicz & Hoya 1993, `z_threshold` default 3.5) from `analyst/analysis_tools.py::detect_anomalies_robust_zscore`, but applied to a single FIXED set of residuals (`actual - forecast` across all matched points) rather than a rolling window — a rolling window doesn't make sense here since the elapsed comparison horizon is typically short (often under 30 points, sometimes under 10). **Known limitation, documented in the function's own docstring, not silently left as a surprise**: if half or more of the residuals are EXACTLY identical (most realistically all exactly 0, i.e. suspiciously perfect forecasts on most days), the MAD itself degenerates to 0, and the code's `if mad > 0` guard falls back to an all-zero modified-z-score array — flagging nothing, even if one remaining residual is enormous. This is the SAME self-dilution failure class that motivated building the robust detector for `ts-analyst` in the first place (see the ADF/anomaly detector caveats above), just triggered by a different, rarer precondition (near-identical residuals rather than a single spike inflating a rolling window's own std). Caught via `tests/test_monitor_tools.py`'s own test-writing process — an early draft of the test used EXACT forecast matches for the "good" days, which is exactly the degenerate condition, and the test failed until the "good" days were given small non-zero misses instead. `metrics_excluding_outliers` is `None` (not an empty/zero metrics dict) whenever fewer than 2 non-outlier points remain or nothing was flagged as non-outlier — a real "can't compute this" state, not a fabricated comparison.
|
|
71
|
+
- `monitor/monitor_tools.py`'s `rolling_drift_check` reuses `detect_data_drift` directly (calling it once per check on a truncated prefix of `df`, `df.iloc[:end]`) rather than reimplementing the t-test/KS-test logic — same reuse pattern as `forecaster/model_tools.py::rolling_origin_backtest` reusing `fit_ets`/`fit_sarima`/`fit_gradient_boosted_trees`. Unlike `rolling_origin_backtest`, this is genuinely CHEAP per check (a t-test and KS test over numpy arrays, no model fitting) — the docstring says so explicitly, and `n_checks` defaults higher-effort-tolerant than `rolling_origin_backtest`'s `n_origins` as a result. `step_size` defaults to `recent_window_size` (fully non-overlapping recent windows across checks, mirroring `rolling_origin_backtest`'s own non-overlapping-origins default) but is independently configurable, unlike `rolling_origin_backtest` which has no equivalent knob. `persistence_threshold_frac` (default 0.5) is an explicit parameter, not a hidden constant — same "thresholds are parameters" convention `recommend_retraining` already documents in its own docstring. Checks are reported chronologically (oldest-to-newest), reversed from the walk-backward computation order, same convention as `rolling_origin_backtest`'s `origins.reverse()`.
|
|
72
|
+
- `retrain/retrain_tools.py`'s `compare_candidate_to_deployed` requires a relative improvement above `improvement_threshold_pct` (default 10%) before recommending a swap, not just any improvement — this is deliberate, to avoid redeploying (and resetting the monitoring baseline) over noise-level metric differences between two similar fits.
|
|
73
|
+
- `retrain/retrain_tools.py`'s `execute_redeploy` imports `deploy/forecast_tools.py` as a whole module, so it needs the `deploy` extra (statsmodels + scikit-learn) installed regardless of `model_type` — even `model_type="naive"` triggers the same module-level import. The `retrain` extra itself stays dependency-free on purpose, since the diagnostic tools don't need it; only `execute_redeploy` does.
|
|
74
|
+
- `retrain/retrain_tools.py`'s `compare_candidate_to_deployed` now reads a bootstrap CI out of `candidate_metrics` if one is present (`{metric_name}_ci_lower`/`{metric_name}_ci_upper` — the fields `ts-forecaster`'s `compute_metrics_with_ci` adds to any recent `fit_*` result), reporting `pct_improvement_ci_lower`/`pct_improvement_ci_upper`, `deployed_metrics_ci_used`, and `redeploy_threshold_within_ci`. **The CI-to-improvement-range mapping is NOT a naive lower-bound-in-lower-bound-out substitution on either side.** `pct_improvement = 100 * (deployed_value - candidate_value) / deployed_value` is DECREASING in `candidate_value` (it's subtracted) and INCREASING in `deployed_value` (for positive values) — this is a genuine two-variable interval-arithmetic problem, not a one-sided one. **Unlike `monitor/monitor_tools.py::recommend_retraining`'s degradation CI (which deliberately treats `mape_backtest` as fixed and documents that as a simplification), this function DOES combine BOTH sides' uncertainty when both are available**: if `deployed_metrics` also has a CI for `metric_name`, the function computes the true worst-case (candidate at its CI's high end, deployed at its CI's low end) and best-case (the reverse) corners of the box `[candidate_ci_lower, candidate_ci_upper] x [deployed_ci_lower, deployed_ci_upper]` — valid because `pct_improvement` is monotonic in each variable independently, so its extrema over a box occur at opposite corners, not requiring any distributional/independence assumption the way `deploy/forecast_tools.py::forecast_ensemble`'s variance combination does. Verified directly: candidate MAPE 6.0 (CI `[5.0, 7.0]`) against deployed MAPE 10.0 ALONE gives `[30.0%, 50.0%]`; adding a deployed-side CI of `[9.0, 11.0]` widens this to `[22.22%, 54.55%]` — worst case `100*(9-7)/9`, best case `100*(11-5)/11`. When `deployed_metrics` has no CI for the metric, both of its "bounds" collapse to the point `deployed_value`, which reduces exactly to the candidate-only combination (`deployed_metrics_ci_used: False`) — don't special-case this fallback separately from the general formula, it falls out automatically. Each bound's own denominator is guarded independently against being exactly 0 (returns `None` for that bound only, not a crash). All CI-derived fields are `None` (not fabricated) when `candidate_metrics` has no CI keys for `metric_name` — most manifests/metrics dicts predating this addition, or any metric computed via the CI-less `compute_metrics`, won't have them, and that's a legitimate "can't compute this" state, not an error.
|
|
75
|
+
- `retrain/retrain_tools.py`'s `execute_redeploy` now reads the OUTGOING manifest via `load_deployment_manifest` immediately before `record_deployment` overwrites it, returning it as `previous_deployment` in the action's own result (`None` if nothing was deployed yet). This does NOT change the manifest file's own deliberate single-record design (still overwritten, still no history) — it's a one-time snapshot inside the action's return value only, so "what did this redeploy just replace" is answerable from the tool call itself instead of requiring conversation history to reconstruct. The read must happen BEFORE `record_deployment` is called, not after — reordering these two calls would make `previous_deployment` echo the NEW manifest back at itself instead of the old one.
|
|
76
|
+
- `analyst/analysis_tools.py`'s `check_stationarity` runs ADF AND KPSS together and reports an effect size for each. **Field names changed when KPSS was added**: the ADF fields were renamed from `p_value`/`is_likely_stationary` to `adf_p_value`/`adf_is_likely_stationary`, so two different tests' results in the same dict wouldn't be ambiguous. If you're referencing this tool's output anywhere (a `SKILL.md`, a test, an integration), use the prefixed names.
|
|
77
|
+
- ADF's effect size (`mean_reversion_lambda`, `mean_reversion_half_life_periods`) comes from an OLS fit of `delta_y_t = lambda*y_{t-1} + mu + epsilon_t` via `statsmodels.regression.linear_model.OLS` (not derived from `adfuller`'s own internals, so it stays interpretable on its own and isn't coupled to ADF's autolag choice; switched from raw `np.linalg.lstsq` specifically to get `.conf_int()` for free). It also reports a confidence interval for both lambda and half-life (`mean_reversion_lambda_ci_lower/upper`, `mean_reversion_half_life_ci_lower/upper`). **Don't assume `mean_reversion_lambda >= 0` under a true unit root** — the OLS estimate is well-known to skew slightly negative in finite samples even with zero true reversion (exactly why ADF needs its own non-standard critical values rather than a plain t-test); this bit a test during development (see `test_check_stationarity_reports_weak_reversion_for_a_pure_random_walk` in `tests/test_analyst_tools.py`, which asserts on `adf_is_likely_stationary`/half-life magnitude instead of the sign of lambda for that reason). **The half-life CI mapping is NOT symmetric/naive**: half-life is an INCREASING function of lambda on lambda < 0 (more negative lambda = faster reversion = shorter half-life), so `half_life_ci_lower` comes from `lambda_ci_lower` and `half_life_ci_upper` comes from `lambda_ci_upper`, in that same order (not swapped). `half_life_ci_upper` is `None` whenever `lambda_ci_upper >= 0` -- lambda's CI reaching non-negative territory means the data can't rule out arbitrarily slow reversion, so the upper bound is genuinely unbounded, not just "a large number that got skipped." Don't "fix" a `None` here by substituting a large finite placeholder.
|
|
78
|
+
- KPSS's effect size (`kpss_effect_size`) is the KPSS statistic as a multiple of its own 5% critical value, computed from values `kpss()` already returns (no re-implementation of its Newey-West long-run variance estimator). This exists specifically because KPSS's own p-value is coarse: `statsmodels` interpolates it from just four lookup-table points and clips it at the table's edges, so p-values of 0.01 or 0.10 can mean "barely past the boundary" or "wildly past it" with no way to tell from the p-value alone. `_kpss_effect_size` deliberately suppresses the `InterpolationWarning` `kpss()` raises at those clipped boundaries — that's not silencing a problem, the effect size *is* the more informative answer to the exact thing the warning is pointing at.
|
|
79
|
+
- ADF and KPSS have opposite null hypotheses (ADF: unit root; KPSS: stationary) — `_combined_stationarity_interpretation` encodes the standard four-way readout (both agree stationary, both agree non-stationary, or either disagreement direction, each with its own guidance e.g. "try `kpss_regression='ct'`"). Keep new stationarity-adjacent tools consistent with this framing rather than reporting ADF and KPSS as two independent, unrelated facts.
|
|
80
|
+
- `analyst/analysis_tools.py`'s `basic_stats` reports a confidence interval for the mean (`mean_ci_lower`/`mean_ci_upper`, Student's t via `scipy.stats.t`, not the normal approximation, since sample sizes here can be small) — both `None` for a constant series (zero variance). Uses `values.count()` (non-null count) for the sample size in the standard-error calculation, NOT `len(df)` -- those differ whenever there are missing values, and using `len(df)` would understate the standard error (and thus the CI width) by inflating the effective sample size.
|
|
81
|
+
- `analyst/analysis_tools.py`'s `acf_pacf_summary` and `detect_anomalies_zscore` both report an effect size for anything they flag, sorted strongest/most-extreme first (not chronologically) before capping the list for brevity. **This changed both fields' shape, not just added new ones**: `significant_acf_lags` went from a bare list of lag integers to a list of `{lag, acf, ci_lower, ci_upper, effect_size}` dicts; `detect_anomalies_zscore` replaced `anomaly_dates` (date strings only) with `anomalies` (`{date, value, z_score}` dicts) plus a new `max_abs_z_score` summary field. Same rationale as the ADF/KPSS effect sizes above: a bare "flagged" boolean throws away magnitude the function already computed internally to make that decision — don't add a new flagging tool that collapses a computed score down to a boolean without also surfacing the score.
|
|
82
|
+
- `analyst/analysis_tools.py`'s `acf_pacf_summary` significance test is a REAL correctness fix, not just an added field: it now uses `statsmodels.tsa.stattools.acf(..., alpha=...)`'s per-lag Bartlett-formula confidence intervals instead of a single global `1.96/sqrt(n)` threshold. The Bartlett standard error grows with lag (`varacf[2:] *= 1 + 2*cumsum(acf[1:-1]**2)` inside `statsmodels`) because it's derived as if the series were an MA(k-1) at lag k -- a uniform threshold is only actually correct at lag 1 and understates the true threshold everywhere past it. This has a real, demonstrated effect: on the project's synthetic series, the old uniform threshold ranked lag 7 as the single strongest significant lag; the corrected per-lag threshold ranks lag 1 first instead (its Bartlett SE is the tightest of any lag, since no prior lags inflate it). `significance_threshold` (a single float) was renamed `significance_alpha` (the alpha level fed to `acf()`, e.g. 0.05) since there's no longer one global threshold to report. `acf(..., alpha=...)` returns confidence intervals CENTERED ON THE ESTIMATED ACF VALUE, not on zero (`statsmodels`' own docs call this out explicitly, since it differs from `plot_acf`) -- significance here means the interval excludes zero, i.e. `abs(acf_vals[i]) > half_width`, not `abs(acf_vals[i]) > ci_upper` or similar. Don't compute the significance check by comparing against the raw `confint` bounds directly.
|
|
83
|
+
- `analyst/analysis_tools.py`'s `detect_seasonality_period` runs Fisher's g-test for hidden periodicity on the FULL periodogram (all non-zero Fourier frequencies, `q = len(freqs)`), THEN separately filters to `[min_period, max_period]` only for which candidates get REPORTED in `top_candidate_periods` -- don't collapse these into one step. The significance test's null distribution depends on `q` being the true, complete set of frequencies; filtering before computing `g0`/`p_value` would silently invalidate the test. The p-value formula (`p_value = min(1.0, q * (1 - g0) ** (q - 1))`) is the standard CONSERVATIVE upper-bound approximation (the leading term of Fisher's full alternating-series formula, same approximation the GeneCycle R package's `fisher.g.test` uses) -- not the exact formula. Don't "improve precision" by implementing the full alternating series without checking its numerical stability first (large `q` with small `g0` can require many terms and risks overflow in the binomial-coefficient terms).
|
|
84
|
+
- `analyst/analysis_tools.py`'s `detect_anomalies_robust_zscore` and its `_rolling_mad` helper rely on `series.rolling(window=..., min_periods=..., center=...).median()` and a separate `.rolling(...).apply(mad, raw=True)` call USING IDENTICAL windowing parameters to both. This is required for correctness, not just style: the modified z-score formula needs each point's local MAD computed using THAT SAME window's own median as the reference (not some other window's median) -- the two rolling calls only agree on medians point-for-point because their `window`/`min_periods`/`center` arguments match exactly. If you change one, change the other the same way.
|
|
85
|
+
- `analyst/analysis_tools.py`'s `detect_changepoints` uses a PERMUTATION TEST (`n_permutations`, default 500, seeded via `seed`, default 42) for its CUSUM statistic's p-value rather than a closed-form asymptotic distribution -- same reasoning `deploy/forecast_tools.py`'s `forecast_ets` already uses simulation (`n_simulations`) for its prediction intervals instead of a closed-form formula: a permutation test is exact/nonparametric by construction and sidesteps needing to derive or hard-code asymptotic critical values correctly. **Binary segmentation's per-split p-values are NOT a global significance guarantee for the full set of reported changepoints** -- each split's test is local to whatever segment existed at that point in the recursion. This is a standard, well-documented limitation of binary segmentation as a class of algorithm (not unique to this implementation, not a bug) -- don't present `detect_changepoints`' results as having an exact family-wise error rate, and don't "fix" this by adding a post-hoc correction without understanding why one isn't already there. `detect_changepoints` answers a different question from `detect_anomalies_zscore`/`detect_anomalies_robust_zscore`: a lasting shift in MEAN LEVEL vs. an individual point outlier -- a single huge isolated spike can trip an anomaly detector without being a real changepoint, and a modest sustained level shift can be a real changepoint without tripping either anomaly detector.
|
|
86
|
+
- `forecaster/model_tools.py`'s `fit_naive_baselines`/`fit_ets`/`fit_sarima`/`fit_gradient_boosted_trees` all now return `backtest_metrics` via `compute_metrics_with_ci` (bootstrap CI, percentile method, `n_bootstrap`/`confidence_level`/`seed` params, deterministic given the same seed) instead of the bare `compute_metrics`, plus new `holdout_actuals`/`holdout_predicted` fields (used by `diebold_mariano_test`, see below). `residual_diagnostics` (ETS/SARIMA) also gained `ljung_box_statistic`/`ljung_box_critical_value`/`ljung_box_effect_size`, same "how far past the boundary, not just past/fail" pattern as `ts-analyst`'s KPSS/ACF effect sizes. **This is additive, not a rename** — unlike the ADF/KPSS and ACF changes in `analyst/analysis_tools.py`, all pre-existing field names (`mae`, `rmse`, `mape_pct`, `ljung_box_p_value`, etc.) are unchanged; only new keys were added.
|
|
87
|
+
- `forecaster/model_tools.py`'s `diebold_mariano_test` is a genuinely new capability, not an enrichment of an existing tool: a Diebold & Mariano (1995)-style paired comparison of two models' forecast errors on the SAME holdout, using a Newey-West/Bartlett-kernel HAC-robust variance estimate of the mean loss differential. **Read the docstring's math carefully before touching this function** — an earlier draft defaulted the autocovariance truncation lag to `horizon = n` (treating the whole holdout as a single classic-DM "h-step-ahead forecast horizon"), which is degenerate: it collapses the intended Harvey-Leybourne-Newbold small-sample correction factor to exactly zero when `h = n` (verified: `(n + 1 - 2h + h(h-1)/n)/n = 0` algebraically when `h = n`), making every test result non-significant regardless of the actual data. The shipped version instead uses `n_lags` (an ordinary Newey-West lag-selection parameter, defaulting to the standard Newey & West 1994 automatic rule `floor(4*(n/100)^(2/9))`, decoupled from any "forecast horizon" concept) and a plain Student's t(df=n-1) reference distribution for small-sample conservatism, rather than the classic HLN correction formula specifically — a more conservative, defensible choice given this toolkit's fixed-single-origin backtest structure doesn't actually match the repeated-rolling-origin setup the classic HLN formula was derived for. Pass `n_lags=0` only when BOTH models being compared are one-step-ahead backtests (independent errors expected); the default assumes genuinely autocorrelated multi-step forecast errors, appropriate for `fit_ets`/`fit_sarima`. Comparing across the one-step-ahead/multi-step boundary (e.g. `fit_gradient_boosted_trees` vs. `fit_sarima`) with this test answers "do the error numbers differ significantly," NOT "is this a fair comparison" — the evaluation-setting mismatch documented on `fit_gradient_boosted_trees` itself still applies regardless of what this test says.
|
|
88
|
+
- `forecaster/model_tools.py`'s `fit_ets`/`fit_sarima` gained `aicc` (via `_aicc`, Hurvich & Tsai 1989 small-sample AIC correction -- `None` when `n - k - 1 <= 0`, i.e. too few training observations relative to `k = len(fit.params)` for the correction to be defined) and `backtest_interval_coverage` (via `_interval_coverage`). `_interval_coverage` deliberately mirrors `monitor/monitor_tools.py::compare_forecast_to_actuals`'s `interval_coverage` shape AND its 15-percentage-point `well_calibrated` threshold exactly, on purpose — same check, run one layer earlier (during backtesting, not after deployment). `fit_ets`'s interval is SIMULATED (`fit.simulate`, same approach as `deploy/forecast_tools.py::forecast_ets`, wrapped in try/except since simulation can fail for some parameter combinations — `backtest_interval_coverage` reports `{"error": ...}` in that case rather than omitting the field); `fit_sarima`'s is ANALYTIC (`prediction.conf_int()`, always available, no failure case). `fit_gradient_boosted_trees` does NOT get this field — it has no native prediction interval, same limitation as `forecast_gradient_boosted_trees` in `ts-deploy`.
|
|
89
|
+
- `forecaster/model_tools.py`'s `rolling_origin_backtest` reuses `fit_ets`/`fit_sarima`/`fit_gradient_boosted_trees` directly rather than reimplementing model fitting: it calls the SAME `fit_*` function repeatedly on progressively larger PREFIXES of `df` (`df.iloc[:test_end]`), relying on the fact that each `fit_*` function already does `train_test_split(df, holdout_size)` internally (last `holdout_size` rows = test, everything before = train) -- so truncating the input df to end at each origin automatically gives an expanding-window origin scheme for free, no separate windowing logic needed. `model_type` dispatch (`"ets"`/`"sarima"`/`"gbt"` -> the matching `fit_*` function, via `_ROLLING_ORIGIN_FIT_FUNCTIONS`) deliberately mirrors `retrain/retrain_tools.py::execute_redeploy`'s existing `model_type`/`params` dispatch convention for consistency across the codebase -- if you add a new `model_type` string to one, consider whether the other should know about it too. `"naive"` is deliberately NOT supported here (see the function's own docstring for why) and `fit_naive_baselines`' different two-baseline return shape isn't compatible with this function's single-result-per-origin assumption anyway.
|
|
90
|
+
- `forecaster/model_tools.py`'s `search_sarima_orders` is explicitly scoped as advisory, not authoritative — this was a deliberate design tension flagged before implementation: this toolkit's whole design (see `ts-forecaster/SKILL.md`) asks the agent to reason about model settings from `ts-analyst`'s findings, not grid-search blindly. Don't strengthen this tool's own claims (e.g. auto-selecting and returning a single "best" order rather than a `top_candidates` shortlist) without discussing that tension explicitly — it would work against the project's existing design principle, not just add a feature. `d`/`seasonal_d` are intentionally NOT searched (held fixed, caller-supplied) for the same reason. Verified directly that the numerically-best-AICc candidate on the project's own synthetic data has `residuals_look_like_white_noise: false` — a live, concrete example of why "ranked first by AICc" isn't the same as "actually good," baked into the tool's own docstring and `interpretation` field, not just asserted.
|
|
91
|
+
- `deploy/forecast_tools.py`'s `forecast_gradient_boosted_trees` now fits THREE `GradientBoostingRegressor` models per call, not one — the original point model, plus a `loss="quantile"` model at `alpha/2` and another at `1 - alpha/2`, giving GBT a prediction interval for the first time (previously the only one of the four `forecast_*` tools with none, ever). This triples the tool's compute cost — a deliberate tradeoff, not an oversight. The recursive loop still follows only the POINT model's own predictions as lag features for later steps; the quantile models are evaluated at each step against that same point-derived feature row rather than recursing on their own predictions, specifically to avoid three diverging trajectories. Independently-fit quantile regressors CAN cross (`lower_pred > upper_pred`) especially on limited data — guarded with `np.minimum`/`np.maximum` on the two arrays before returning, not by refitting or discarding. **The point model is independently fit too** (squared-error loss, not quantile loss) — nothing mathematically ties it to the quantile models, so it can legitimately land outside their own interval; caught via CI running the test suite on a different Python/scikit-learn resolution than the one this function was developed against, where `lower_pred > forecast` actually fired. Same guard-don't-refit philosophy extends to this case: `lower_arr`/`upper_arr` are ALSO clamped against `point_arr` (`np.minimum(lower_arr, point_arr)`, `np.maximum(upper_arr, point_arr)`) after the crossing fix, guaranteeing `lower <= point <= upper` always holds without ever refitting or discarding a forecast. This interval is explicitly weaker than SARIMA's/ETS's: it doesn't grow with the recursive compounding-error risk that's this tool's headline caveat, so don't let `interval_note`'s mere presence imply the same rigor — both `interval_note` and `caveat` say this explicitly, keep both if you touch this function.
|
|
92
|
+
- `deploy/forecast_tools.py` gained `_aicc`, duplicated from `forecaster/model_tools.py::_aicc` rather than imported cross-layer (same precedent as `_build_lag_features`'s existing duplication, documented in its own docstring) — `forecast_ets`/`forecast_sarima` now report `aicc` alongside `aic`. This value is NOT directly comparable to `ts-forecaster`'s `fit_ets`/`fit_sarima` `aicc` for the same series — Layer 2 computes it on the training split only (`n` = `len(train)`), Layer 3 refits on the FULL series (`n` = `len(df)`), so both `n` and the fitted params can differ. Its main use here is as a diagnostic on the deployment refit itself, not a rematch against Layer 2's candidate comparison.
|
|
93
|
+
- `deploy/forecast_tools.py`'s `_check_forecast_plausibility` (embedded as `plausibility_check` in every `forecast_*` tool's result, including `forecast_ensemble`) automates part of `ts-deploy/SKILL.md` Step 3's "does this look plausible" eyeball check. **This is deliberately NOT a hypothesis test** — there's no null distribution being tested, only an empirical comparison ("has the series done a move like this before"), so don't describe its `endpoint_change_z_score` as a p-value-bearing statistic in docs or output. It compares the forecast's ENDPOINT change (last forecast value minus last observed value) against the empirical distribution of `horizon`-length changes taken from the historical series itself (`values[horizon:] - values[:-horizon]`) — not against a fitted parametric distribution — and separately flags whether the forecast's own min/max ever leaves the historical min/max range. Returns `None` for the z-score/percentile fields (not zero, not an error) when `n <= horizon` (not enough history) or when historical change variance is ~zero — both real "can't compute this" states, not degenerate results to paper over. `is_extreme_relative_to_history` uses `abs(z) > 2.5` as its threshold — a fixed, undocumented-elsewhere constant; if you change it, note why here.
|
|
94
|
+
- `deploy/forecast_tools.py`'s `_future_dates` has a real correctness fix: `pd.infer_freq` does NOT return `None` for a series with fewer than 3 dates the way the function's original comment assumed — it RAISES `ValueError`. The pre-existing `freq = pd.infer_freq(df["date"]) or "D"` fallback only ever covered the "inferred an ambiguous/no frequency" case, not the "too few dates to even attempt inference" case, so any `forecast_*` call on a 1-2 row series crashed instead of falling back to daily. Found via `forecast_naive`'s new short-series edge-case tests (see below), not via any user report — wrap `pd.infer_freq` in `try/except ValueError` rather than relying on `or "D"` alone if you touch this function again.
|
|
95
|
+
- `deploy/forecast_tools.py`'s `forecast_naive` (via `_fit_naive_values`) now has an analytic prediction interval — previously the only `forecast_*` tool with NONE, ever. Uses the naive method's own in-sample residuals as the basis (flat naive: `np.diff(values)`, i.e. one-step differences, since a flat naive forecast IS a random-walk-with-no-drift model; seasonal naive: `values[m:] - values[:-m]`, seasonal differences) and the standard textbook formula (Hyndman & Athanasopoulos) `sigma_h = sigma * sqrt(cycles)` where `cycles = h` for flat naive and `cycles = floor((h-1)/m) + 1` for seasonal naive — the seasonal interval only widens at each COMPLETE additional seasonal cycle (steps within one cycle share the same uncertainty), not at every single step, which is why it's `floor((h-1)/m) + 1` rather than a per-step `sqrt(h)`. `z` comes from `scipy.stats.norm.ppf` (a new import in this module), NOT from a t-distribution — the residual count here can be large (the full historical series), so the normal approximation is standard and matches the textbook source, unlike `basic_stats`' small-sample Student's t elsewhere in this toolkit. Falls back to point-forecast-only (`lower`/`upper` both `None`, not zero-width) when fewer than 2 residuals are available to compute a standard deviation from — a real "can't compute this" state, same pattern as `_check_forecast_plausibility`'s own `None` fields for insufficient history, not an error to paper over. `forecast_ensemble`'s `"naive"` branch was updated to actually use this interval (previously always passed `lower=None, upper=None` for naive unconditionally) — combinations including `"naive"` can now get a combined interval where before they categorically couldn't.
|
|
96
|
+
- `deploy/forecast_tools.py`'s `forecast_ensemble` fills the gap flagged as a Next Step in the `ts-forecaster` blog post ("no ensemble or model-averaging tooling yet... arguably `ts-deploy`'s question to answer") — combining backtest-validated candidates is this layer's job (producing a forecast to use), not `ts-forecaster`'s (evaluating candidates individually). Internally calls the private `_fit_*_values` raw helpers (`_fit_naive_values`, `_fit_ets_values`, `_fit_sarima_values`, `_fit_gbt_values`) directly rather than the public `forecast_*` functions — this is required, not a style choice: the public functions run their output through `_format_forecast`'s truncation (`cap=60`), which for `horizon > 60` drops the middle of the array entirely rather than keeping it, so combining already-truncated component results would silently misalign or shrink the weighted average. The four public `forecast_*` functions were refactored into thin wrappers around these same raw helpers specifically so `forecast_ensemble` could reuse the untruncated arrays without duplicating model-fitting logic. `weights` accepts un-normalized values on purpose (normalizes internally by dividing by `sum(weights)`) specifically so raw inverse-error values from a Layer 2 comparison can be passed straight through. The combined interval is reported ONLY when every included model contributes one of its own (checked via `all(iv is not None for iv in interval_pairs)`) — deliberately not silently dropping a no-interval model's weight from a partial interval average, which would misrepresent the combination as more rigorous than it is; `interval_note` explains which model(s) were missing one when this happens.
|
|
97
|
+
- `deploy/forecast_tools.py`'s `forecast_ensemble` combined interval is a VARIANCE combination, not a bound average — this REPLACED the original implementation (which literally did `weighted_lower = sum(w_i * lo_i)`, `weighted_upper = sum(w_i * up_i)`), after the user flagged that approach as one of two remaining polish items worth fixing. Current approach: each component's own interval half-width becomes an implied sigma (`sigma_i = (up_i - lo_i) / (2*z)`, `z` from `scipy.stats.norm.ppf` at the shared `confidence_level`), combined as `combined_sigma = sqrt(sum(w_i^2 * sigma_i^2))` under an INDEPENDENCE assumption between components, then the final interval is rebuilt symmetrically around `weighted_point` as `weighted_point ± z * combined_sigma`. **This can produce a NARROWER combined interval than any single component's own interval** — verified directly: two identical naive components at equal weight (0.5/0.5) combine to exactly `1/sqrt(2)` (≈0.707x) the width of either alone, since `sqrt(0.5^2 + 0.5^2) = 1/sqrt(2)`. This is the correct, expected mathematical consequence of combining independent estimates, NOT a bug to "fix" by clamping the combined width to be at least as wide as the widest component — if you're tempted to add that clamp, don't; it would misrepresent the (still real, still documented as optimistic) independence assumption as something else entirely. The independence assumption itself IS the acknowledged weakness here (models fit on the same series share real error structure, so true combined uncertainty is wider than this formula gives) — `interval_note` and this codebase's docstrings both frame the result as a lower bound, not a precise value; don't strengthen that framing without addressing the underlying assumption.
|
|
98
|
+
- `deploy/forecast_tools.py`'s `_fit_gbt_values`/`forecast_gradient_boosted_trees` gained a bootstrap confidence interval on `feature_importances` — the other of the two polish items from the same round. **This is a SHAPE CHANGE**: `feature_importances` went from `{col: float}` to `{col: {importance, ci_lower, ci_upper}}`, same "changed shape, not just added fields" pattern as `ts-analyst`'s earlier ACF/anomaly-detector upgrades (see above) — if you're reading this field anywhere, update for the nested shape. The bootstrap resamples TRAINING ROWS (`feat[feature_cols].iloc[idx]`/`feat["value"].iloc[idx]` via `rng.integers`), not resampled errors like `compute_metrics_with_ci` elsewhere in this toolkit — there's no "error" to resample for a feature-importance question, only what the model was fit ON. This is genuinely expensive, unlike the rest of this toolkit's bootstrap CIs (which resample a precomputed metric formula in pure numpy): each of `n_bootstrap` (default 100) resamples is a FULL `GradientBoostingRegressor` refit at the same `n_estimators`/`max_depth`/`learning_rate` as the point model, on top of the three model fits `_fit_gbt_values` already does for the point forecast + its own quantile interval — so a default call now costs roughly `3 + n_bootstrap` total model fits, not 3. `n_bootstrap=0` is a first-class, documented opt-out (skips the loop entirely, returns `ci_lower`/`ci_upper` as `None`, not zero-width) — `forecast_ensemble`'s internal GBT branch always passes `n_bootstrap=0` explicitly, since ensemble results never surface `feature_importances` at all and paying for the CI there would be pure waste; don't remove that `n_bootstrap=0` from the ensemble call without understanding why it's there. Confirmed on the project's synthetic data that the CI is actually informative: `lag_7` (the real weekly seasonality) showed `importance=0.622` but a CI of `[0.44, 0.79]`, since `lag_7` and `lag_14` compete to explain the same weekly pattern and which one "wins" varies by resample — not a decorative addition.
|
|
99
|
+
|
|
100
|
+
## Where things live
|
|
101
|
+
|
|
102
|
+
- `src/omen/skills/` — the five `SKILL.md` files, bundled as package data via `skills_dir()` in `__init__.py`.
|
|
103
|
+
- `src/omen/plotting.py` — shared `render_plot` used by every layer's `plot_tools.py` (see the non-obvious-conventions entry above).
|
|
104
|
+
- `src/omen/{analyst,forecaster,deploy,monitor}/plot_tools.py` — that layer's `plot_*` tools (no `retrain/plot_tools.py` — retrain has nothing visual to plot).
|
|
105
|
+
- `openclaw.config.snippet.jsonc` — reference config for wiring all five servers + a model into OpenClaw.
|
|
106
|
+
- `graphify-out/` — a `/graphify`-generated knowledge graph of this repo (code + docs + book + images), scoped to this repo only. `graph.json`, `GRAPH_REPORT.md`, `graph.html`, `manifest.json`, and `.graphify_labels.json` are committed; `.graphify_python`, `.graphify_root`, `cost.json`, and `cache/` are gitignored on purpose — machine-specific paths or a re-buildable cache, not durable artifacts. Re-run `/graphify --update` to refresh it incrementally after code/doc changes rather than a full rebuild.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emily Marie Williams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|