expr-tracker 0.1.8__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.
Files changed (102) hide show
  1. expr_tracker-0.2.0/.github/workflows/docs.yaml +53 -0
  2. {expr_tracker-0.1.8 → expr_tracker-0.2.0}/.gitignore +3 -0
  3. expr_tracker-0.2.0/PKG-INFO +143 -0
  4. expr_tracker-0.2.0/README.md +116 -0
  5. expr_tracker-0.2.0/docs/architecture.md +214 -0
  6. expr_tracker-0.2.0/docs/design.md +378 -0
  7. expr_tracker-0.2.0/docs/getting-started.md +96 -0
  8. expr_tracker-0.2.0/docs/guide/alerts.md +169 -0
  9. expr_tracker-0.2.0/docs/guide/artifacts.md +82 -0
  10. expr_tracker-0.2.0/docs/guide/backends.md +89 -0
  11. expr_tracker-0.2.0/docs/guide/cli.md +70 -0
  12. expr_tracker-0.2.0/docs/guide/distributed.md +68 -0
  13. expr_tracker-0.2.0/docs/guide/history.md +103 -0
  14. expr_tracker-0.2.0/docs/guide/logging.md +112 -0
  15. expr_tracker-0.2.0/docs/index.md +47 -0
  16. expr_tracker-0.2.0/docs/reference/api.md +207 -0
  17. expr_tracker-0.2.0/docs/reference/configuration.md +127 -0
  18. expr_tracker-0.2.0/docs/reference/expressions.md +139 -0
  19. expr_tracker-0.2.0/mkdocs.yml +66 -0
  20. expr_tracker-0.2.0/pyproject.toml +86 -0
  21. expr_tracker-0.2.0/src/expr_tracker/__init__.py +66 -0
  22. expr_tracker-0.2.0/src/expr_tracker/alerts/__init__.py +276 -0
  23. expr_tracker-0.2.0/src/expr_tracker/alerts/backends/__init__.py +270 -0
  24. expr_tracker-0.2.0/src/expr_tracker/alerts/backends/base.py +115 -0
  25. expr_tracker-0.2.0/src/expr_tracker/alerts/dispatch.py +317 -0
  26. expr_tracker-0.2.0/src/expr_tracker/alerts/engine.py +384 -0
  27. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/__init__.py +27 -0
  28. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/eval.py +358 -0
  29. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/functions.py +283 -0
  30. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/lexer.py +136 -0
  31. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/nodes.py +356 -0
  32. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/parser.py +195 -0
  33. expr_tracker-0.2.0/src/expr_tracker/alerts/expr/rule.py +127 -0
  34. expr_tracker-0.2.0/src/expr_tracker/alerts/models.py +357 -0
  35. expr_tracker-0.2.0/src/expr_tracker/artifacts.py +355 -0
  36. expr_tracker-0.2.0/src/expr_tracker/cli.py +193 -0
  37. {expr_tracker-0.1.8 → expr_tracker-0.2.0}/src/expr_tracker/encoders.py +5 -3
  38. expr_tracker-0.2.0/src/expr_tracker/history/__init__.py +21 -0
  39. expr_tracker-0.2.0/src/expr_tracker/history/codec.py +98 -0
  40. expr_tracker-0.2.0/src/expr_tracker/history/frame.py +91 -0
  41. expr_tracker-0.2.0/src/expr_tracker/history/reader.py +359 -0
  42. expr_tracker-0.2.0/src/expr_tracker/history/series.py +115 -0
  43. expr_tracker-0.2.0/src/expr_tracker/history/store.py +863 -0
  44. expr_tracker-0.2.0/src/expr_tracker/history/writer.py +406 -0
  45. expr_tracker-0.2.0/src/expr_tracker/run.py +431 -0
  46. expr_tracker-0.2.0/src/expr_tracker/summary.py +95 -0
  47. expr_tracker-0.2.0/src/expr_tracker/tracker.py +168 -0
  48. expr_tracker-0.2.0/src/expr_tracker/types.py +11 -0
  49. expr_tracker-0.2.0/tests/conftest.py +74 -0
  50. expr_tracker-0.2.0/tests/test_alert_backends.py +181 -0
  51. expr_tracker-0.2.0/tests/test_alert_delivery.py +493 -0
  52. expr_tracker-0.2.0/tests/test_alert_dispatch.py +317 -0
  53. expr_tracker-0.2.0/tests/test_alert_engine.py +184 -0
  54. expr_tracker-0.2.0/tests/test_alert_models.py +151 -0
  55. expr_tracker-0.2.0/tests/test_alert_routing.py +519 -0
  56. expr_tracker-0.2.0/tests/test_artifacts.py +350 -0
  57. expr_tracker-0.2.0/tests/test_benchmark.py +307 -0
  58. expr_tracker-0.2.0/tests/test_cache.py +368 -0
  59. expr_tracker-0.2.0/tests/test_cli.py +290 -0
  60. expr_tracker-0.2.0/tests/test_correctness.py +407 -0
  61. expr_tracker-0.2.0/tests/test_distributed.py +352 -0
  62. expr_tracker-0.2.0/tests/test_e2e.py +286 -0
  63. expr_tracker-0.2.0/tests/test_expr_builder.py +163 -0
  64. expr_tracker-0.2.0/tests/test_expr_eval.py +208 -0
  65. expr_tracker-0.2.0/tests/test_expr_functions.py +381 -0
  66. expr_tracker-0.2.0/tests/test_expr_parser.py +178 -0
  67. expr_tracker-0.2.0/tests/test_expr_properties.py +320 -0
  68. expr_tracker-0.2.0/tests/test_failure_modes.py +451 -0
  69. expr_tracker-0.2.0/tests/test_features.py +417 -0
  70. expr_tracker-0.2.0/tests/test_frame_codec_summary.py +210 -0
  71. expr_tracker-0.2.0/tests/test_history.py +481 -0
  72. expr_tracker-0.2.0/tests/test_hot_paths.py +348 -0
  73. expr_tracker-0.2.0/tests/test_integration.py +191 -0
  74. expr_tracker-0.2.0/tests/test_lark_live.py +269 -0
  75. expr_tracker-0.2.0/tests/test_perf.py +82 -0
  76. expr_tracker-0.2.0/tests/test_public_surfaces.py +400 -0
  77. expr_tracker-0.2.0/tests/test_review_regressions.py +498 -0
  78. expr_tracker-0.2.0/tests/test_rule_lifecycle.py +453 -0
  79. expr_tracker-0.2.0/tests/test_run_backends.py +352 -0
  80. expr_tracker-0.2.0/tests/test_scenarios.py +435 -0
  81. expr_tracker-0.2.0/tests/test_step_commit.py +140 -0
  82. expr_tracker-0.2.0/tests/test_stress.py +249 -0
  83. expr_tracker-0.2.0/tests/test_trackio.py +226 -0
  84. expr_tracker-0.2.0/tests/test_value_encoding.py +376 -0
  85. expr_tracker-0.2.0/tests/test_wandb.py +260 -0
  86. expr_tracker-0.2.0/tests/test_writer_buffer.py +261 -0
  87. expr_tracker-0.2.0/tests/test_writer_durability.py +306 -0
  88. expr_tracker-0.2.0/uv.lock +2807 -0
  89. expr_tracker-0.1.8/PKG-INFO +0 -67
  90. expr_tracker-0.1.8/README.md +0 -55
  91. expr_tracker-0.1.8/pyproject.toml +0 -26
  92. expr_tracker-0.1.8/src/expr_tracker/__init__.py +0 -9
  93. expr_tracker-0.1.8/src/expr_tracker/alert.py +0 -73
  94. expr_tracker-0.1.8/src/expr_tracker/cli.py +0 -15
  95. expr_tracker-0.1.8/src/expr_tracker/jsonl.py +0 -350
  96. expr_tracker-0.1.8/src/expr_tracker/tracker.py +0 -167
  97. expr_tracker-0.1.8/src/expr_tracker/types.py +0 -10
  98. expr_tracker-0.1.8/tests/test_jsonl_buffer.py +0 -217
  99. expr_tracker-0.1.8/tests/test_tracker.py +0 -14
  100. expr_tracker-0.1.8/uv.lock +0 -1425
  101. {expr_tracker-0.1.8 → expr_tracker-0.2.0}/.github/workflows/release.yaml +0 -0
  102. {expr_tracker-0.1.8 → 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
@@ -11,3 +11,6 @@ wandb/
11
11
  # Virtual environments
12
12
  .venv
13
13
  test.ipynb
14
+ .coverage
15
+ htmlcov/
16
+ site/
@@ -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&nbsp;µ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&nbsp;µ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&nbsp;µ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&nbsp;µ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.