expr-tracker 0.2.2__tar.gz → 0.2.3__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 (100) hide show
  1. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/PKG-INFO +6 -1
  2. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/README.md +5 -0
  3. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/design.md +42 -1
  4. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/cli.md +19 -0
  5. expr_tracker-0.2.3/docs/guide/spans.md +169 -0
  6. expr_tracker-0.2.3/docs/guide/streams.md +113 -0
  7. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/reference/api.md +26 -0
  8. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/reference/configuration.md +2 -0
  9. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/mkdocs.yml +2 -0
  10. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/__init__.py +4 -0
  11. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/cli.py +24 -0
  12. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/__init__.py +13 -2
  13. expr_tracker-0.2.3/src/expr_tracker/history/naming.py +74 -0
  14. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/reader.py +40 -10
  15. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/store.py +96 -28
  16. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/run.py +27 -6
  17. expr_tracker-0.2.3/src/expr_tracker/spans.py +236 -0
  18. expr_tracker-0.2.3/src/expr_tracker/trace.py +189 -0
  19. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/tracker.py +27 -5
  20. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_distributed.py +4 -4
  21. expr_tracker-0.2.3/tests/test_spans.py +620 -0
  22. expr_tracker-0.2.3/tests/test_streams.py +518 -0
  23. expr_tracker-0.2.3/tests/test_trace.py +421 -0
  24. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/.github/workflows/docs.yaml +0 -0
  25. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/.github/workflows/release.yaml +0 -0
  26. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/.gitignore +0 -0
  27. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/LICENSE +0 -0
  28. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/architecture.md +0 -0
  29. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/getting-started.md +0 -0
  30. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/alerts.md +0 -0
  31. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/artifacts.md +0 -0
  32. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/backends.md +0 -0
  33. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/distributed.md +0 -0
  34. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/history.md +0 -0
  35. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/guide/logging.md +0 -0
  36. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/index.md +0 -0
  37. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/docs/reference/expressions.md +0 -0
  38. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/pyproject.toml +0 -0
  39. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/_compat.py +0 -0
  40. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/__init__.py +0 -0
  41. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  42. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/backends/base.py +0 -0
  43. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/dispatch.py +0 -0
  44. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/engine.py +0 -0
  45. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  46. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/eval.py +0 -0
  47. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/functions.py +0 -0
  48. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  49. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  50. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/parser.py +0 -0
  51. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/expr/rule.py +0 -0
  52. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/alerts/models.py +0 -0
  53. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/artifacts.py +0 -0
  54. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/encoders.py +0 -0
  55. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/codec.py +0 -0
  56. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/frame.py +0 -0
  57. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/series.py +0 -0
  58. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/history/writer.py +0 -0
  59. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/py.typed +0 -0
  60. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/summary.py +0 -0
  61. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/src/expr_tracker/types.py +0 -0
  62. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/conftest.py +0 -0
  63. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_backends.py +0 -0
  64. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_delivery.py +0 -0
  65. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_dispatch.py +0 -0
  66. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_engine.py +0 -0
  67. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_models.py +0 -0
  68. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_alert_routing.py +0 -0
  69. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_artifacts.py +0 -0
  70. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_benchmark.py +0 -0
  71. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_cache.py +0 -0
  72. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_cli.py +0 -0
  73. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_correctness.py +0 -0
  74. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_e2e.py +0 -0
  75. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_expr_builder.py +0 -0
  76. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_expr_eval.py +0 -0
  77. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_expr_functions.py +0 -0
  78. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_expr_parser.py +0 -0
  79. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_expr_properties.py +0 -0
  80. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_failure_modes.py +0 -0
  81. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_features.py +0 -0
  82. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_frame_codec_summary.py +0 -0
  83. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_history.py +0 -0
  84. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_hot_paths.py +0 -0
  85. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_integration.py +0 -0
  86. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_lark_live.py +0 -0
  87. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_perf.py +0 -0
  88. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_public_surfaces.py +0 -0
  89. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_review_regressions.py +0 -0
  90. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_rule_lifecycle.py +0 -0
  91. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_run_backends.py +0 -0
  92. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_scenarios.py +0 -0
  93. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_step_commit.py +0 -0
  94. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_stress.py +0 -0
  95. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_trackio.py +0 -0
  96. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_value_encoding.py +0 -0
  97. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_wandb.py +0 -0
  98. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_writer_buffer.py +0 -0
  99. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/tests/test_writer_durability.py +0 -0
  100. {expr_tracker-0.2.2 → expr_tracker-0.2.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expr_tracker
3
- Version: 0.2.2
3
+ Version: 0.2.3
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/
@@ -106,6 +106,8 @@ install command; it never crashes a run.
106
106
  | [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. |
107
107
  | [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. |
108
108
  | [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
109
+ | [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. |
110
+ | [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. |
109
111
  | [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. |
110
112
  | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
111
113
 
@@ -148,6 +150,9 @@ uv run ruff format src tests
148
150
  | `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
149
151
  | `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
150
152
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
153
+ | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
154
+ | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
155
+ | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
151
156
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
152
157
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
153
158
  | `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
@@ -59,6 +59,8 @@ install command; it never crashes a run.
59
59
  | [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. |
60
60
  | [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. |
61
61
  | [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
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. |
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. |
62
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. |
63
65
  | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
64
66
 
@@ -101,6 +103,9 @@ uv run ruff format src tests
101
103
  | `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
102
104
  | `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
103
105
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
106
+ | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
107
+ | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
108
+ | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
104
109
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
105
110
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
106
111
  | `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
@@ -75,7 +75,48 @@ last line. Patch lines and `step_policy="allow"` both let a file end on a lower
75
75
  and continuing from that would reuse an existing one. `max_step` is maintained
76
76
  incrementally and persisted in the sidecar.
77
77
 
78
- ### A.4 Multiple processes
78
+ ### A.4 Streams
79
+
80
+ Independent producers — a training loop and a data worker — have unrelated step
81
+ semantics, so each gets its own file, cursor and resume state under one run
82
+ directory: `metrics[.stream][.rankN].jsonl`, plus per-stream `summary` and
83
+ `config` sidecars so concurrent processes cannot clobber each other.
84
+
85
+ The store is instantiated once per stream rather than made multi-tenant, so every
86
+ invariant below holds unchanged within a stream; only the filename differs.
87
+
88
+ `resolve_run_path()` matches names exactly rather than taking the first glob hit:
89
+ `metrics.data.jsonl` sorts before `metrics.jsonl`, so sort order would silently
90
+ return a stream in place of the default producer.
91
+
92
+ A stream is forwarded to a backend as its own run, grouped under the run name.
93
+ Neither wandb nor trackio can merge two step axes into one run; wandb's shared
94
+ mode can, but requires a live server and has no trackio equivalent.
95
+
96
+ ### A.5 Spans
97
+
98
+ `et.span` times a region and its children. A closed span takes the same ingress as
99
+ `log()` — `HistoryStore.ingest()` — so the step policy, the open-row merge and the
100
+ commit rules exist in one place regardless of which API produced the metrics.
101
+
102
+ Two differences from a log call:
103
+
104
+ - the merge **accumulates** rather than replaces, because the same span can run
105
+ many times in one step;
106
+ - the metrics are built here from floats and ints, so they skip `RecordCodec`.
107
+ That encoder costs 5 µs, which a per-sub-step call cannot afford.
108
+
109
+ A span never commits a step: durations ride along with whatever `log()` commits,
110
+ so timing a region adds no row. The tree, with timestamps and attributes, goes to
111
+ `spans[.stream][.rankN].jsonl` through a second `JsonlWriter`, enqueued rather
112
+ than appended so a span does not pay for a flush decision of its own.
113
+
114
+ The nesting stack is a `ContextVar`, not a `threading.local`: a new thread starts
115
+ from the default and each asyncio task gets its own copy, which is exactly the
116
+ scoping spans need. (The run singleton went the other way, from `ContextVar` to a
117
+ locked global, because there the goal was for worker threads to *share* it.)
118
+
119
+ ### A.6 Multiple processes
79
120
 
80
121
  The supported model is **rank 0 tracks**; there is no cross-rank merging.
81
122
 
@@ -22,6 +22,25 @@ et history tracker/jsonl/demo/run-1 -n -1 --format csv > run.csv
22
22
 
23
23
  The argument is a run directory or a `metrics.jsonl` file.
24
24
 
25
+ ## `et trace`
26
+
27
+ Export recorded [spans](spans.md) as a Chrome Trace, for
28
+ [Perfetto](https://ui.perfetto.dev) or `chrome://tracing`.
29
+
30
+ ```bash
31
+ et trace runs/llm/sft-1 # every stream -> trace.json
32
+ et trace runs/llm/sft-1 -o pipeline.json
33
+ et trace runs/llm/sft-1 --stream data # one stream
34
+ et trace runs/llm/sft-1 --stream default,data # several
35
+ et trace runs/llm/sft-1 --step-range 100:200 # a slice of the run
36
+ ```
37
+
38
+ | Option | Default | Meaning |
39
+ | --- | --- | --- |
40
+ | `-o`, `--output` | `trace.json` | where to write |
41
+ | `--stream` | every stream | comma separated; `default` is the unnamed one |
42
+ | `--step-range` | all | `start:end`, end exclusive |
43
+
25
44
  ## `et rules explain`
26
45
 
27
46
  Show how an expression parses, and what it references. Useful when precedence is in
@@ -0,0 +1,169 @@
1
+ # Spans
2
+
3
+ A step is rarely one thing. `et.span` times the parts, and their parts, and turns
4
+ each duration into an ordinary metric — so `history()`, alert rules and plots all
5
+ work on it with no extra machinery.
6
+
7
+ ```python
8
+ for batch in loader:
9
+ with et.span("forward"):
10
+ with et.span("attention"):
11
+ attn = attention(batch)
12
+ with et.span("mlp"):
13
+ out = mlp(attn)
14
+ with et.span("backward"):
15
+ loss.backward()
16
+ et.log({"loss": loss.item()})
17
+ ```
18
+
19
+ The step's row then carries:
20
+
21
+ ```jsonc
22
+ {"_step": 42, "_time": ...,
23
+ "forward/duration_ms": 31.2, "forward/count": 1,
24
+ "forward/attention/duration_ms": 18.4, "forward/attention/count": 1,
25
+ "forward/mlp/duration_ms": 9.1, "forward/mlp/count": 1,
26
+ "backward/duration_ms": 44.7, "backward/count": 1,
27
+ "loss": 0.31}
28
+ ```
29
+
30
+ Nested names join with `/`, so `forward/norm` and `backward/norm` stay distinct,
31
+ and the alert language reads them directly.
32
+
33
+ ## Forms
34
+
35
+ ```python
36
+ with et.span("forward"): ... # context manager
37
+
38
+ async with et.span("fetch"): ... # async
39
+
40
+ @et.span("preprocess") # decorator, sync or async
41
+ def preprocess(batch): ...
42
+
43
+ span = et.start_span("epoch") # manual, for crossing scopes
44
+ ...
45
+ span.end()
46
+ ```
47
+
48
+ ## Repeated spans
49
+
50
+ The same span several times in one step sums, and counts:
51
+
52
+ ```python
53
+ for layer in layers: # 32 layers
54
+ with et.span("layer"):
55
+ x = layer(x)
56
+ ```
57
+
58
+ ```jsonc
59
+ {"layer/duration_ms": 412.8, "layer/count": 32}
60
+ ```
61
+
62
+ The total is usually what you want; divide by the count for the mean.
63
+
64
+ ## A span never commits a step
65
+
66
+ Durations join whatever `log()` commits, so a span costs no row of its own and
67
+ you can time things before you know what to log:
68
+
69
+ ```python
70
+ with et.span("forward"):
71
+ ...
72
+ # nothing written yet
73
+ et.log({"loss": loss}) # one row, with the metrics and the durations
74
+ ```
75
+
76
+ ## Attributes
77
+
78
+ Attributes describe an individual span. They reach `spans.jsonl`, not the metrics,
79
+ because they are usually not numbers:
80
+
81
+ ```python
82
+ with et.span("load", batch_size=32) as span:
83
+ rows = read()
84
+ span.set(rows=len(rows))
85
+ ```
86
+
87
+ ## Alerting on durations
88
+
89
+ Because a duration is a metric, the [expression language](../reference/expressions.md)
90
+ already handles it:
91
+
92
+ ```python
93
+ et.init(..., alert_rules=[
94
+ "mean(data/load_ms[50]) > 200 => warning: data loading is slowing down",
95
+ "forward/duration_ms > 3 * mean(forward/duration_ms[100]) => error: slow step",
96
+ ])
97
+ ```
98
+
99
+ ## The span file
100
+
101
+ The full tree is written to `spans.jsonl` beside the metrics, one line per span:
102
+
103
+ ```jsonc
104
+ {"_step": 42, "name": "forward/attention", "depth": 1,
105
+ "start": 1754323200.123456, "dur_ms": 18.4, "args": {"batch_size": 32}}
106
+ ```
107
+
108
+ Children appear before their parents, because a parent closes last. It follows
109
+ the [stream](streams.md): a data worker writes `spans.data.jsonl`.
110
+
111
+ Turn it off if you only want the metrics:
112
+
113
+ ```python
114
+ et.init(..., spans=False)
115
+ ```
116
+
117
+ ## Viewing the timeline
118
+
119
+ `et trace` turns the span file into a Chrome Trace, which
120
+ [Perfetto](https://ui.perfetto.dev) and `chrome://tracing` open directly:
121
+
122
+ ```bash
123
+ et trace runs/llm/sft-1 -o trace.json
124
+ et trace runs/llm/sft-1 --stream data --step-range 100:200
125
+ ```
126
+
127
+ Each stream becomes a process and each thread a track, so a data worker and a
128
+ training loop sit on one timeline and the gap where one waited for the other is
129
+ visible. Spans keep their nesting, their step and their attributes.
130
+
131
+ Exporting a standard format rather than drawing our own view means the result
132
+ can be loaded beside a `torch.profiler` trace, which is usually where the real
133
+ question is: what were the GPUs doing while the loader stalled.
134
+
135
+ ## Errors
136
+
137
+ An exception is recorded and re-raised — the span never swallows it:
138
+
139
+ ```jsonc
140
+ {"_step": 42, "name": "risky", "dur_ms": 3.1, "error": "ValueError"}
141
+ ```
142
+
143
+ A failure inside the recording itself is logged and ignored: measuring something
144
+ must not break it.
145
+
146
+ ## Threads and tasks
147
+
148
+ The nesting stack is per thread and per asyncio task, so concurrent work does not
149
+ nest inside unrelated spans:
150
+
151
+ ```python
152
+ async def work(name):
153
+ async with et.span(name): # "a" and "b", never "a/b"
154
+ await asyncio.sleep(1)
155
+
156
+ await asyncio.gather(work("a"), work("b"))
157
+ ```
158
+
159
+ ## Cost
160
+
161
+ | | per span |
162
+ | --- | --- |
163
+ | default | ~13 µs |
164
+ | `spans=False` | ~6 µs |
165
+ | no active run | ~3 µs |
166
+
167
+ For comparison, `et.log()` is ~24 µs. Twenty spans on a 100 ms step is 0.26% of
168
+ the step. If your step is closer to a millisecond, set `spans=False` and keep the
169
+ metrics, or time fewer regions.
@@ -0,0 +1,113 @@
1
+ # Streams
2
+
3
+ A training loop and a data worker measure different things on different clocks:
4
+ training step 100 and data batch 100 are unrelated. A **stream** gives each
5
+ producer its own step cursor and its own file inside one run directory.
6
+
7
+ ```python
8
+ # the training process
9
+ et.init(project="llm", name="sft-1")
10
+ et.log({"train/loss": loss}) # steps 0, 1, 2, ...
11
+
12
+ # the data worker, a separate process
13
+ et.init(project="llm", name="sft-1", stream="data")
14
+ et.log({"data/produce_ms": 12.4}) # its own steps 0, 1, 2, ...
15
+ ```
16
+
17
+ Without this, both producers share one cursor: their steps interleave, the merge
18
+ puts unrelated metrics on one row, and `step_policy="monotonic"` drops whichever
19
+ producer happens to fall behind.
20
+
21
+ ## Layout
22
+
23
+ ```
24
+ runs/llm/sft-1/
25
+ ├── metrics.jsonl # the default producer
26
+ ├── metrics.data.jsonl # stream "data"
27
+ ├── metrics.meta.json
28
+ ├── metrics.data.meta.json
29
+ ├── summary.json # per stream, so processes cannot clobber each other
30
+ ├── summary.data.json
31
+ ├── config.json
32
+ └── config.data.json
33
+ ```
34
+
35
+ Streams compose with [rank shards](distributed.md): rank 1 of the data worker
36
+ writes `metrics.data.rank1.jsonl`.
37
+
38
+ Stream names become part of a filename, so they must be letters, digits, `_` or
39
+ `-`, starting with a letter or digit. `rank1` and friends are rejected because
40
+ they already mean a rank shard.
41
+
42
+ ## Reading
43
+
44
+ ```python
45
+ et.history(50) # whichever stream this process writes
46
+ et.history(50, stream=None) # the default producer
47
+ et.history(50, stream="data") # another stream
48
+ et.history(50, run=path, stream="data") # offline
49
+ ```
50
+
51
+ Omitting `stream` reads the running stream; passing `stream=None` explicitly means
52
+ the default, unnamed one. Another process's stream is read from its file, so you
53
+ see what it has flushed rather than what it has buffered.
54
+
55
+ ```python
56
+ from expr_tracker.history import list_streams
57
+ list_streams("runs/llm/sft-1") # [None, "data"]
58
+ ```
59
+
60
+ ## Alerts
61
+
62
+ Each process alerts on what it can see, which is its own stream. That is usually
63
+ what you want — the data worker is the thing that knows its pipeline stalled:
64
+
65
+ ```python
66
+ et.init(
67
+ project="llm", name="sft-1", stream="data",
68
+ alert_rules=[
69
+ "produce_ms > 1000 => warning: data pipeline slow",
70
+ "no_data(5m) => error: data worker stopped producing",
71
+ ],
72
+ )
73
+ ```
74
+
75
+ !!! note
76
+ A rule cannot span streams that live in different processes, because neither
77
+ process holds the other's metrics. If you need that, log both from one
78
+ process, or evaluate the rule downstream against the files.
79
+
80
+ ## Backends
81
+
82
+ A stream is forwarded as its own backend run, grouped under the run name:
83
+
84
+ ```python
85
+ et.init(project="llm", name="sft-1", stream="data", backends=["wandb"])
86
+ # -> wandb.init(name="sft-1-data", group="sft-1", job_type="data")
87
+ ```
88
+
89
+ Both wandb and trackio understand `group`, and neither can merge two step axes
90
+ into a single run. wandb's shared mode can, but it needs a live server and has no
91
+ trackio equivalent, so grouping is the default.
92
+
93
+ Override it per backend if you want something else:
94
+
95
+ ```python
96
+ et.init(..., stream="data", backend_kwargs={"wandb": {"group": "my-group"}})
97
+ ```
98
+
99
+ ## When you do not need a stream
100
+
101
+ If the producers are in **one process at different cadences** — an eval loop
102
+ every 100 steps, say — you do not need a stream. Log with the training step and
103
+ the sparse metric simply appears on the steps where you logged it:
104
+
105
+ ```python
106
+ et.log({"train/loss": loss})
107
+ if step % 100 == 0:
108
+ et.log({"eval/acc": acc}, step=step)
109
+ ```
110
+
111
+ Window functions already work on that, because `eval.acc[20]` counts points of
112
+ that metric, not rows. Reach for a stream when producers are genuinely concurrent
113
+ and their step numbers mean different things.
@@ -23,6 +23,7 @@ et.init(
23
23
  backends: Sequence[str] = ("wandb", "jsonl"),
24
24
  backend_kwargs: dict[str, dict] | None = None,
25
25
  print_to_screen: bool = False,
26
+ stream: str | None = None,
26
27
  alert=None,
27
28
  alert_rules: Sequence = (),
28
29
  **history_options,
@@ -77,9 +78,13 @@ et.history(
77
78
  fill_missing: bool = False,
78
79
  dropna: bool = False,
79
80
  run: str | Path | None = None,
81
+ stream: str | None = ...,
80
82
  )
81
83
  ```
82
84
 
85
+ Omit `stream` to read the running one; pass `None` for the default producer. See
86
+ [Streams](../guide/streams.md).
87
+
83
88
  `n=-1` or `None` returns everything. With `run=`, reads that run offline and no
84
89
  `init()` is needed. See [Querying history](../guide/history.md).
85
90
 
@@ -148,6 +153,27 @@ et.register_backend(kind: str, cls: type[AlertBackend])
148
153
 
149
154
  Registers a custom channel type.
150
155
 
156
+ ## Spans
157
+
158
+ ```python
159
+ with et.span(name, **attributes) as span: ... # also async, also a decorator
160
+ span = et.start_span(name, **attributes) # ends with span.end()
161
+ span.set(**attributes)
162
+ span.duration_ms
163
+ ```
164
+
165
+ A closed span adds `<path>/duration_ms` and `<path>/count` to the open row and
166
+ appends the full record to `spans.jsonl`. See [Spans](../guide/spans.md).
167
+
168
+ ### Trace export
169
+
170
+ ```python
171
+ from expr_tracker.trace import build_trace, write_trace
172
+
173
+ write_trace(run, "trace.json", stream="*", step_range=None) # returns the span count
174
+ build_trace(run, stream="*") # the dict, unwritten
175
+ ```
176
+
151
177
  ## Artifacts
152
178
 
153
179
  ```python
@@ -13,6 +13,8 @@ list of valid options rather than being silently ignored.
13
13
  | `max_open_seconds` | 60.0 | commit an idle open row after this long; `None` disables |
14
14
  | `step_policy` | `"monotonic"` | `"monotonic"` drops backward steps, `"allow"` keeps them |
15
15
  | `rank_aware` | `True` | non-zero ranks write their own shard |
16
+ | `stream` | `None` | an independent producer with its own file and step cursor |
17
+ | `spans` | `True` | write the span tree to `spans.jsonl` beside the metrics |
16
18
  | `print_to_screen` | `False` | print every committed row |
17
19
  | `print_handle` | `print` | where those lines go |
18
20
  | `buffer_size` | 50 | flush after this many buffered rows |
@@ -34,6 +34,8 @@ nav:
34
34
  - Querying history: guide/history.md
35
35
  - Alerts: guide/alerts.md
36
36
  - Artifacts: guide/artifacts.md
37
+ - Spans: guide/spans.md
38
+ - Streams: guide/streams.md
37
39
  - Distributed runs: guide/distributed.md
38
40
  - Backends: guide/backends.md
39
41
  - CLI: guide/cli.md
@@ -19,6 +19,7 @@ from .alerts import (
19
19
  )
20
20
  from .artifacts import Artifact
21
21
  from .run import Run
22
+ from .spans import Span, span, start_span
22
23
 
23
24
  try:
24
25
  __version__ = _version("expr_tracker")
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "ChannelConfig",
46
47
  "M",
47
48
  "Run",
49
+ "Span",
48
50
  "WebhookPolicy",
49
51
  "__version__",
50
52
  "add_alert_rule",
@@ -61,6 +63,8 @@ __all__ = [
61
63
  "log_artifact",
62
64
  "register_backend",
63
65
  "remove_alert_rule",
66
+ "span",
67
+ "start_span",
64
68
  "summary",
65
69
  "use_artifact",
66
70
  ]
@@ -13,6 +13,7 @@ from .alerts.engine import AlertEngine
13
13
  from .alerts.expr import EvalContext, compile_condition, parse_rule, validate
14
14
  from .alerts.models import AlertConfig, ChannelConfig, WebhookPolicy
15
15
  from .history import MetricSeries, read_history
16
+ from .trace import write_trace
16
17
 
17
18
 
18
19
  @click.group()
@@ -54,6 +55,29 @@ def history(run: str, n: int, metrics: str | None, step_range: str | None, fmt:
54
55
  click.echo(_to_table(rows))
55
56
 
56
57
 
58
+ @main.command()
59
+ @click.argument("run", type=click.Path(exists=True))
60
+ @click.option("-o", "--output", default="trace.json", help="Where to write the trace")
61
+ @click.option(
62
+ "--stream",
63
+ default=None,
64
+ help="Comma separated streams; every stream by default. Use 'default' for the "
65
+ "unnamed one.",
66
+ )
67
+ @click.option("--step-range", default=None, help="start:end (end exclusive)")
68
+ def trace(run: str, output: str, stream: str | None, step_range: str | None):
69
+ """Export recorded spans as a Chrome Trace.
70
+
71
+ Open the result at https://ui.perfetto.dev or chrome://tracing. It can be
72
+ loaded beside a torch.profiler trace.
73
+ """
74
+ wanted: str | list[str] | None = "*"
75
+ if stream:
76
+ wanted = [None if s in ("default", "") else s for s in stream.split(",")]
77
+ count = write_trace(run, output, wanted, step_range=_parse_range(step_range))
78
+ click.echo(f"wrote {count} span(s) to {output}")
79
+
80
+
57
81
  @main.group()
58
82
  def rules():
59
83
  """Alert rule tooling."""
@@ -1,8 +1,15 @@
1
1
  from .codec import RecordCodec
2
2
  from .frame import project, to_output
3
- from .reader import JsonlReader, merge_steps, read_history, resolve_run_path
3
+ from .naming import current_rank, metrics_filename, parse_stream, validate_stream
4
+ from .reader import (
5
+ JsonlReader,
6
+ list_streams,
7
+ merge_steps,
8
+ read_history,
9
+ resolve_run_path,
10
+ )
4
11
  from .series import MetricSeries
5
- from .store import HistoryStore, current_rank, resolve_commit
12
+ from .store import HistoryStore, resolve_commit
6
13
  from .writer import JsonlWriter
7
14
 
8
15
  __all__ = [
@@ -12,10 +19,14 @@ __all__ = [
12
19
  "MetricSeries",
13
20
  "RecordCodec",
14
21
  "current_rank",
22
+ "list_streams",
15
23
  "merge_steps",
24
+ "metrics_filename",
25
+ "parse_stream",
16
26
  "project",
17
27
  "read_history",
18
28
  "resolve_commit",
19
29
  "resolve_run_path",
20
30
  "to_output",
31
+ "validate_stream",
21
32
  ]
@@ -0,0 +1,74 @@
1
+ """Run file naming: distributed rank shards and independent producer streams.
2
+
3
+ Kept separate from the store and the reader because both need it, and the store
4
+ already depends on the reader.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+
12
+ STREAM_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
13
+ RANK_PATTERN = re.compile(r"rank\d+")
14
+
15
+
16
+ def current_rank() -> int:
17
+ """Distributed rank from the usual environment variables, 0 when unset."""
18
+ rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
19
+ try:
20
+ return int(rank)
21
+ except ValueError:
22
+ return 0
23
+
24
+
25
+ def validate_stream(stream: str) -> str:
26
+ """Check a stream name is safe as a filename component.
27
+
28
+ The name becomes part of ``metrics.<stream>[.rankN].jsonl``, so it must not
29
+ contain a separator, and must not look like a rank shard.
30
+ """
31
+ if not STREAM_PATTERN.match(stream):
32
+ raise ValueError(
33
+ f"Invalid stream name {stream!r}; use letters, digits, '_' or '-', "
34
+ "starting with a letter or digit."
35
+ )
36
+ if RANK_PATTERN.fullmatch(stream):
37
+ raise ValueError(
38
+ f"Stream name {stream!r} collides with the rank shard naming; "
39
+ "pick another name."
40
+ )
41
+ return stream
42
+
43
+
44
+ def metrics_filename(stream: str | None, rank_aware: bool = True) -> str:
45
+ """``metrics[.stream][.rankN].jsonl``.
46
+
47
+ Streams separate independent producers; non-zero ranks get their own shard so
48
+ concurrent appends cannot interleave. The two compose.
49
+ """
50
+ rank = current_rank() if rank_aware else 0
51
+ parts = ["metrics"]
52
+ if stream:
53
+ parts.append(stream)
54
+ if rank > 0:
55
+ parts.append(f"rank{rank}")
56
+ return ".".join(parts) + ".jsonl"
57
+
58
+
59
+ def spans_filename(stream: str | None, rank_aware: bool = True) -> str:
60
+ """``spans[.stream][.rankN].jsonl``, alongside the metrics file."""
61
+ return metrics_filename(stream, rank_aware).replace("metrics", "spans", 1)
62
+
63
+
64
+ def sidecar_filename(base: str, stream: str | None, suffix: str) -> str:
65
+ """``<base>[.stream].<suffix>``, so producers do not overwrite each other."""
66
+ return f"{base}.{stream}.{suffix}" if stream else f"{base}.{suffix}"
67
+
68
+
69
+ def parse_stream(filename: str) -> str | None:
70
+ """The stream a metrics filename belongs to; ``None`` is the default producer."""
71
+ parts = filename.split(".")[1:-1] # drop "metrics" and "jsonl"
72
+ if parts and RANK_PATTERN.fullmatch(parts[-1]):
73
+ parts = parts[:-1]
74
+ return parts[0] if parts else None