expr-tracker 0.2.4__tar.gz → 0.2.5__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 (111) hide show
  1. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/.github/workflows/docs.yaml +1 -0
  2. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/PKG-INFO +22 -2
  3. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/README.md +21 -1
  4. expr_tracker-0.2.5/docs/examples.md +10 -0
  5. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/getting-started.md +4 -0
  6. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/cli.md +2 -0
  7. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/distributed.md +25 -2
  8. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/history.md +8 -0
  9. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/streams.md +33 -0
  10. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/index.md +1 -0
  11. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/reference/api.md +10 -0
  12. expr_tracker-0.2.5/examples/README.md +65 -0
  13. expr_tracker-0.2.5/examples/alert_rules.py +115 -0
  14. expr_tracker-0.2.5/examples/checkpoints.py +83 -0
  15. expr_tracker-0.2.5/examples/early_stopping.py +99 -0
  16. expr_tracker-0.2.5/examples/multiprocess_pipeline.py +205 -0
  17. expr_tracker-0.2.5/examples/profile_step.py +92 -0
  18. expr_tracker-0.2.5/examples/quickstart.py +90 -0
  19. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/mkdocs.yml +4 -0
  20. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/cli.py +14 -1
  21. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/run.py +20 -6
  22. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_cli.py +43 -0
  23. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_distributed.py +106 -0
  24. expr_tracker-0.2.5/tests/test_examples.py +383 -0
  25. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/.github/workflows/release.yaml +0 -0
  26. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/.gitignore +0 -0
  27. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/LICENSE +0 -0
  28. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/architecture.md +0 -0
  29. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/design.md +0 -0
  30. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/alerts.md +0 -0
  31. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/artifacts.md +0 -0
  32. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/backends.md +0 -0
  33. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/logging.md +0 -0
  34. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/guide/spans.md +0 -0
  35. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/reference/configuration.md +0 -0
  36. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/docs/reference/expressions.md +0 -0
  37. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/pyproject.toml +0 -0
  38. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/__init__.py +0 -0
  39. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/_compat.py +0 -0
  40. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/__init__.py +0 -0
  41. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  42. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/backends/base.py +0 -0
  43. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/dispatch.py +0 -0
  44. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/engine.py +0 -0
  45. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  46. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
  47. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/functions.py +0 -0
  48. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  49. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  50. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
  51. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
  52. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/alerts/models.py +0 -0
  53. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/artifacts.py +0 -0
  54. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/encoders.py +0 -0
  55. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/__init__.py +0 -0
  56. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/codec.py +0 -0
  57. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/frame.py +0 -0
  58. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/naming.py +0 -0
  59. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/reader.py +0 -0
  60. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/series.py +0 -0
  61. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/store.py +0 -0
  62. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/history/writer.py +0 -0
  63. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/plugins.py +0 -0
  64. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/py.typed +0 -0
  65. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/spans.py +0 -0
  66. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/summary.py +0 -0
  67. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/trace.py +0 -0
  68. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/tracker.py +0 -0
  69. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/src/expr_tracker/types.py +0 -0
  70. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/conftest.py +0 -0
  71. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_backends.py +0 -0
  72. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_delivery.py +0 -0
  73. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_dispatch.py +0 -0
  74. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_engine.py +0 -0
  75. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_models.py +0 -0
  76. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_alert_routing.py +0 -0
  77. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_artifacts.py +0 -0
  78. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_benchmark.py +0 -0
  79. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_cache.py +0 -0
  80. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_correctness.py +0 -0
  81. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_e2e.py +0 -0
  82. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_expr_builder.py +0 -0
  83. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_expr_eval.py +0 -0
  84. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_expr_functions.py +0 -0
  85. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_expr_parser.py +0 -0
  86. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_expr_properties.py +0 -0
  87. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_failure_modes.py +0 -0
  88. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_features.py +0 -0
  89. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_frame_codec_summary.py +0 -0
  90. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_history.py +0 -0
  91. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_hot_paths.py +0 -0
  92. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_integration.py +0 -0
  93. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_lark_live.py +0 -0
  94. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_perf.py +0 -0
  95. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_public_surfaces.py +0 -0
  96. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_review_regressions.py +0 -0
  97. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_rule_lifecycle.py +0 -0
  98. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_run_backends.py +0 -0
  99. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_scenarios.py +0 -0
  100. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_span_plugins.py +0 -0
  101. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_spans.py +0 -0
  102. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_step_commit.py +0 -0
  103. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_streams.py +0 -0
  104. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_stress.py +0 -0
  105. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_trace.py +0 -0
  106. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_trackio.py +0 -0
  107. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_value_encoding.py +0 -0
  108. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_wandb.py +0 -0
  109. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_writer_buffer.py +0 -0
  110. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/tests/test_writer_durability.py +0 -0
  111. {expr_tracker-0.2.4 → expr_tracker-0.2.5}/uv.lock +0 -0
@@ -5,6 +5,7 @@ on:
5
5
  branches: [main]
6
6
  paths:
7
7
  - "docs/**"
8
+ - "examples/**" # docs/examples.md includes examples/README.md
8
9
  - "mkdocs.yml"
9
10
  - ".github/workflows/docs.yaml"
10
11
  workflow_dispatch:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
5
5
  Project-URL: Homepage, https://hspk.github.io/expr_tracker/
6
6
  Project-URL: Documentation, https://hspk.github.io/expr_tracker/
@@ -112,7 +112,26 @@ install command; it never crashes a run.
112
112
  | [Spans](https://hspk.github.io/expr_tracker/guide/spans/) | Time the parts of a step, and their parts. Each duration becomes a metric, so alerts and queries work on it unchanged; `et trace` exports the timeline for Perfetto. `print_fn` prints the tree live, and plugins attach CPU and GPU cost to each region. |
113
113
  | [Streams](https://hspk.github.io/expr_tracker/guide/streams/) | Independent producers — a data worker and a training loop — each with their own step cursor and file inside one run. |
114
114
  | [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. |
115
- | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
115
+ | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et trace`, `et rules explain`, `et rules test`, `et alert`. |
116
+
117
+ ## Examples
118
+
119
+ Every one runs offline, with no account and no network.
120
+
121
+ | | |
122
+ | --- | --- |
123
+ | [`quickstart.py`](examples/quickstart.py) | The sixty-second tour: log a run, merge eval into the training step, read the history back while it is open and again afterwards. |
124
+ | [`alert_rules.py`](examples/alert_rules.py) | Four rules against four faults — a loss spike, a non-finite loss, a stalled curve and a regression that must persist. `--fault none` fires nothing, which is the point. |
125
+ | [`profile_step.py`](examples/profile_step.py) | Where a step goes. Nested spans become metrics, a plugin adds CPU cost, and the tree exports to Perfetto. |
126
+ | [`early_stopping.py`](examples/early_stopping.py) | Querying your own history mid-run to decay the learning rate on a plateau and stop when it stops paying. |
127
+ | [`checkpoints.py`](examples/checkpoints.py) | Checkpoints as versioned artifacts, deduplicated by content and fetched later by alias. |
128
+ | [`multiprocess_pipeline.py`](examples/multiprocess_pipeline.py) | Four data producers and four trainers as eight processes in one run, with bounded staleness. Each worker gets its own stream and its own lane in the trace, and the blocking spans show which side is the bottleneck. |
129
+
130
+ ```bash
131
+ uv run python examples/quickstart.py
132
+ uv run python examples/alert_rules.py --fault spike
133
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
134
+ ```
116
135
 
117
136
  ## wandb compatibility
118
137
 
@@ -155,6 +174,7 @@ uv run ruff format src tests
155
174
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
156
175
  | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
157
176
  | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
177
+ | `test_examples.py` | the shipped examples run, and their backpressure claims hold |
158
178
  | `test_span_plugins.py` | `print_fn` output and indentation, the plugin protocol, failure isolation, CPU/GPU built-ins |
159
179
  | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
160
180
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
@@ -62,7 +62,26 @@ install command; it never crashes a run.
62
62
  | [Spans](https://hspk.github.io/expr_tracker/guide/spans/) | Time the parts of a step, and their parts. Each duration becomes a metric, so alerts and queries work on it unchanged; `et trace` exports the timeline for Perfetto. `print_fn` prints the tree live, and plugins attach CPU and GPU cost to each region. |
63
63
  | [Streams](https://hspk.github.io/expr_tracker/guide/streams/) | Independent producers — a data worker and a training loop — each with their own step cursor and file inside one run. |
64
64
  | [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. |
65
- | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
65
+ | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et trace`, `et rules explain`, `et rules test`, `et alert`. |
66
+
67
+ ## Examples
68
+
69
+ Every one runs offline, with no account and no network.
70
+
71
+ | | |
72
+ | --- | --- |
73
+ | [`quickstart.py`](examples/quickstart.py) | The sixty-second tour: log a run, merge eval into the training step, read the history back while it is open and again afterwards. |
74
+ | [`alert_rules.py`](examples/alert_rules.py) | Four rules against four faults — a loss spike, a non-finite loss, a stalled curve and a regression that must persist. `--fault none` fires nothing, which is the point. |
75
+ | [`profile_step.py`](examples/profile_step.py) | Where a step goes. Nested spans become metrics, a plugin adds CPU cost, and the tree exports to Perfetto. |
76
+ | [`early_stopping.py`](examples/early_stopping.py) | Querying your own history mid-run to decay the learning rate on a plateau and stop when it stops paying. |
77
+ | [`checkpoints.py`](examples/checkpoints.py) | Checkpoints as versioned artifacts, deduplicated by content and fetched later by alias. |
78
+ | [`multiprocess_pipeline.py`](examples/multiprocess_pipeline.py) | Four data producers and four trainers as eight processes in one run, with bounded staleness. Each worker gets its own stream and its own lane in the trace, and the blocking spans show which side is the bottleneck. |
79
+
80
+ ```bash
81
+ uv run python examples/quickstart.py
82
+ uv run python examples/alert_rules.py --fault spike
83
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
84
+ ```
66
85
 
67
86
  ## wandb compatibility
68
87
 
@@ -105,6 +124,7 @@ uv run ruff format src tests
105
124
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
106
125
  | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
107
126
  | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
127
+ | `test_examples.py` | the shipped examples run, and their backpressure claims hold |
108
128
  | `test_span_plugins.py` | `print_fn` output and indentation, the plugin protocol, failure isolation, CPU/GPU built-ins |
109
129
  | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
110
130
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Examples
3
+ ---
4
+
5
+ <!-- Single source: examples/README.md, so the two cannot drift apart. -->
6
+
7
+ --8<-- "examples/README.md"
8
+
9
+ The source lives in
10
+ [`examples/`](https://github.com/HSPK/expr_tracker/tree/main/examples).
@@ -85,12 +85,16 @@ et.get_run().step # 1000 - the cursor picked up where it left off
85
85
  len(et.history(-1)) # 1000 - the old rows are still there
86
86
  ```
87
87
 
88
+ This is unconditional: `resume` is passed on to wandb and trackio, but the local
89
+ file always continues. Use a different name to start clean.
90
+
88
91
  A process that dies without `finish()` still leaves a complete, valid file: the open
89
92
  row is committed and the summary saved by an exit hook, and a torn trailing line is
90
93
  repaired on the next run.
91
94
 
92
95
  ## Next
93
96
 
97
+ - [Examples](examples.md) — six runnable programs, all offline.
94
98
  - [Logging metrics](guide/logging.md) for commit semantics and out-of-order steps.
95
99
  - [Alerts](guide/alerts.md) to get notified instead of watching curves.
96
100
  - [CLI](guide/cli.md) to inspect runs without writing code.
@@ -11,6 +11,7 @@ et history tracker/jsonl/demo/run-1
11
11
  et history tracker/jsonl/demo/run-1 -n 50 --metrics loss,lr
12
12
  et history tracker/jsonl/demo/run-1 --step-range 100:200 --format json
13
13
  et history tracker/jsonl/demo/run-1 -n -1 --format csv > run.csv
14
+ et history runs/llm/sft-1 --stream data
14
15
  ```
15
16
 
16
17
  | Option | Default | Meaning |
@@ -18,6 +19,7 @@ et history tracker/jsonl/demo/run-1 -n -1 --format csv > run.csv
18
19
  | `-n` | 20 | number of steps, `-1` for all |
19
20
  | `--metrics` | all | comma-separated names |
20
21
  | `--step-range` | — | `start:end`, end exclusive; either side may be empty |
22
+ | `--stream` | the unnamed producer | which [stream](streams.md) to read |
21
23
  | `--format` | `table` | `table`, `json` or `csv` |
22
24
 
23
25
  The argument is a run directory or a `metrics.jsonl` file.
@@ -50,6 +50,29 @@ its rules, and reports them through `info()`:
50
50
  et.info()["rank"] # which rank this process was detected as
51
51
  ```
52
52
 
53
+ ## Remote backends
54
+
55
+ wandb and trackio identify a run by id, and neither can merge two step axes into
56
+ one run. If every rank opened the same id they would interleave their steps into
57
+ it — exactly what the local shards exist to prevent. So only rank 0 opens a
58
+ remote run by default:
59
+
60
+ ```python
61
+ et.init(..., backends=["wandb"]) # rank 0 reports (default)
62
+ et.init(..., backends=["wandb"], backend_on_rank=2) # rank 2 reports instead
63
+ et.init(..., backends=["wandb"], backend_on_rank=None) # every rank reports
64
+ ```
65
+
66
+ A silenced rank still writes its full local history; it simply never calls the
67
+ backend. When every rank does report, each gets its own run id and they are tied
68
+ together with `group`, which both backends understand:
69
+
70
+ | | id | group |
71
+ | --- | --- | --- |
72
+ | rank 0 | `sft-1` | `sft-1` |
73
+ | rank 2 | `sft-1-rank2` | `sft-1` |
74
+ | rank 2 of stream `data` | `sft-1-data-rank2` | `sft-1` |
75
+
53
76
  ## Typical setup
54
77
 
55
78
  ```python
@@ -64,5 +87,5 @@ et.init(
64
87
  )
65
88
  ```
66
89
 
67
- Every rank writes to `/shared/runs/llm/sft-<id>/`, each into its own shard, and only
68
- rank 0 pages you.
90
+ Every rank writes to `/shared/runs/llm/sft-<id>/`, each into its own shard. Only
91
+ rank 0 pages you, and only rank 0 opens the wandb run.
@@ -30,6 +30,14 @@ et.history(-1, metrics=["eval/acc"], dropna=True) # drop steps without it
30
30
  `dropna` drops a row when **all** selected metrics are missing, which is what you
31
31
  want for sparse eval metrics logged every N steps.
32
32
 
33
+ `n` selects rows first and `dropna` filters them afterwards. Asking for the last
34
+ 4 rows of a metric logged every 10 steps therefore finds nothing — take every
35
+ eval point and slice:
36
+
37
+ ```python
38
+ et.history(-1, metrics=["eval/acc"], dropna=True)[-4:] # the last 4 evals
39
+ ```
40
+
33
41
  ## Output types
34
42
 
35
43
  ```python
@@ -57,6 +57,39 @@ from expr_tracker.history import list_streams
57
57
  list_streams("runs/llm/sft-1") # [None, "data"]
58
58
  ```
59
59
 
60
+ ```bash
61
+ et history runs/llm/sft-1 --stream data
62
+ et trace runs/llm/sft-1 # every stream, one lane each
63
+ ```
64
+
65
+ ## A worked example
66
+
67
+ `examples/multiprocess_pipeline.py` runs four data producers and four trainers as
68
+ eight processes sharing one run, with a queue that lets a producer run at most
69
+ `--staleness` batches ahead of the trainers. Each worker writes its own stream,
70
+ so the exported trace gives each one a lane and the blocking spans show which
71
+ side is the bottleneck:
72
+
73
+ ```bash
74
+ # producers faster than trainers: they stall on a full queue
75
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
76
+
77
+ # trainers faster than producers: they starve waiting for batches
78
+ uv run python examples/multiprocess_pipeline.py --produce-ms 40 --train-ms 10
79
+ ```
80
+
81
+ ```
82
+ producers 0.38s in produce
83
+ read 0.15s 37.9%
84
+ enqueue 0.13s 35.0% <- backpressure
85
+ decode 0.09s 24.5%
86
+
87
+ trainers 1.05s in step
88
+ backward 0.59s 55.9%
89
+ forward 0.40s 38.1%
90
+ wait_for_batch 0.05s 4.9% <- starvation
91
+ ```
92
+
60
93
  ## Alerts
61
94
 
62
95
  Each process alerts on what it can see, which is its own stream. That is usually
@@ -42,6 +42,7 @@ uv add "expr_tracker[all]" # + wandb, trackio, lark, pandas, polars
42
42
  ## Next
43
43
 
44
44
  - [Getting started](getting-started.md) — a complete run, end to end.
45
+ - [Examples](examples.md) — six runnable programs, all offline.
45
46
  - [Logging metrics](guide/logging.md) — commit semantics and the step model.
46
47
  - [Alerts](guide/alerts.md) — rules, channels and delivery policy.
47
48
  - [Design](design.md) — the data model and the invariants behind it.
@@ -26,6 +26,8 @@ et.init(
26
26
  stream: str | None = None,
27
27
  alert=None,
28
28
  alert_rules: Sequence = (),
29
+ alert_on_rank: int | None = 0,
30
+ backend_on_rank: int | None = 0,
29
31
  **history_options,
30
32
  ) -> Run
31
33
  ```
@@ -34,6 +36,14 @@ Starts a run and publishes it as the process-wide current run. `name` defaults t
34
36
  timestamp. `dir` defaults to `./tracker/jsonl`. Extra keyword arguments are
35
37
  [history options](configuration.md#history-options).
36
38
 
39
+ `resume` is forwarded to wandb and trackio. The local history always continues an
40
+ existing run directory, so re-running with the same project and name picks up the
41
+ step cursor whatever `resume` says; use a new name to start clean.
42
+
43
+ `alert_on_rank` and `backend_on_rank` pick which rank alerts and which opens the
44
+ remote backend run; `None` means every rank. See
45
+ [Distributed runs](../guide/distributed.md).
46
+
37
47
  Calling `init()` twice without `finish()` raises.
38
48
 
39
49
  ### `finish`
@@ -0,0 +1,65 @@
1
+ # Examples
2
+
3
+ Every example runs offline: no account, no network, no GPU required. They write
4
+ to `runs/` by default, which is gitignored — pass `--dir` to put them elsewhere.
5
+
6
+ ```bash
7
+ uv run python examples/quickstart.py
8
+ uv run python examples/<name>.py --help # every example takes arguments
9
+ ```
10
+
11
+ ## [`quickstart.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/quickstart.py)
12
+
13
+ The sixty-second tour. Logs a training run, merges a sparse eval metric into the
14
+ training step with `commit=False`, queries the history while the run is still
15
+ open, and reads it back afterwards from the directory.
16
+
17
+ ## [`alert_rules.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/alert_rules.py)
18
+
19
+ Four rules against four faults: a loss spike caught by z-score, a non-finite
20
+ loss, a curve that goes flat, and an accuracy regression that has to persist for
21
+ three steps before it counts.
22
+
23
+ ```bash
24
+ uv run python examples/alert_rules.py --fault spike # or nan, stall, none
25
+ ```
26
+
27
+ `--fault none` sends nothing. That is the interesting case: warm-up, missing
28
+ data and NaN all evaluate to UNKNOWN rather than False, so rules cannot cry wolf
29
+ before they have the evidence.
30
+
31
+ ## [`profile_step.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/profile_step.py)
32
+
33
+ Where a training step actually goes. Nested spans become metrics on the step's
34
+ row, so timings query and alert like any other metric; a plugin attaches CPU
35
+ cost; the tree exports to a Chrome Trace for Perfetto.
36
+
37
+ ```bash
38
+ uv run python examples/profile_step.py --print-spans
39
+ ```
40
+
41
+ ## [`early_stopping.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/early_stopping.py)
42
+
43
+ The loop reading its own history to decide what to do next: decay the learning
44
+ rate when the eval metric plateaus, and stop once decaying no longer pays. This
45
+ is what a local, queryable history buys you over shipping metrics out.
46
+
47
+ ## [`checkpoints.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/checkpoints.py)
48
+
49
+ Checkpoints as artifacts — versioned, deduplicated by content, aliased `best`,
50
+ and fetched by a later run that knows nothing about which run wrote them.
51
+
52
+ ## [`multiprocess_pipeline.py`](https://github.com/HSPK/expr_tracker/blob/main/examples/multiprocess_pipeline.py)
53
+
54
+ Four data producers and four trainers as eight processes sharing one run, with a
55
+ queue that lets a producer run at most `--staleness` batches ahead. Each worker
56
+ writes its own stream and gets its own lane in the trace, and the blocking spans
57
+ show which side is the bottleneck.
58
+
59
+ ```bash
60
+ # producers faster than trainers: they stall on a full queue
61
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
62
+
63
+ # trainers faster than producers: they starve waiting for batches
64
+ uv run python examples/multiprocess_pipeline.py --produce-ms 40 --train-ms 10
65
+ ```
@@ -0,0 +1,115 @@
1
+ """Alert rules that catch the four ways a run goes wrong.
2
+
3
+ Rules are expressions over a rolling window of your metrics, evaluated once per
4
+ committed step. This sends to a local handler so it runs offline; swap the
5
+ channel for lark/slack/email and nothing else changes.
6
+
7
+ uv run python examples/alert_rules.py
8
+ uv run python examples/alert_rules.py --fault stall
9
+
10
+ Faults: spike, nan, stall, none.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import math
17
+ import random
18
+ import time
19
+
20
+ import expr_tracker as et
21
+
22
+ RULES = [
23
+ # Never fires during warm-up: too few points evaluates to UNKNOWN, not False
24
+ "zscore(train/loss[30]) > 4 => error: loss spike {train/loss:.4f} @ step {step}",
25
+ "isnan(train/loss) => critical: loss went non-finite",
26
+ "stalled(train/loss[20]) => warning: loss flat for 20 steps",
27
+ # Must hold for 3 consecutive steps, so one noisy eval does not page anyone
28
+ {
29
+ "name": "accuracy_regression",
30
+ "condition": "eval/accuracy < 0.5",
31
+ "level": "warning",
32
+ "for_steps": 3,
33
+ "notify_recovery": True,
34
+ },
35
+ ]
36
+
37
+
38
+ def channel(sink):
39
+ """A channel that appends to a list. Synchronous, so output stays ordered."""
40
+ return {
41
+ "channels": [
42
+ {
43
+ "type": "callable",
44
+ "name": "local",
45
+ "options": {"handler": sink.append},
46
+ "policy": {"async_send": False, "dedup_window": 0},
47
+ }
48
+ ]
49
+ }
50
+
51
+
52
+ def losses(args):
53
+ """A loss curve with the requested fault injected into it."""
54
+ rng = random.Random(args.seed)
55
+ for step in range(args.steps):
56
+ loss = math.exp(-step / 60) + rng.uniform(0, 0.02)
57
+ if args.fault == "spike" and step == args.at:
58
+ loss *= 12 # a sudden jump the z-score will notice
59
+ elif args.fault == "nan" and step >= args.at:
60
+ loss = float("nan")
61
+ elif args.fault == "stall" and step >= args.at:
62
+ loss = 0.5 # exactly flat, which stalled() is looking for
63
+ yield step, loss
64
+
65
+
66
+ def main(argv=None) -> list:
67
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
68
+ parser.add_argument(
69
+ "--fault", default="spike", choices=["spike", "nan", "stall", "none"]
70
+ )
71
+ parser.add_argument("--steps", type=int, default=80)
72
+ parser.add_argument("--at", type=int, default=50, help="step to inject the fault")
73
+ parser.add_argument("--seed", type=int, default=0)
74
+ parser.add_argument("--dir", default="runs")
75
+ parser.add_argument("--name", default=None)
76
+ args = parser.parse_args(argv)
77
+ # A fresh run each time: resuming would leave the previous run's losses in
78
+ # the rolling window, and the jump back to a high loss reads as a spike
79
+ args.name = args.name or f"{args.fault}-{time.strftime('%H%M%S')}"
80
+
81
+ fired: list = []
82
+ et.init(
83
+ project="alerts",
84
+ name=args.name,
85
+ dir=args.dir,
86
+ backends=[],
87
+ alert=channel(fired),
88
+ alert_rules=RULES,
89
+ )
90
+
91
+ for step, loss in losses(args):
92
+ accuracy = 0.4 if args.fault == "stall" and step > args.at else 0.9
93
+ et.log({"eval/accuracy": accuracy}, commit=False)
94
+ et.log({"train/loss": loss})
95
+
96
+ print(f"fault: {args.fault}, {args.steps} steps")
97
+ print(f"\n{len(fired)} message(s):")
98
+ for message in fired:
99
+ print(f" [{message.level.value}] {message.title}")
100
+ print(f" {message.text.splitlines()[0]}")
101
+
102
+ state = et.info()["alerts"]["rules"]
103
+ print("\nrule state:")
104
+ for rule in et.list_alert_rules():
105
+ # An unnamed rule is known by its condition, which is what you read
106
+ label = rule.condition if rule.auto_named else rule.name
107
+ counts = state[rule.name]
108
+ print(f" {label:<34} fires={counts['fires']} firing={counts['firing']}")
109
+
110
+ et.finish()
111
+ return fired
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,83 @@
1
+ """Checkpoints as artifacts: versioned, deduplicated, and found again later.
2
+
3
+ Artifacts are stored per project and shared by its runs, so a later run can ask
4
+ for ``model:best`` without knowing which run produced it.
5
+
6
+ uv run python examples/checkpoints.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import expr_tracker as et
16
+
17
+
18
+ def write_checkpoint(directory: Path, step: int, accuracy: float) -> Path:
19
+ """Stand-in for torch.save."""
20
+ directory.mkdir(parents=True, exist_ok=True)
21
+ path = directory / "model.pt"
22
+ path.write_text(f"weights@{step}:{accuracy:.3f}", encoding="utf-8")
23
+ return path
24
+
25
+
26
+ def train(args, workdir: Path) -> str:
27
+ run = et.init(
28
+ project="checkpoints",
29
+ name=args.name,
30
+ dir=args.dir,
31
+ backends=[],
32
+ config={"steps": args.steps},
33
+ )
34
+ best = -1.0
35
+
36
+ for step in range(0, args.steps, args.every):
37
+ accuracy = 0.7 + 0.1 * (step / args.steps)
38
+ et.log({"eval/accuracy": accuracy}, commit=False)
39
+ et.log({"train/loss": 1.0 / (step + 1)})
40
+
41
+ path = write_checkpoint(workdir, step, accuracy)
42
+ aliases = ["best"] if accuracy > best else []
43
+ artifact = et.log_artifact(path, name="model", type="model", aliases=aliases)
44
+ if accuracy > best:
45
+ best = accuracy
46
+ et.summary()["best_accuracy"] = accuracy
47
+ print(f"step {step:>3}: acc {accuracy:.3f} -> model:v{artifact.version}")
48
+
49
+ # The same bytes again: deduplicated, so no new version is created
50
+ repeat = et.log_artifact(workdir / "model.pt", name="model", type="model")
51
+ print(f"logging the same file again -> v{repeat.version} (deduplicated)")
52
+
53
+ et.finish()
54
+ return run.name
55
+
56
+
57
+ def restore(args, into: Path) -> None:
58
+ """A different run, later, asking for the best checkpoint."""
59
+ et.init(project="checkpoints", name=f"{args.name}-eval", dir=args.dir, backends=[])
60
+ artifact = et.use_artifact("model:best")
61
+ path = Path(artifact.download(str(into)))
62
+ payload = (path / "model.pt").read_text(encoding="utf-8")
63
+ print(f"\nrestored model:best (v{artifact.version}) -> {payload}")
64
+ print(f"downloaded to {path}")
65
+ et.finish()
66
+
67
+
68
+ def main(argv=None) -> None:
69
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
70
+ parser.add_argument("--steps", type=int, default=40)
71
+ parser.add_argument("--every", type=int, default=10, help="checkpoint interval")
72
+ parser.add_argument("--dir", default="runs")
73
+ parser.add_argument("--name", default=None)
74
+ args = parser.parse_args(argv)
75
+ args.name = args.name or time.strftime("ckpt-%H%M%S")
76
+
77
+ workdir = Path(args.dir) / "scratch" / args.name
78
+ train(args, workdir)
79
+ restore(args, workdir / "restored")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,99 @@
1
+ """Reading your own history mid-run to decide what to do next.
2
+
3
+ Most trackers only ship metrics out. Because history is local and queryable
4
+ while the run is open, the loop can ask what has been happening and act on it:
5
+ decay the learning rate on a plateau, stop early, skip a bad batch.
6
+
7
+ uv run python examples/early_stopping.py
8
+ uv run python examples/early_stopping.py --patience 3 --plateau 0.002
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import math
15
+ import random
16
+ import time
17
+
18
+ import expr_tracker as et
19
+
20
+
21
+ def plateaued(window: int, threshold: float) -> bool:
22
+ """Has the eval metric stopped improving over the last `window` evals?
23
+
24
+ `n` counts rows and `dropna` filters them afterwards, so asking for the last
25
+ 4 rows of a metric logged every 10 steps finds nothing. Take the eval points
26
+ first, then the last few.
27
+ """
28
+ points = et.history(-1, metrics=["eval/loss"], dropna=True)[-window:]
29
+ if len(points) < window:
30
+ return False # not enough evidence yet, so do nothing
31
+ values = [row["eval/loss"] for row in points]
32
+ return max(values) - min(values) < threshold
33
+
34
+
35
+ def main(argv=None) -> dict:
36
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
37
+ parser.add_argument("--steps", type=int, default=600)
38
+ parser.add_argument("--eval-every", type=int, default=10)
39
+ parser.add_argument("--patience", type=int, default=4, help="evals before acting")
40
+ parser.add_argument("--plateau", type=float, default=0.004, help="no-progress band")
41
+ parser.add_argument("--lr", type=float, default=1e-3)
42
+ parser.add_argument("--min-lr", type=float, default=1e-5)
43
+ parser.add_argument("--seed", type=int, default=0)
44
+ parser.add_argument("--dir", default="runs")
45
+ parser.add_argument("--name", default=None)
46
+ args = parser.parse_args(argv)
47
+ args.name = args.name or time.strftime("earlystop-%H%M%S")
48
+
49
+ et.init(
50
+ project="control",
51
+ name=args.name,
52
+ dir=args.dir,
53
+ backends=[],
54
+ config={"lr": args.lr, "patience": args.patience},
55
+ )
56
+ rng = random.Random(args.seed)
57
+ lr, decays, stopped_at, since = args.lr, 0, None, 0
58
+
59
+ for step in range(args.steps):
60
+ # Improvement restarts after each decay and then flattens again, so
61
+ # there is a real plateau to find. Noise stays under --plateau.
62
+ floor = 0.25 * (0.6**decays)
63
+ loss = floor + 0.8 * math.exp(-(step - since) / 30) + rng.uniform(0, 0.001)
64
+
65
+ if step % args.eval_every == 0 and step:
66
+ et.log({"eval/loss": loss * 0.98}, commit=False)
67
+ if plateaued(args.patience, args.plateau):
68
+ if lr > args.min_lr:
69
+ lr, decays, since = lr / 10, decays + 1, step
70
+ print(f"step {step:>3}: plateau -> lr {lr:.1e}")
71
+ et.log({"event/lr_decay": decays}, commit=False)
72
+ else:
73
+ stopped_at = step
74
+ print(f"step {step:>3}: plateau at the minimum lr, stopping")
75
+ et.summary()["stopped_early"] = True
76
+
77
+ et.log({"train/loss": loss, "train/lr": lr})
78
+ if stopped_at is not None:
79
+ break
80
+
81
+ et.summary()["decays"] = decays
82
+ et.summary()["steps_run"] = et.get_run().step
83
+
84
+ rows = et.history(-1, metrics=["eval/loss"], dropna=True)
85
+ print(f"\n{len(rows)} evals, {decays} decay(s)")
86
+ print(f"first eval {rows[0]['eval/loss']:.4f} -> last {rows[-1]['eval/loss']:.4f}")
87
+ if stopped_at is None:
88
+ print(f"ran all {args.steps} steps without stopping")
89
+ else:
90
+ saved = args.steps - stopped_at
91
+ print(f"stopped at step {stopped_at}, saving {saved} steps of compute")
92
+
93
+ info = dict(et.summary())
94
+ et.finish()
95
+ return info
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()