expr-tracker 0.1.7__tar.gz → 0.2.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.
- expr_tracker-0.2.0/.github/workflows/docs.yaml +53 -0
- {expr_tracker-0.1.7 → expr_tracker-0.2.0}/.gitignore +3 -0
- expr_tracker-0.2.0/PKG-INFO +143 -0
- expr_tracker-0.2.0/README.md +116 -0
- expr_tracker-0.2.0/docs/architecture.md +214 -0
- expr_tracker-0.2.0/docs/design.md +378 -0
- expr_tracker-0.2.0/docs/getting-started.md +96 -0
- expr_tracker-0.2.0/docs/guide/alerts.md +169 -0
- expr_tracker-0.2.0/docs/guide/artifacts.md +82 -0
- expr_tracker-0.2.0/docs/guide/backends.md +89 -0
- expr_tracker-0.2.0/docs/guide/cli.md +70 -0
- expr_tracker-0.2.0/docs/guide/distributed.md +68 -0
- expr_tracker-0.2.0/docs/guide/history.md +103 -0
- expr_tracker-0.2.0/docs/guide/logging.md +112 -0
- expr_tracker-0.2.0/docs/index.md +47 -0
- expr_tracker-0.2.0/docs/reference/api.md +207 -0
- expr_tracker-0.2.0/docs/reference/configuration.md +127 -0
- expr_tracker-0.2.0/docs/reference/expressions.md +139 -0
- expr_tracker-0.2.0/mkdocs.yml +66 -0
- expr_tracker-0.2.0/pyproject.toml +86 -0
- expr_tracker-0.2.0/src/expr_tracker/__init__.py +66 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/__init__.py +276 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/backends/__init__.py +270 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/backends/base.py +115 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/dispatch.py +317 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/engine.py +384 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/__init__.py +27 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/eval.py +358 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/functions.py +283 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/lexer.py +136 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/nodes.py +356 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/parser.py +195 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/expr/rule.py +127 -0
- expr_tracker-0.2.0/src/expr_tracker/alerts/models.py +357 -0
- expr_tracker-0.2.0/src/expr_tracker/artifacts.py +355 -0
- expr_tracker-0.2.0/src/expr_tracker/cli.py +193 -0
- {expr_tracker-0.1.7 → expr_tracker-0.2.0}/src/expr_tracker/encoders.py +27 -1
- expr_tracker-0.2.0/src/expr_tracker/history/__init__.py +21 -0
- expr_tracker-0.2.0/src/expr_tracker/history/codec.py +98 -0
- expr_tracker-0.2.0/src/expr_tracker/history/frame.py +91 -0
- expr_tracker-0.2.0/src/expr_tracker/history/reader.py +359 -0
- expr_tracker-0.2.0/src/expr_tracker/history/series.py +115 -0
- expr_tracker-0.2.0/src/expr_tracker/history/store.py +863 -0
- expr_tracker-0.2.0/src/expr_tracker/history/writer.py +406 -0
- expr_tracker-0.2.0/src/expr_tracker/run.py +431 -0
- expr_tracker-0.2.0/src/expr_tracker/summary.py +95 -0
- expr_tracker-0.2.0/src/expr_tracker/tracker.py +168 -0
- expr_tracker-0.2.0/src/expr_tracker/types.py +11 -0
- expr_tracker-0.2.0/tests/conftest.py +74 -0
- expr_tracker-0.2.0/tests/test_alert_backends.py +181 -0
- expr_tracker-0.2.0/tests/test_alert_delivery.py +493 -0
- expr_tracker-0.2.0/tests/test_alert_dispatch.py +317 -0
- expr_tracker-0.2.0/tests/test_alert_engine.py +184 -0
- expr_tracker-0.2.0/tests/test_alert_models.py +151 -0
- expr_tracker-0.2.0/tests/test_alert_routing.py +519 -0
- expr_tracker-0.2.0/tests/test_artifacts.py +350 -0
- expr_tracker-0.2.0/tests/test_benchmark.py +307 -0
- expr_tracker-0.2.0/tests/test_cache.py +368 -0
- expr_tracker-0.2.0/tests/test_cli.py +290 -0
- expr_tracker-0.2.0/tests/test_correctness.py +407 -0
- expr_tracker-0.2.0/tests/test_distributed.py +352 -0
- expr_tracker-0.2.0/tests/test_e2e.py +286 -0
- expr_tracker-0.2.0/tests/test_expr_builder.py +163 -0
- expr_tracker-0.2.0/tests/test_expr_eval.py +208 -0
- expr_tracker-0.2.0/tests/test_expr_functions.py +381 -0
- expr_tracker-0.2.0/tests/test_expr_parser.py +178 -0
- expr_tracker-0.2.0/tests/test_expr_properties.py +320 -0
- expr_tracker-0.2.0/tests/test_failure_modes.py +451 -0
- expr_tracker-0.2.0/tests/test_features.py +417 -0
- expr_tracker-0.2.0/tests/test_frame_codec_summary.py +210 -0
- expr_tracker-0.2.0/tests/test_history.py +481 -0
- expr_tracker-0.2.0/tests/test_hot_paths.py +348 -0
- expr_tracker-0.2.0/tests/test_integration.py +191 -0
- expr_tracker-0.2.0/tests/test_lark_live.py +269 -0
- expr_tracker-0.2.0/tests/test_perf.py +82 -0
- expr_tracker-0.2.0/tests/test_public_surfaces.py +400 -0
- expr_tracker-0.2.0/tests/test_review_regressions.py +498 -0
- expr_tracker-0.2.0/tests/test_rule_lifecycle.py +453 -0
- expr_tracker-0.2.0/tests/test_run_backends.py +352 -0
- expr_tracker-0.2.0/tests/test_scenarios.py +435 -0
- expr_tracker-0.2.0/tests/test_step_commit.py +140 -0
- expr_tracker-0.2.0/tests/test_stress.py +249 -0
- expr_tracker-0.2.0/tests/test_trackio.py +226 -0
- expr_tracker-0.2.0/tests/test_value_encoding.py +376 -0
- expr_tracker-0.2.0/tests/test_wandb.py +260 -0
- expr_tracker-0.2.0/tests/test_writer_buffer.py +261 -0
- expr_tracker-0.2.0/tests/test_writer_durability.py +306 -0
- expr_tracker-0.2.0/uv.lock +2807 -0
- expr_tracker-0.1.7/PKG-INFO +0 -67
- expr_tracker-0.1.7/README.md +0 -55
- expr_tracker-0.1.7/pyproject.toml +0 -26
- expr_tracker-0.1.7/src/expr_tracker/__init__.py +0 -9
- expr_tracker-0.1.7/src/expr_tracker/alert.py +0 -73
- expr_tracker-0.1.7/src/expr_tracker/cli.py +0 -15
- expr_tracker-0.1.7/src/expr_tracker/jsonl.py +0 -209
- expr_tracker-0.1.7/src/expr_tracker/tracker.py +0 -167
- expr_tracker-0.1.7/src/expr_tracker/types.py +0 -10
- expr_tracker-0.1.7/tests/test_jsonl_buffer.py +0 -113
- expr_tracker-0.1.7/tests/test_tracker.py +0 -14
- expr_tracker-0.1.7/uv.lock +0 -1425
- {expr_tracker-0.1.7 → expr_tracker-0.2.0}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.1.7 → expr_tracker-0.2.0}/src/expr_tracker/_compat.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "docs/**"
|
|
8
|
+
- "mkdocs.yml"
|
|
9
|
+
- ".github/workflows/docs.yaml"
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: pages
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
name: Build site
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout
|
|
25
|
+
uses: actions/checkout@v5
|
|
26
|
+
|
|
27
|
+
- name: Install uv
|
|
28
|
+
uses: astral-sh/setup-uv@v6
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
|
|
32
|
+
- name: Build
|
|
33
|
+
run: uv run --group docs mkdocs build --strict
|
|
34
|
+
|
|
35
|
+
- name: Upload artifact
|
|
36
|
+
uses: actions/upload-pages-artifact@v3
|
|
37
|
+
with:
|
|
38
|
+
path: site
|
|
39
|
+
|
|
40
|
+
deploy:
|
|
41
|
+
name: Deploy to GitHub Pages
|
|
42
|
+
needs: build
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
permissions:
|
|
45
|
+
pages: write
|
|
46
|
+
id-token: write
|
|
47
|
+
environment:
|
|
48
|
+
name: github-pages
|
|
49
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
50
|
+
steps:
|
|
51
|
+
- name: Deploy
|
|
52
|
+
id: deployment
|
|
53
|
+
uses: actions/deploy-pages@v4
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: expr_tracker
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: HSPK <whxway@whu.edu.cn>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: click>=8.1.0
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Provides-Extra: all
|
|
11
|
+
Requires-Dist: pandas>=1.5; extra == 'all'
|
|
12
|
+
Requires-Dist: polars>=0.20; extra == 'all'
|
|
13
|
+
Requires-Dist: slark>=0.1.28; extra == 'all'
|
|
14
|
+
Requires-Dist: trackio>=0.4.0; extra == 'all'
|
|
15
|
+
Requires-Dist: wandb>=0.21.0; extra == 'all'
|
|
16
|
+
Provides-Extra: lark
|
|
17
|
+
Requires-Dist: slark>=0.1.28; extra == 'lark'
|
|
18
|
+
Provides-Extra: pandas
|
|
19
|
+
Requires-Dist: pandas>=1.5; extra == 'pandas'
|
|
20
|
+
Provides-Extra: polars
|
|
21
|
+
Requires-Dist: polars>=0.20; extra == 'polars'
|
|
22
|
+
Provides-Extra: trackio
|
|
23
|
+
Requires-Dist: trackio>=0.4.0; extra == 'trackio'
|
|
24
|
+
Provides-Extra: wandb
|
|
25
|
+
Requires-Dist: wandb>=0.21.0; extra == 'wandb'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Experiment Tracker
|
|
29
|
+
|
|
30
|
+
A local-first experiment tracker. Metrics land in a JSONL file you own, stay
|
|
31
|
+
queryable while the run is live, and can trigger alerts from an expression language.
|
|
32
|
+
`wandb` and `trackio` are optional mirrors, not requirements.
|
|
33
|
+
|
|
34
|
+
📖 **Documentation: <https://hspk.github.io/expr_tracker/>**
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import expr_tracker as et
|
|
38
|
+
|
|
39
|
+
et.init(project="demo", name="run-1", alert_rules=["zscore(loss[50]) > 3 => error: spike"])
|
|
40
|
+
for step in range(1000):
|
|
41
|
+
et.log({"loss": loss, "lr": lr})
|
|
42
|
+
et.finish()
|
|
43
|
+
|
|
44
|
+
et.history(50) # the last 50 steps, as dicts
|
|
45
|
+
et.history(-1, output_type="pd") # everything, as a DataFrame
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
- **The file is the source of truth.** One JSON object per step, appended to
|
|
51
|
+
`metrics.jsonl`. No server, no database, no vendor.
|
|
52
|
+
- **History is queryable during the run.** `et.history(n)` answers from an in-memory
|
|
53
|
+
cache and touches the file only for what it evicted — 227 µs for
|
|
54
|
+
`history(50)` whether the run has 1,000 steps or 100,000.
|
|
55
|
+
- **Alerts are expressions, not callbacks.** `zscore(loss[50]) > 3 or isnan(loss)`
|
|
56
|
+
is parsed, validated, and evaluated against a rolling window. Rules can be replayed
|
|
57
|
+
over a finished run to tune thresholds before you trust them.
|
|
58
|
+
- **It stays out of the way.** `log()` costs ~26 µs. A failed disk, a dead
|
|
59
|
+
webhook or an unserialisable value degrades with a warning; none can stop training.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv add expr_tracker # local-first: click, loguru, pydantic only
|
|
65
|
+
uv add "expr_tracker[wandb]" # mirror to Weights & Biases
|
|
66
|
+
uv add "expr_tracker[trackio]" # mirror to trackio
|
|
67
|
+
uv add "expr_tracker[lark]" # Feishu/Lark alert channel
|
|
68
|
+
uv add "expr_tracker[pandas]" # history(output_type="pandas")
|
|
69
|
+
uv add "expr_tracker[all]" # everything
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Only the JSONL history is built in. A missing extra is reported with the exact
|
|
73
|
+
install command; it never crashes a run.
|
|
74
|
+
|
|
75
|
+
## Features
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| [Logging](https://hspk.github.io/expr_tracker/guide/logging/) | One line per step. Several `log()` calls for one step merge into one row, wandb-compatible `step`/`commit` semantics, numpy and pydantic values handled. |
|
|
80
|
+
| [History](https://hspk.github.io/expr_tracker/guide/history/) | `et.history(n)` during or after the run, offline reads of any run directory, dict/pandas/polars output, bounded in-memory cache with observable hit rate. |
|
|
81
|
+
| [Alerts](https://hspk.github.io/expr_tracker/guide/alerts/) | An expression DSL with rolling windows, three-valued logic (no false alarms during warm-up), a rule state machine, and a watchdog that catches a hung run. |
|
|
82
|
+
| [Channels](https://hspk.github.io/expr_tracker/guide/alerts/#channels) | Lark, Slack, DingTalk, WeCom, generic webhook, email — with rate limiting, dedup, retries and per-channel routing. |
|
|
83
|
+
| [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
|
|
84
|
+
| [Distributed](https://hspk.github.io/expr_tracker/guide/distributed/) | Per-rank shards so concurrent appends cannot corrupt step order; only rank 0 alerts by default. |
|
|
85
|
+
| [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
|
|
86
|
+
|
|
87
|
+
## wandb compatibility
|
|
88
|
+
|
|
89
|
+
Migrating an existing script is usually one line:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# import wandb as et
|
|
93
|
+
import expr_tracker as et
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`init`, `log`, `finish`, `alert`, `log_artifact`, `use_artifact`, `Artifact`,
|
|
97
|
+
`define_metric`, `run.summary`, `run.step`, `run.dir` and `run.url` keep their wandb
|
|
98
|
+
names and signatures. See the
|
|
99
|
+
[compatibility table](https://hspk.github.io/expr_tracker/guide/backends/#wandb-compatibility).
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv sync --all-extras
|
|
105
|
+
uv run pytest # everything
|
|
106
|
+
uv run pytest -m "not slow and not benchmark" # the fast suite
|
|
107
|
+
uv run pytest -m benchmark -s # timing and memory report
|
|
108
|
+
uv run pytest --cov=expr_tracker # coverage
|
|
109
|
+
uv run ruff check src tests
|
|
110
|
+
uv run ruff format src tests
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Test layout
|
|
114
|
+
|
|
115
|
+
| File | Covers |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `test_history`, `test_expr_*`, `test_alert_*`, `test_writer_durability`, … | per-module unit tests |
|
|
118
|
+
| `test_correctness.py` | value and type round trips, randomised commit sequences, ordering invariants |
|
|
119
|
+
| `test_cache.py` | that the cache really serves reads: zero-IO assertions, eviction boundaries, warm/cold parity |
|
|
120
|
+
| `test_failure_modes.py` | degradation: write failures, read-only dirs, encoder blow-ups, dead alert backends |
|
|
121
|
+
| `test_e2e.py` | full runs, resume, crash recovery, offline reads, CLI |
|
|
122
|
+
| `test_scenarios.py` | live cross-process reads, alerts during eviction, out-of-order resume |
|
|
123
|
+
| `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
|
|
124
|
+
| `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
|
|
125
|
+
| `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
|
|
126
|
+
| `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
|
|
127
|
+
| `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
|
|
128
|
+
| `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
|
|
129
|
+
| `test_lark_live.py` | Lark channel; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
|
|
130
|
+
| `test_stress.py` (`slow`) | 100k-row writes, concurrency, cache thrash, write-failure recovery |
|
|
131
|
+
| `test_benchmark.py` (`benchmark`) | throughput, tail latency, query cost, memory stability |
|
|
132
|
+
|
|
133
|
+
### Docs
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
uv run --group docs mkdocs serve # preview at localhost:8000
|
|
137
|
+
uv run --group docs mkdocs build # build into site/
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Published to GitHub Pages by `.github/workflows/docs.yaml` on every push to `main`.
|
|
141
|
+
Internals: [`docs/design.md`](docs/design.md) (data model and key invariants) and
|
|
142
|
+
[`docs/architecture.md`](docs/architecture.md) (module map, read/write paths,
|
|
143
|
+
concurrency model).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Experiment Tracker
|
|
2
|
+
|
|
3
|
+
A local-first experiment tracker. Metrics land in a JSONL file you own, stay
|
|
4
|
+
queryable while the run is live, and can trigger alerts from an expression language.
|
|
5
|
+
`wandb` and `trackio` are optional mirrors, not requirements.
|
|
6
|
+
|
|
7
|
+
📖 **Documentation: <https://hspk.github.io/expr_tracker/>**
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import expr_tracker as et
|
|
11
|
+
|
|
12
|
+
et.init(project="demo", name="run-1", alert_rules=["zscore(loss[50]) > 3 => error: spike"])
|
|
13
|
+
for step in range(1000):
|
|
14
|
+
et.log({"loss": loss, "lr": lr})
|
|
15
|
+
et.finish()
|
|
16
|
+
|
|
17
|
+
et.history(50) # the last 50 steps, as dicts
|
|
18
|
+
et.history(-1, output_type="pd") # everything, as a DataFrame
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
- **The file is the source of truth.** One JSON object per step, appended to
|
|
24
|
+
`metrics.jsonl`. No server, no database, no vendor.
|
|
25
|
+
- **History is queryable during the run.** `et.history(n)` answers from an in-memory
|
|
26
|
+
cache and touches the file only for what it evicted — 227 µs for
|
|
27
|
+
`history(50)` whether the run has 1,000 steps or 100,000.
|
|
28
|
+
- **Alerts are expressions, not callbacks.** `zscore(loss[50]) > 3 or isnan(loss)`
|
|
29
|
+
is parsed, validated, and evaluated against a rolling window. Rules can be replayed
|
|
30
|
+
over a finished run to tune thresholds before you trust them.
|
|
31
|
+
- **It stays out of the way.** `log()` costs ~26 µs. A failed disk, a dead
|
|
32
|
+
webhook or an unserialisable value degrades with a warning; none can stop training.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add expr_tracker # local-first: click, loguru, pydantic only
|
|
38
|
+
uv add "expr_tracker[wandb]" # mirror to Weights & Biases
|
|
39
|
+
uv add "expr_tracker[trackio]" # mirror to trackio
|
|
40
|
+
uv add "expr_tracker[lark]" # Feishu/Lark alert channel
|
|
41
|
+
uv add "expr_tracker[pandas]" # history(output_type="pandas")
|
|
42
|
+
uv add "expr_tracker[all]" # everything
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Only the JSONL history is built in. A missing extra is reported with the exact
|
|
46
|
+
install command; it never crashes a run.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
| | |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| [Logging](https://hspk.github.io/expr_tracker/guide/logging/) | One line per step. Several `log()` calls for one step merge into one row, wandb-compatible `step`/`commit` semantics, numpy and pydantic values handled. |
|
|
53
|
+
| [History](https://hspk.github.io/expr_tracker/guide/history/) | `et.history(n)` during or after the run, offline reads of any run directory, dict/pandas/polars output, bounded in-memory cache with observable hit rate. |
|
|
54
|
+
| [Alerts](https://hspk.github.io/expr_tracker/guide/alerts/) | An expression DSL with rolling windows, three-valued logic (no false alarms during warm-up), a rule state machine, and a watchdog that catches a hung run. |
|
|
55
|
+
| [Channels](https://hspk.github.io/expr_tracker/guide/alerts/#channels) | Lark, Slack, DingTalk, WeCom, generic webhook, email — with rate limiting, dedup, retries and per-channel routing. |
|
|
56
|
+
| [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
|
|
57
|
+
| [Distributed](https://hspk.github.io/expr_tracker/guide/distributed/) | Per-rank shards so concurrent appends cannot corrupt step order; only rank 0 alerts by default. |
|
|
58
|
+
| [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
|
|
59
|
+
|
|
60
|
+
## wandb compatibility
|
|
61
|
+
|
|
62
|
+
Migrating an existing script is usually one line:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# import wandb as et
|
|
66
|
+
import expr_tracker as et
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`init`, `log`, `finish`, `alert`, `log_artifact`, `use_artifact`, `Artifact`,
|
|
70
|
+
`define_metric`, `run.summary`, `run.step`, `run.dir` and `run.url` keep their wandb
|
|
71
|
+
names and signatures. See the
|
|
72
|
+
[compatibility table](https://hspk.github.io/expr_tracker/guide/backends/#wandb-compatibility).
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv sync --all-extras
|
|
78
|
+
uv run pytest # everything
|
|
79
|
+
uv run pytest -m "not slow and not benchmark" # the fast suite
|
|
80
|
+
uv run pytest -m benchmark -s # timing and memory report
|
|
81
|
+
uv run pytest --cov=expr_tracker # coverage
|
|
82
|
+
uv run ruff check src tests
|
|
83
|
+
uv run ruff format src tests
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Test layout
|
|
87
|
+
|
|
88
|
+
| File | Covers |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `test_history`, `test_expr_*`, `test_alert_*`, `test_writer_durability`, … | per-module unit tests |
|
|
91
|
+
| `test_correctness.py` | value and type round trips, randomised commit sequences, ordering invariants |
|
|
92
|
+
| `test_cache.py` | that the cache really serves reads: zero-IO assertions, eviction boundaries, warm/cold parity |
|
|
93
|
+
| `test_failure_modes.py` | degradation: write failures, read-only dirs, encoder blow-ups, dead alert backends |
|
|
94
|
+
| `test_e2e.py` | full runs, resume, crash recovery, offline reads, CLI |
|
|
95
|
+
| `test_scenarios.py` | live cross-process reads, alerts during eviction, out-of-order resume |
|
|
96
|
+
| `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
|
|
97
|
+
| `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
|
|
98
|
+
| `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
|
|
99
|
+
| `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
|
|
100
|
+
| `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
|
|
101
|
+
| `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
|
|
102
|
+
| `test_lark_live.py` | Lark channel; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
|
|
103
|
+
| `test_stress.py` (`slow`) | 100k-row writes, concurrency, cache thrash, write-failure recovery |
|
|
104
|
+
| `test_benchmark.py` (`benchmark`) | throughput, tail latency, query cost, memory stability |
|
|
105
|
+
|
|
106
|
+
### Docs
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
uv run --group docs mkdocs serve # preview at localhost:8000
|
|
110
|
+
uv run --group docs mkdocs build # build into site/
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Published to GitHub Pages by `.github/workflows/docs.yaml` on every push to `main`.
|
|
114
|
+
Internals: [`docs/design.md`](docs/design.md) (data model and key invariants) and
|
|
115
|
+
[`docs/architecture.md`](docs/architecture.md) (module map, read/write paths,
|
|
116
|
+
concurrency model).
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How the pieces fit together, and which module owns what. Pair this with
|
|
4
|
+
[`design.md`](design.md), which records *why* the data model looks the way it does.
|
|
5
|
+
|
|
6
|
+
## Module map
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
expr_tracker/
|
|
10
|
+
├── __init__.py public names re-exported for `import expr_tracker as et`
|
|
11
|
+
├── tracker.py the functional API (init/log/history/finish/artifacts/summary)
|
|
12
|
+
├── run.py Run object + global singleton + backend fan-out
|
|
13
|
+
├── summary.py run.summary mapping, persisted as summary.json
|
|
14
|
+
├── artifacts.py Artifact + project-scoped ArtifactStore
|
|
15
|
+
├── encoders.py JSON coercion (numpy/torch/pydantic/dataclasses/...)
|
|
16
|
+
├── cli.py `et history` / `et rules` / `et alert`
|
|
17
|
+
├── history/
|
|
18
|
+
│ ├── store.py HistoryStore: open-row assembly, cache, query planning
|
|
19
|
+
│ ├── writer.py JsonlWriter: buffered append, sparse index, meta sidecar
|
|
20
|
+
│ ├── reader.py JsonlReader + the offline read_history entry points
|
|
21
|
+
│ ├── codec.py RecordCodec: metric values -> JSON lines, warn-once
|
|
22
|
+
│ ├── series.py MetricSeries: per-metric numeric buffers
|
|
23
|
+
│ └── frame.py projection + dict/pandas/polars output
|
|
24
|
+
└── alerts/
|
|
25
|
+
├── __init__.py public alert API + config resolution + engine wiring
|
|
26
|
+
├── models.py AlertLevel/Message/ChannelConfig/WebhookPolicy/AlertRule
|
|
27
|
+
├── engine.py rule compilation, state machine, watchdog
|
|
28
|
+
├── dispatch.py routing, rate limiting, dedup, retry, async worker
|
|
29
|
+
├── backends/ lark, slack, dingtalk, wecom, webhook, email, callable
|
|
30
|
+
└── expr/ lexer, parser, AST nodes + builder, functions, evaluator
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Dependency direction
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
tracker.py ──> run.py ──> history/ (always)
|
|
37
|
+
└──> alerts/ (lazily, to avoid an import cycle)
|
|
38
|
+
└──> artifacts.py, summary.py
|
|
39
|
+
alerts/expr ──> history/series.py (read-only: evaluation needs metric windows)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
|
|
44
|
+
* `history/` never imports from `alerts/`. The alert engine reads `MetricSeries`,
|
|
45
|
+
which lives in `history/` because it is populated on the write path.
|
|
46
|
+
* `alerts/` never imports `run` at module scope; `alerts/__init__` imports it inside
|
|
47
|
+
functions so `run.py` can import `alerts` lazily without a cycle.
|
|
48
|
+
* `expr/` is self-contained apart from `MetricSeries`, so the DSL can be parsed,
|
|
49
|
+
validated and replayed without a live run (this is what `et rules test` uses).
|
|
50
|
+
|
|
51
|
+
## Write path
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
et.log(data, step, commit)
|
|
55
|
+
│
|
|
56
|
+
▼
|
|
57
|
+
Run.log ──> Summary.observe(data) # last value per metric
|
|
58
|
+
└──> HistoryStore.log
|
|
59
|
+
│ encode once (jsonable_encoder)
|
|
60
|
+
│ merge into the open row for the current step
|
|
61
|
+
▼ commit when the step advances / commit=True / finish / timeout
|
|
62
|
+
HistoryStore._emit
|
|
63
|
+
├── _store_row(record, line) (holds the store lock)
|
|
64
|
+
│ ├── cache.append((row, step, line)) # ordinal allocated here
|
|
65
|
+
│ ├── MetricSeries.add(...) # feeds alert evaluation
|
|
66
|
+
│ └── JsonlWriter.enqueue(step, row, line)
|
|
67
|
+
├── writer.flush() / schedule_timer (outside the lock)
|
|
68
|
+
├── _evict()
|
|
69
|
+
└── _notify(record) # screen + alert engine
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
on_commit ──> AlertEngine.on_step(record) ──> Dispatcher (async)
|
|
73
|
+
└──> other backends (wandb, trackio, custom)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The ordinal allocation and the writer enqueue happen under one lock so that
|
|
77
|
+
row ordinal *N* is always physical line *N*; the query planner depends on it.
|
|
78
|
+
|
|
79
|
+
`HistoryStore.log` returns the `_step` the metrics landed on, or `None` when the call
|
|
80
|
+
was rejected. `Run.log` forwards that resolved step (and the resolved commit flag) to
|
|
81
|
+
the remote backends, so a backend's row layout matches the local history instead of
|
|
82
|
+
drifting on its own counter — two `log()` calls for one step stay one step everywhere.
|
|
83
|
+
Test the result with `is None`, since step 0 is falsy.
|
|
84
|
+
|
|
85
|
+
`log()` never branches on the open row directly: `_switch_open_row(step)` points it
|
|
86
|
+
at the requested step (or reuses it when no step is given) and hands back whatever row
|
|
87
|
+
that displaced. `JsonlWriter._track_line` is likewise the single place that folds a line
|
|
88
|
+
into the counters and index, shared by live appends and by resume rescans — so the
|
|
89
|
+
two can never drift apart.
|
|
90
|
+
|
|
91
|
+
## Read path
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
et.history(n, ...)
|
|
95
|
+
│
|
|
96
|
+
▼
|
|
97
|
+
HistoryStore.get ──> _collect ──> _collect_tail | _collect_range
|
|
98
|
+
│ │ │
|
|
99
|
+
│ │ ├─ _view_* one lock: cache rows + boundary
|
|
100
|
+
│ │ └─ _older_* only when the view is incomplete
|
|
101
|
+
│ ├─ _open_rows the uncommitted row, if wanted and in range
|
|
102
|
+
│ └─ _take_steps merge by step, keep the newest n
|
|
103
|
+
▼
|
|
104
|
+
frame.project(...) ──> frame.to_output(dict | pandas | polars)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Both query kinds share one shape: take a `_CacheView` under a single lock, and touch
|
|
108
|
+
the disk only when the view reports it cannot answer on its own.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
view = self._view_tail(steps) # or _view_range(step_range)
|
|
112
|
+
records = view.records()
|
|
113
|
+
if view.complete:
|
|
114
|
+
return records
|
|
115
|
+
return self._older_tail(view, steps, records) + records
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`_CacheView.complete` folds together the three reasons the disk can be skipped: the
|
|
119
|
+
cache holds an older step, nothing was ever evicted, or there is no writer. The
|
|
120
|
+
scans themselves (`_scan_tail`, `_scan_range`, `_nearer_front`, `_newest_steps`) are
|
|
121
|
+
plain functions over a sequence, so they hold no lock and are tested directly.
|
|
122
|
+
|
|
123
|
+
`et.history(run=...)` bypasses the store entirely and reads through `JsonlReader`
|
|
124
|
+
(`read_history`), which is what makes offline analysis and `et rules test` possible.
|
|
125
|
+
|
|
126
|
+
### Rows versus steps
|
|
127
|
+
|
|
128
|
+
A step normally occupies one physical row, but a `max_open_seconds` timeout followed
|
|
129
|
+
by more data for that step writes a *patch line*, so one step can span several rows.
|
|
130
|
+
The two are kept strictly separate in the read path:
|
|
131
|
+
|
|
132
|
+
* `JsonlReader.tail_rows(n)` returns **physical rows**; `JsonlReader.tail(n)` returns
|
|
133
|
+
**merged steps** and widens its own read until it has one whole step to spare.
|
|
134
|
+
* `HistoryStore._collect_tail(steps)` works in rows, then `_take_steps` merges and
|
|
135
|
+
trims by *step*, so `get(n)` never returns a half-merged oldest row.
|
|
136
|
+
* The cache stores each row's step next to its bytes, so `_view_tail` snapshots the
|
|
137
|
+
exact rows covering *n* steps without parsing any JSON, and stops as soon as one
|
|
138
|
+
older step proves nothing is truncated.
|
|
139
|
+
* `parse_rows` drops lines that are corrupt or carry no integer `_step`, so every
|
|
140
|
+
record a reader returns can be ordered and merged by step.
|
|
141
|
+
|
|
142
|
+
### Query cost
|
|
143
|
+
|
|
144
|
+
Queries never scan the whole cache:
|
|
145
|
+
|
|
146
|
+
| query | cost |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `history(n)` | O(rows returned), independent of cache size |
|
|
149
|
+
| `history(step_range=...)` | O(distance from the nearer end of the cache) |
|
|
150
|
+
| `history(-1)` | O(run length) — it has to materialise everything |
|
|
151
|
+
|
|
152
|
+
Disk fallback only happens once rows have been evicted or the run was resumed;
|
|
153
|
+
`stats()["disk_prefix"]` reports whether that is the case.
|
|
154
|
+
|
|
155
|
+
## Concurrency model
|
|
156
|
+
|
|
157
|
+
| Lock / thread | Owner | Protects |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `HistoryStore._lock` (RLock) | store | open row, cache, series, row ordinal, writer enqueue |
|
|
160
|
+
| `JsonlWriter._lock` (RLock) | writer | buffer, index, meta fields |
|
|
161
|
+
| `JsonlWriter._write_lock` | writer | batch swap + file append (ordering) |
|
|
162
|
+
| open-row `threading.Timer` | store | commits a stale open row; carries a generation token |
|
|
163
|
+
| buffer `threading.Timer` | writer | flushes records that sat in memory too long |
|
|
164
|
+
| `CompiledRule.lock` | engine | one rule's state machine transitions |
|
|
165
|
+
| dispatch worker thread | dispatcher | drains the send queue; drained at exit |
|
|
166
|
+
| watchdog thread | engine | evaluates time-based rules when no logs arrive |
|
|
167
|
+
|
|
168
|
+
Lock order is always `_write_lock` → `_lock`; nothing acquires them the other way.
|
|
169
|
+
|
|
170
|
+
## Extension points
|
|
171
|
+
|
|
172
|
+
| I want to add... | Where it goes |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| a notification channel | subclass `AlertBackend`, call `register_backend("type", cls)` |
|
|
175
|
+
| an expression function | add to `expr/functions.py` (`WINDOW_FUNCS` / `SCALAR_FUNCS` / `SPECIAL_FUNCS`) |
|
|
176
|
+
| an output format | add a branch in `history/frame.to_output` |
|
|
177
|
+
| a metrics backend | pass any object with `init/log/finish` in `backends=[...]` |
|
|
178
|
+
| an artifact storage mode | extend `ArtifactStore._materialise` (default is `copy`: `link` shares the caller's inode) |
|
|
179
|
+
| a value type to encode | extend `history/codec.py` (and `encoders.py` for the coercion) |
|
|
180
|
+
| a history tunable | add a field to `HistoryOptions`; it is validated and documented automatically |
|
|
181
|
+
|
|
182
|
+
## Configuration
|
|
183
|
+
|
|
184
|
+
Every history tunable lives on `HistoryOptions`, a frozen dataclass validated once in
|
|
185
|
+
`HistoryStore.init()`. Unknown names raise `TypeError` listing the valid ones, so a
|
|
186
|
+
typo like `cache_byte=` fails loudly instead of silently keeping the default. Run
|
|
187
|
+
state is initialised in exactly one place, `HistoryStore._reset()`, which both
|
|
188
|
+
`__init__` and `init()` call — re-initialising cannot leave a field behind.
|
|
189
|
+
|
|
190
|
+
## Invariants
|
|
191
|
+
|
|
192
|
+
See [`design.md` §G](design.md). The short version: metadata is written last,
|
|
193
|
+
undurable rows are never evicted, the cache/disk boundary is addressed by row
|
|
194
|
+
ordinal, alerts are evaluated once per committed step, and expression evaluation
|
|
195
|
+
degrades to `UNKNOWN` instead of raising.
|
|
196
|
+
|
|
197
|
+
## Naming
|
|
198
|
+
|
|
199
|
+
The write path reads as one sentence, so the stages are named after what they do to
|
|
200
|
+
the open row:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
_accept_step → _switch_open_row → _update_open_row → _close_open_row → _emit
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Two words are reserved and mean exactly one thing each:
|
|
207
|
+
|
|
208
|
+
| word | meaning |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| **merge** | combining rows that share a `_step` (`merge_steps`, `_needs_merge`). Never used for folding metrics into a row — that is `_update_open_row`. |
|
|
211
|
+
| **row** | one physical JSONL line. A step may span several rows, so `tail_rows`/`parse_rows` return rows while `tail`/`parse` return merged steps. |
|
|
212
|
+
|
|
213
|
+
`stats()` distinguishes `rows_on_disk` (lines in the file) from `rows_logged`
|
|
214
|
+
(ordinals this process handed out); they differ only after records are dropped.
|