rootcause-sdk 1.0.0__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.
Files changed (30) hide show
  1. rootcause_sdk-1.0.0/.gitignore +10 -0
  2. rootcause_sdk-1.0.0/.gitlab-ci.yml +65 -0
  3. rootcause_sdk-1.0.0/PKG-INFO +148 -0
  4. rootcause_sdk-1.0.0/README.md +116 -0
  5. rootcause_sdk-1.0.0/examples/.ipynb_checkpoints/temporal-panel-checkpoint.ipynb +296 -0
  6. rootcause_sdk-1.0.0/examples/.ipynb_checkpoints/temporal-panel-executed-checkpoint.ipynb +946 -0
  7. rootcause_sdk-1.0.0/examples/quickstart.ipynb +1267 -0
  8. rootcause_sdk-1.0.0/examples/temporal-panel.ipynb +296 -0
  9. rootcause_sdk-1.0.0/pyproject.toml +45 -0
  10. rootcause_sdk-1.0.0/rootcause/__init__.py +175 -0
  11. rootcause_sdk-1.0.0/rootcause/_http.py +361 -0
  12. rootcause_sdk-1.0.0/rootcause/direct.py +178 -0
  13. rootcause_sdk-1.0.0/rootcause/errors.py +58 -0
  14. rootcause_sdk-1.0.0/rootcause/graph.py +119 -0
  15. rootcause_sdk-1.0.0/rootcause/interventions.py +149 -0
  16. rootcause_sdk-1.0.0/rootcause/jupyter.py +237 -0
  17. rootcause_sdk-1.0.0/rootcause/ontology.py +188 -0
  18. rootcause_sdk-1.0.0/rootcause/results.py +266 -0
  19. rootcause_sdk-1.0.0/rootcause/twin.py +428 -0
  20. rootcause_sdk-1.0.0/rootcause/workspace.py +338 -0
  21. rootcause_sdk-1.0.0/tests/conftest.py +61 -0
  22. rootcause_sdk-1.0.0/tests/test_direct.py +44 -0
  23. rootcause_sdk-1.0.0/tests/test_interventions.py +61 -0
  24. rootcause_sdk-1.0.0/tests/test_jupyter.py +54 -0
  25. rootcause_sdk-1.0.0/tests/test_new_verbs.py +232 -0
  26. rootcause_sdk-1.0.0/tests/test_transport.py +58 -0
  27. rootcause_sdk-1.0.0/tests/test_twin.py +152 -0
  28. rootcause_sdk-1.0.0/tests/test_twin_reuse.py +78 -0
  29. rootcause_sdk-1.0.0/tests/test_workspace_and_ontology.py +112 -0
  30. rootcause_sdk-1.0.0/uv.lock +1076 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ .venv/
8
+
9
+ *.rctwin
10
+ examples/*-executed.ipynb
@@ -0,0 +1,65 @@
1
+ stages:
2
+ - generate
3
+ - build
4
+ - publish
5
+
6
+ variables:
7
+ PYTHON_IMAGE: python:3.12-slim
8
+
9
+ generate:
10
+ stage: generate
11
+ image: $PYTHON_IMAGE
12
+ script:
13
+ - pip install openapi-python-client
14
+ - openapi-python-client generate --path $OPENAPI_SPEC_URL --output-path /tmp/gen --overwrite
15
+ - cp -r /tmp/gen/root_cause_public_api_client/* generated/
16
+ artifacts:
17
+ paths:
18
+ - generated/
19
+ rules:
20
+ - if: $CI_PIPELINE_SOURCE == "trigger"
21
+ - if: $CI_PIPELINE_SOURCE == "web"
22
+
23
+ build:
24
+ stage: build
25
+ image: $PYTHON_IMAGE
26
+ script:
27
+ # On a tag, the tag is the single source of truth for the version.
28
+ - |
29
+ if [ -n "$CI_COMMIT_TAG" ]; then
30
+ echo "Pinning version to tag $CI_COMMIT_TAG"
31
+ sed -i -E "s/^version = \".*\"/version = \"$CI_COMMIT_TAG\"/" pyproject.toml
32
+ grep -E '^version = ' pyproject.toml
33
+ fi
34
+ - pip install build twine
35
+ - python -m build
36
+ - twine check --strict dist/*
37
+ - ls -l dist/
38
+ artifacts:
39
+ paths:
40
+ - dist/
41
+ expire_in: 30 days
42
+ rules:
43
+ - if: $CI_COMMIT_TAG =~ /^[0-9]+\.[0-9]+\.[0-9]+([ab]|rc)?[0-9]*$/
44
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
45
+
46
+ publish:
47
+ stage: publish
48
+ image: $PYTHON_IMAGE
49
+ needs:
50
+ - job: build
51
+ artifacts: true
52
+ environment:
53
+ name: pypi
54
+ url: https://pypi.org/project/rootcause-sdk/
55
+ variables:
56
+ TWINE_USERNAME: __token__
57
+ TWINE_PASSWORD: $PYPI_API_TOKEN
58
+ TWINE_REPOSITORY_URL: https://upload.pypi.org/legacy/
59
+ script:
60
+ - pip install twine
61
+ - twine check --strict dist/*
62
+ - twine upload --non-interactive --disable-progress-bar dist/*
63
+ rules:
64
+ - if: $CI_COMMIT_TAG =~ /^[0-9]+\.[0-9]+\.[0-9]+([ab]|rc)?[0-9]*$/
65
+ when: manual
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: rootcause-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the RootCause causal AI platform
5
+ Project-URL: Homepage, https://rootcause.ai
6
+ Project-URL: Repository, https://gitlab.com/perceptura/sdks/rootcause-python
7
+ Project-URL: Documentation, https://docs.rootcause.ai
8
+ Author-email: RootCause <engineering@rootcause.ai>
9
+ License: MIT
10
+ Keywords: api-client,causal-inference,digital-twin,jupyter,rootcause,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27.0
22
+ Requires-Dist: pandas>=2.0.0
23
+ Requires-Dist: pyarrow>=14.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Provides-Extra: graph
28
+ Requires-Dist: networkx>=3.0; extra == 'graph'
29
+ Provides-Extra: jupyter
30
+ Requires-Dist: anywidget>=0.9; extra == 'jupyter'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # rootcause-sdk
34
+
35
+ [RootCause](https://rootcause.ai) is a causal AI platform: bring your data, discover causal structure, train digital twins, and ask what-if questions. This is the official Python SDK. Full docs: [docs.rootcause.ai](https://docs.rootcause.ai).
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install rootcause-sdk
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ import rootcause as rc
47
+ import pandas as pd
48
+
49
+ rc.login() # ROOTCAUSE_API_KEY, or browser login
50
+
51
+ df = pd.read_csv("lalonde.csv")
52
+
53
+ graph = rc.discover(df, target="re78") # causal discovery on a DataFrame
54
+ graph.pin("treat", "re78") # domain knowledge
55
+ twin = graph.train()
56
+
57
+ ate = twin.intervene({"treat": rc.set(1)}, where={"re75": ("<", 5000)})
58
+ ate.summary
59
+ ```
60
+
61
+ Nothing above mentions a workspace: direct mode keeps platform ceremony out of sight and reuses uploads by content hash.
62
+
63
+ ## Platform mode
64
+
65
+ The same classes work against everything your team builds in the RootCause UI:
66
+
67
+ ```python
68
+ ws = rc.workspace("Calix Forecasting")
69
+
70
+ ws.sources["shipments"].to_frame() # tab-completes live names
71
+ ws.upload(df, name="shipments-v2")
72
+
73
+ twin = ws.twin("C8 Temporal") # trained by a colleague — just there
74
+ fc = twin.forecast(horizon=24)
75
+ fc.to_frame() # tidy long format, straight to pandas
76
+
77
+ twin.ask("what happens to bookings if we cut trade shows entirely?")
78
+ ```
79
+
80
+ ## The power-user primitive
81
+
82
+ Every simulation family is a wrapper over conditional sampling. The SDK exposes it raw:
83
+
84
+ ```python
85
+ draws = twin.sample(n=10_000, do={"price": rc.pct(+10)}, where={"region": "FL"}, seed=42)
86
+ draws.to_frame() # one row per joint posterior draw
87
+ ```
88
+
89
+ Interventions: `rc.set(value)`, `rc.pct(+15)`, `rc.add(-5)`, `rc.prob("yes", 0.8)`, `rc.adjust_prob("yes", +10)`, `rc.members(include=[...], size=4)`. Bare values mean `rc.set`. Conditions: `{"region": "EMEA"}` or `{"re75": ("<", 5000)}`.
90
+
91
+ ## Ontology queries
92
+
93
+ ```python
94
+ onto = ws.ontology
95
+ onto.concepts
96
+
97
+ result = onto.query(
98
+ select=["customer", "revenue"],
99
+ where=[("region", "==", "US")],
100
+ group_by=["customer"],
101
+ order_by="-revenue",
102
+ aggregate={"revenue": "sum"},
103
+ )
104
+ result.to_frame()
105
+
106
+ onto.ask("average revenue per customer in Florida last quarter")
107
+ ```
108
+
109
+ ## Portable twins
110
+
111
+ ```python
112
+ twin.save("c8.rctwin") # export zip with trained model params
113
+ twin2 = rc.load_twin("c8.rctwin") # later, anywhere, same auth
114
+ ```
115
+
116
+ ## Authentication
117
+
118
+ `rc.login()` resolves credentials in order: explicit `api_key="pk_…"` → `ROOTCAUSE_API_KEY` / `ROOTCAUSE_BASE_URL` env vars → cached OAuth token in `~/.rootcause/` → interactive browser login (PKCE; remote kernels get a paste-the-code fallback). Create API keys under **Organisation → API** on your platform.
119
+
120
+ ## Releasing
121
+
122
+ Publishing to PyPI is driven entirely by git tags. The tag is the version — `pyproject.toml`
123
+ is patched in CI at build time, so don't bother bumping it by hand.
124
+
125
+ 1. Push a tag matching `MAJOR.MINOR.PATCH` (pre-releases like `1.2.0rc1` also work):
126
+
127
+ ```bash
128
+ git tag 0.2.0 && git push origin 0.2.0
129
+ ```
130
+
131
+ 2. The `build` job builds the sdist + wheel and runs `twine check --strict`.
132
+ 3. Trigger the `publish` job (manual) to release to PyPI.
133
+
134
+ ### Required CI variable
135
+
136
+ Set as a **masked** and **protected** project-level variable in GitLab
137
+ (Settings → CI/CD → Variables):
138
+
139
+ | Variable | Purpose |
140
+ | ---------------- | --------------------------------------------------- |
141
+ | `PYPI_API_TOKEN` | PyPI API token (`pypi-...`), scoped to this project |
142
+
143
+ It is passed to twine as the password with username `__token__`; nothing is
144
+ hardcoded in the pipeline.
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,116 @@
1
+ # rootcause-sdk
2
+
3
+ [RootCause](https://rootcause.ai) is a causal AI platform: bring your data, discover causal structure, train digital twins, and ask what-if questions. This is the official Python SDK. Full docs: [docs.rootcause.ai](https://docs.rootcause.ai).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install rootcause-sdk
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import rootcause as rc
15
+ import pandas as pd
16
+
17
+ rc.login() # ROOTCAUSE_API_KEY, or browser login
18
+
19
+ df = pd.read_csv("lalonde.csv")
20
+
21
+ graph = rc.discover(df, target="re78") # causal discovery on a DataFrame
22
+ graph.pin("treat", "re78") # domain knowledge
23
+ twin = graph.train()
24
+
25
+ ate = twin.intervene({"treat": rc.set(1)}, where={"re75": ("<", 5000)})
26
+ ate.summary
27
+ ```
28
+
29
+ Nothing above mentions a workspace: direct mode keeps platform ceremony out of sight and reuses uploads by content hash.
30
+
31
+ ## Platform mode
32
+
33
+ The same classes work against everything your team builds in the RootCause UI:
34
+
35
+ ```python
36
+ ws = rc.workspace("Calix Forecasting")
37
+
38
+ ws.sources["shipments"].to_frame() # tab-completes live names
39
+ ws.upload(df, name="shipments-v2")
40
+
41
+ twin = ws.twin("C8 Temporal") # trained by a colleague — just there
42
+ fc = twin.forecast(horizon=24)
43
+ fc.to_frame() # tidy long format, straight to pandas
44
+
45
+ twin.ask("what happens to bookings if we cut trade shows entirely?")
46
+ ```
47
+
48
+ ## The power-user primitive
49
+
50
+ Every simulation family is a wrapper over conditional sampling. The SDK exposes it raw:
51
+
52
+ ```python
53
+ draws = twin.sample(n=10_000, do={"price": rc.pct(+10)}, where={"region": "FL"}, seed=42)
54
+ draws.to_frame() # one row per joint posterior draw
55
+ ```
56
+
57
+ Interventions: `rc.set(value)`, `rc.pct(+15)`, `rc.add(-5)`, `rc.prob("yes", 0.8)`, `rc.adjust_prob("yes", +10)`, `rc.members(include=[...], size=4)`. Bare values mean `rc.set`. Conditions: `{"region": "EMEA"}` or `{"re75": ("<", 5000)}`.
58
+
59
+ ## Ontology queries
60
+
61
+ ```python
62
+ onto = ws.ontology
63
+ onto.concepts
64
+
65
+ result = onto.query(
66
+ select=["customer", "revenue"],
67
+ where=[("region", "==", "US")],
68
+ group_by=["customer"],
69
+ order_by="-revenue",
70
+ aggregate={"revenue": "sum"},
71
+ )
72
+ result.to_frame()
73
+
74
+ onto.ask("average revenue per customer in Florida last quarter")
75
+ ```
76
+
77
+ ## Portable twins
78
+
79
+ ```python
80
+ twin.save("c8.rctwin") # export zip with trained model params
81
+ twin2 = rc.load_twin("c8.rctwin") # later, anywhere, same auth
82
+ ```
83
+
84
+ ## Authentication
85
+
86
+ `rc.login()` resolves credentials in order: explicit `api_key="pk_…"` → `ROOTCAUSE_API_KEY` / `ROOTCAUSE_BASE_URL` env vars → cached OAuth token in `~/.rootcause/` → interactive browser login (PKCE; remote kernels get a paste-the-code fallback). Create API keys under **Organisation → API** on your platform.
87
+
88
+ ## Releasing
89
+
90
+ Publishing to PyPI is driven entirely by git tags. The tag is the version — `pyproject.toml`
91
+ is patched in CI at build time, so don't bother bumping it by hand.
92
+
93
+ 1. Push a tag matching `MAJOR.MINOR.PATCH` (pre-releases like `1.2.0rc1` also work):
94
+
95
+ ```bash
96
+ git tag 0.2.0 && git push origin 0.2.0
97
+ ```
98
+
99
+ 2. The `build` job builds the sdist + wheel and runs `twine check --strict`.
100
+ 3. Trigger the `publish` job (manual) to release to PyPI.
101
+
102
+ ### Required CI variable
103
+
104
+ Set as a **masked** and **protected** project-level variable in GitLab
105
+ (Settings → CI/CD → Variables):
106
+
107
+ | Variable | Purpose |
108
+ | ---------------- | --------------------------------------------------- |
109
+ | `PYPI_API_TOKEN` | PyPI API token (`pypi-...`), scoped to this project |
110
+
111
+ It is passed to twine as the password with username `__token__`; nothing is
112
+ hardcoded in the pipeline.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,296 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Temporal and panel twins\n",
8
+ "\n",
9
+ "Time series and multi-environment (panel) data get their own twin kinds with their own machinery:\n",
10
+ "lagged dependencies, latent influence detection, per-environment models, forecasts with\n",
11
+ "attribution, and interventions that are scheduled in time. This notebook runs all of it live.\n",
12
+ "\n",
13
+ "| Kind | Kwargs | What it models |\n",
14
+ "| --- | --- | --- |\n",
15
+ "| `temporal` | `time=` | one time series with lagged causal structure |\n",
16
+ "| `multi-environment-static` | `entity=` | the same system observed across environments |\n",
17
+ "| `multi-environment-temporal` | `time=` + `entity=` | a panel: many environments, each a time series |"
18
+ ]
19
+ },
20
+ {
21
+ "cell_type": "code",
22
+ "execution_count": null,
23
+ "metadata": {},
24
+ "outputs": [],
25
+ "source": [
26
+ "import os\n",
27
+ "import numpy as np\n",
28
+ "import pandas as pd\n",
29
+ "\n",
30
+ "import rootcause as rc\n",
31
+ "\n",
32
+ "rc.login(base_url=os.environ.get(\"ROOTCAUSE_BASE_URL\", \"https://platform.rootcause.ai\"))"
33
+ ]
34
+ },
35
+ {
36
+ "cell_type": "markdown",
37
+ "metadata": {},
38
+ "source": [
39
+ "## A panel: three stores, thirty months\n",
40
+ "\n",
41
+ "Ground truth: price suppresses demand, demand carries momentum (a lag), seasonality moves both,\n",
42
+ "and each store runs at its own scale. Long format: one row per store per month."
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "code",
47
+ "execution_count": null,
48
+ "metadata": {},
49
+ "outputs": [],
50
+ "source": [
51
+ "rng = np.random.default_rng(11)\n",
52
+ "months = pd.date_range(\"2024-01-01\", periods=30, freq=\"MS\")\n",
53
+ "stores = {\"london\": 1.0, \"paris\": 0.8, \"berlin\": 1.25}\n",
54
+ "rows = []\n",
55
+ "for store, scale in stores.items():\n",
56
+ " demand_prev = 100.0 * scale\n",
57
+ " for i, month in enumerate(months):\n",
58
+ " season = 12 * np.sin(2 * np.pi * (i % 12) / 12)\n",
59
+ " price = 20 + 2 * np.sin(2 * np.pi * (i % 12) / 12 + 1) + rng.normal(0, 0.5)\n",
60
+ " demand = 0.55 * demand_prev + 60 * scale - 2.4 * price + season + rng.normal(0, 4)\n",
61
+ " revenue = price * demand * 0.1 + rng.normal(0, 3)\n",
62
+ " rows.append({\"month\": month.strftime(\"%Y-%m-%d\"), \"store\": store,\n",
63
+ " \"price\": round(price, 2), \"demand\": round(demand, 1), \"revenue\": round(revenue, 1)})\n",
64
+ " demand_prev = demand\n",
65
+ "panel = pd.DataFrame(rows)\n",
66
+ "panel.head()"
67
+ ]
68
+ },
69
+ {
70
+ "cell_type": "markdown",
71
+ "metadata": {},
72
+ "source": [
73
+ "## Discovery finds more than edges\n",
74
+ "\n",
75
+ "`time=` and `entity=` make this a panel-temporal twin. Note the graph: alongside the causal\n",
76
+ "edges, discovery surfaced a **latent influence**, a hidden common cause it detected in the data\n",
77
+ "but could not name, and it attributes the store-level differences to the environment itself."
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "code",
82
+ "execution_count": null,
83
+ "metadata": {},
84
+ "outputs": [],
85
+ "source": [
86
+ "graph = rc.discover(panel, time=\"month\", entity=\"store\", force=True)\n",
87
+ "graph.edges"
88
+ ]
89
+ },
90
+ {
91
+ "cell_type": "code",
92
+ "execution_count": null,
93
+ "metadata": {},
94
+ "outputs": [],
95
+ "source": [
96
+ "twin = graph.train()\n",
97
+ "twin"
98
+ ]
99
+ },
100
+ {
101
+ "cell_type": "markdown",
102
+ "metadata": {},
103
+ "source": [
104
+ "## Per-environment sampling\n",
105
+ "\n",
106
+ "Panel twins hold one model per environment. Sampling narrows with `environments=` and the\n",
107
+ "returned frame carries an `environment` column; seeds derive stable per-environment children,\n",
108
+ "so comparisons are deterministic."
109
+ ]
110
+ },
111
+ {
112
+ "cell_type": "code",
113
+ "execution_count": null,
114
+ "metadata": {},
115
+ "outputs": [],
116
+ "source": [
117
+ "draws = twin.sample(n=500, environments=[\"london\", \"berlin\"], seed=3)\n",
118
+ "draws.to_frame().groupby(\"environment\").mean(numeric_only=True).round(1)"
119
+ ]
120
+ },
121
+ {
122
+ "cell_type": "markdown",
123
+ "metadata": {},
124
+ "source": [
125
+ "## Forecasts carry their reasoning\n",
126
+ "\n",
127
+ "Each forecast step comes with bounds and an attribution: how much of the prediction is trend,\n",
128
+ "season, and each causal parent (with lags). `aggregate=\"sum\"` adds a combined series across\n",
129
+ "environments; `origin_timestamp` anchors backtests."
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": null,
135
+ "metadata": {},
136
+ "outputs": [],
137
+ "source": [
138
+ "fc = twin.forecast(horizon=6, targets=[\"revenue\"], aggregate=\"sum\")\n",
139
+ "fc.to_frame()[[\"environment\", \"timestamp\", \"prediction\", \"lowerBound\", \"upperBound\"]].head(8).round(1)"
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "code",
144
+ "execution_count": null,
145
+ "metadata": {},
146
+ "outputs": [],
147
+ "source": [
148
+ "fc.to_frame().loc[0, \"attribution\"]"
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "markdown",
153
+ "metadata": {},
154
+ "source": [
155
+ "## Interventions scheduled in time\n",
156
+ "\n",
157
+ "`rc.at` wraps any intervention value with when it applies: `persistent=True` from the first\n",
158
+ "step onwards, `duration_steps=` for a limited window, `timestamp=` for a specific start.\n",
159
+ "Here: a permanent 10 percent price cut, in London only."
160
+ ]
161
+ },
162
+ {
163
+ "cell_type": "code",
164
+ "execution_count": null,
165
+ "metadata": {},
166
+ "outputs": [],
167
+ "source": [
168
+ "result = twin.intervene(\n",
169
+ " {\"price\": rc.at(rc.pct(-10), persistent=True)},\n",
170
+ " outcomes=[\"revenue\"],\n",
171
+ " environments=[\"london\"],\n",
172
+ ")\n",
173
+ "result"
174
+ ]
175
+ },
176
+ {
177
+ "cell_type": "markdown",
178
+ "metadata": {},
179
+ "source": [
180
+ "## Monthly refresh: assimilate instead of retrain\n",
181
+ "\n",
182
+ "When next month's rows arrive, the model doesn't need rebuilding — extend the twin's\n",
183
+ "source and fold the new rows into the fitted model with `update()`. It finishes with a\n",
184
+ "status, never an error: `committed` (rows folded in), `up_to_date` (nothing new), or\n",
185
+ "`retrain_required` (the model can't take these rows incrementally — call `twin.retrain()`).\n",
186
+ "\n",
187
+ "Static and temporal twins assimilate out of the box. Panel twins need the v2 panel\n",
188
+ "engine (an opt-in in the twin builder); on anything else `update()` simply reports\n",
189
+ "`retrain_required`, and `twin.update_eligibility` tells you in advance. London's series\n",
190
+ "as its own temporal twin:"
191
+ ]
192
+ },
193
+ {
194
+ "cell_type": "code",
195
+ "metadata": {},
196
+ "execution_count": null,
197
+ "outputs": [],
198
+ "source": [
199
+ "london = panel[panel[\"store\"] == \"london\"][[\"month\", \"price\", \"demand\", \"revenue\"]].reset_index(drop=True)\n",
200
+ "monthly = rc.discover(london, time=\"month\", force=True).train()\n",
201
+ "monthly"
202
+ ]
203
+ },
204
+ {
205
+ "cell_type": "markdown",
206
+ "metadata": {},
207
+ "source": [
208
+ "Two months pass. Extend the source with the new rows and update — the transcript is\n",
209
+ "the whole loop:"
210
+ ]
211
+ },
212
+ {
213
+ "cell_type": "code",
214
+ "metadata": {},
215
+ "execution_count": null,
216
+ "outputs": [],
217
+ "source": [
218
+ "source = monthly.source\n",
219
+ "current = source.to_frame()\n",
220
+ "last_month = pd.to_datetime(current[\"month\"]).max()\n",
221
+ "demand_prev = current.sort_values(\"month\")[\"demand\"].iloc[-1]\n",
222
+ "\n",
223
+ "rows = []\n",
224
+ "for month in pd.date_range(last_month + pd.offsets.MonthBegin(1), periods=2, freq=\"MS\"):\n",
225
+ " j = (month.year - 2024) * 12 + (month.month - 1)\n",
226
+ " season = 12 * np.sin(2 * np.pi * (j % 12) / 12)\n",
227
+ " price = 20 + 2 * np.sin(2 * np.pi * (j % 12) / 12 + 1) + rng.normal(0, 0.5)\n",
228
+ " demand = 0.55 * demand_prev + 60 - 2.4 * price + season + rng.normal(0, 4)\n",
229
+ " rows.append({\"month\": month.strftime(\"%Y-%m-%d\"),\n",
230
+ " \"price\": round(price, 2), \"demand\": round(demand, 1),\n",
231
+ " \"revenue\": round(price * demand * 0.1 + rng.normal(0, 3), 1)})\n",
232
+ " demand_prev = demand\n",
233
+ "\n",
234
+ "source.extend(pd.DataFrame(rows))\n",
235
+ "result = monthly.update()\n",
236
+ "result"
237
+ ]
238
+ },
239
+ {
240
+ "cell_type": "markdown",
241
+ "metadata": {},
242
+ "source": [
243
+ "Running it again with nothing new in the source is how a scheduled job stays honest —\n",
244
+ "the second call is a cheap no-op:"
245
+ ]
246
+ },
247
+ {
248
+ "cell_type": "code",
249
+ "metadata": {},
250
+ "execution_count": null,
251
+ "outputs": [],
252
+ "source": [
253
+ "monthly.update()"
254
+ ]
255
+ },
256
+ {
257
+ "cell_type": "markdown",
258
+ "metadata": {},
259
+ "source": [
260
+ "The refreshed model forecasts onwards from the assimilated months:"
261
+ ]
262
+ },
263
+ {
264
+ "cell_type": "code",
265
+ "metadata": {},
266
+ "execution_count": null,
267
+ "outputs": [],
268
+ "source": [
269
+ "fc = monthly.forecast(horizon=3, targets=[\"revenue\"])\n",
270
+ "fc.to_frame()[[\"timestamp\", \"prediction\", \"lowerBound\", \"upperBound\"]].round(1)"
271
+ ]
272
+ },
273
+ {
274
+ "cell_type": "markdown",
275
+ "metadata": {},
276
+ "source": [
277
+ "The same scheduling works on plain temporal twins, and everything else from the\n",
278
+ "[quickstart](quickstart.ipynb) applies unchanged: `save`, `load_twin`, `console`, and the\n",
279
+ "ontology all understand temporal and panel twins."
280
+ ]
281
+ }
282
+ ],
283
+ "metadata": {
284
+ "kernelspec": {
285
+ "display_name": "Python 3",
286
+ "language": "python",
287
+ "name": "python3"
288
+ },
289
+ "language_info": {
290
+ "name": "python",
291
+ "version": "3.12"
292
+ }
293
+ },
294
+ "nbformat": 4,
295
+ "nbformat_minor": 5
296
+ }