expr-tracker 0.3.2__tar.gz → 0.4.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 (140) hide show
  1. expr_tracker-0.4.0/.github/workflows/ci.yaml +39 -0
  2. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/.github/workflows/release.yaml +4 -0
  3. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/PKG-INFO +4 -2
  4. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/README.md +1 -1
  5. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/architecture.md +32 -9
  6. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/design.md +22 -17
  7. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/artifacts.md +6 -0
  8. expr_tracker-0.4.0/docs/guide/logging.md +180 -0
  9. expr_tracker-0.4.0/docs/guide/spans.md +457 -0
  10. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/streams.md +32 -4
  11. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/reference/api.md +35 -3
  12. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/reference/configuration.md +32 -4
  13. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/reference/expressions.md +8 -1
  14. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/pyproject.toml +2 -0
  15. expr_tracker-0.4.0/src/expr_tracker/_process.py +33 -0
  16. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/__init__.py +16 -5
  17. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/backends/__init__.py +9 -2
  18. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/backends/base.py +11 -4
  19. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/dispatch.py +32 -6
  20. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/eval.py +6 -2
  21. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/functions.py +8 -2
  22. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/rule.py +25 -12
  23. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/models.py +9 -4
  24. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/artifacts.py +12 -4
  25. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/cli.py +57 -7
  26. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/encoders.py +33 -2
  27. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/codec.py +14 -22
  28. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/frame.py +6 -1
  29. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/reader.py +212 -52
  30. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/series.py +37 -2
  31. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/store.py +473 -170
  32. expr_tracker-0.4.0/src/expr_tracker/history/writer.py +768 -0
  33. expr_tracker-0.4.0/src/expr_tracker/plugins.py +392 -0
  34. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/run.py +75 -9
  35. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/spans.py +57 -14
  36. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/summary.py +12 -1
  37. expr_tracker-0.4.0/src/expr_tracker/trace.py +533 -0
  38. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/tracker.py +16 -1
  39. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/conftest.py +1 -3
  40. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_backends.py +57 -2
  41. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_dispatch.py +73 -0
  42. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_engine.py +29 -0
  43. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_routing.py +0 -1
  44. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_artifacts.py +58 -1
  45. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_benchmark.py +81 -1
  46. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_cache.py +1 -2
  47. expr_tracker-0.4.0/tests/test_callback_isolation.py +863 -0
  48. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_cli.py +140 -3
  49. expr_tracker-0.4.0/tests/test_commit_timestamps.py +131 -0
  50. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_correctness.py +0 -1
  51. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_distributed.py +0 -1
  52. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_e2e.py +77 -8
  53. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_examples.py +19 -13
  54. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_expr_functions.py +19 -1
  55. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_expr_properties.py +0 -1
  56. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_failure_modes.py +0 -1
  57. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_features.py +0 -2
  58. expr_tracker-0.4.0/tests/test_fork_safety.py +204 -0
  59. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_history.py +18 -21
  60. expr_tracker-0.4.0/tests/test_history_admission.py +186 -0
  61. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_hot_paths.py +0 -1
  62. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_lark.py +2 -2
  63. expr_tracker-0.4.0/tests/test_nonfinite_conditions.py +56 -0
  64. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_perf.py +0 -1
  65. expr_tracker-0.4.0/tests/test_process_compat.py +34 -0
  66. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_public_surfaces.py +0 -1
  67. expr_tracker-0.4.0/tests/test_reader_edge_cases.py +830 -0
  68. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_review_regressions.py +14 -24
  69. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_rule_lifecycle.py +0 -2
  70. expr_tracker-0.4.0/tests/test_rule_overrides.py +69 -0
  71. expr_tracker-0.4.0/tests/test_rule_quoted_names.py +40 -0
  72. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_run_backends.py +25 -6
  73. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_run_dir.py +1 -3
  74. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_scenarios.py +0 -5
  75. expr_tracker-0.4.0/tests/test_serialization_edge_cases.py +496 -0
  76. expr_tracker-0.4.0/tests/test_series_edge_cases.py +115 -0
  77. expr_tracker-0.4.0/tests/test_span_lifecycle.py +496 -0
  78. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_span_plugins.py +819 -13
  79. expr_tracker-0.4.0/tests/test_span_value_encoding.py +111 -0
  80. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_spans.py +159 -27
  81. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_step_commit.py +94 -7
  82. expr_tracker-0.4.0/tests/test_step_types.py +81 -0
  83. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_streams.py +1 -10
  84. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_stress.py +4 -4
  85. expr_tracker-0.4.0/tests/test_trace.py +853 -0
  86. expr_tracker-0.4.0/tests/test_trace_perf.py +230 -0
  87. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_trackio.py +2 -1
  88. expr_tracker-0.4.0/tests/test_unicode_records.py +126 -0
  89. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_value_encoding.py +1 -5
  90. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_wandb.py +3 -4
  91. expr_tracker-0.4.0/tests/test_writer_async.py +764 -0
  92. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_writer_buffer.py +1 -1
  93. expr_tracker-0.4.0/tests/test_writer_bytes.py +635 -0
  94. expr_tracker-0.4.0/tests/test_writer_diagnostics.py +556 -0
  95. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_writer_durability.py +44 -1
  96. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/uv.lock +4 -0
  97. expr_tracker-0.3.2/docs/guide/logging.md +0 -112
  98. expr_tracker-0.3.2/docs/guide/spans.md +0 -307
  99. expr_tracker-0.3.2/src/expr_tracker/history/writer.py +0 -406
  100. expr_tracker-0.3.2/src/expr_tracker/plugins.py +0 -269
  101. expr_tracker-0.3.2/src/expr_tracker/trace.py +0 -190
  102. expr_tracker-0.3.2/tests/test_trace.py +0 -421
  103. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/.github/workflows/docs.yaml +0 -0
  104. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/.gitignore +0 -0
  105. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/LICENSE +0 -0
  106. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/examples.md +0 -0
  107. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/getting-started.md +0 -0
  108. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/alerts.md +0 -0
  109. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/backends.md +0 -0
  110. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/cli.md +0 -0
  111. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/distributed.md +0 -0
  112. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/guide/history.md +0 -0
  113. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/docs/index.md +0 -0
  114. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/README.md +0 -0
  115. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/alert_rules.py +0 -0
  116. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/checkpoints.py +0 -0
  117. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/early_stopping.py +0 -0
  118. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/multiprocess_pipeline.py +0 -0
  119. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/profile_step.py +0 -0
  120. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/examples/quickstart.py +0 -0
  121. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/mkdocs.yml +0 -0
  122. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/__init__.py +0 -0
  123. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/_compat.py +0 -0
  124. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/backends/cards.py +0 -0
  125. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/engine.py +0 -0
  126. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  127. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  128. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  129. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/alerts/expr/parser.py +0 -0
  130. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/__init__.py +0 -0
  131. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/history/naming.py +0 -0
  132. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/py.typed +0 -0
  133. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/src/expr_tracker/types.py +0 -0
  134. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_delivery.py +0 -0
  135. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_alert_models.py +0 -0
  136. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_expr_builder.py +0 -0
  137. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_expr_eval.py +0 -0
  138. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_expr_parser.py +0 -0
  139. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_frame_codec_summary.py +0 -0
  140. {expr_tracker-0.3.2 → expr_tracker-0.4.0}/tests/test_integration.py +0 -0
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Python ${{ matrix.python }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ["3.10", "3.11", "3.12", "3.13"]
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v5
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v6
28
+ with:
29
+ enable-cache: true
30
+ python-version: ${{ matrix.python }}
31
+
32
+ - name: Install dependencies
33
+ run: uv sync --frozen --all-extras
34
+
35
+ - name: Lint
36
+ run: uv run --frozen ruff check src tests
37
+
38
+ - name: Test
39
+ run: uv run --frozen pytest -m "not slow and not benchmark"
@@ -10,8 +10,12 @@ permissions:
10
10
  contents: read
11
11
 
12
12
  jobs:
13
+ checks:
14
+ uses: ./.github/workflows/ci.yaml
15
+
13
16
  release:
14
17
  name: Build and publish to PyPI
18
+ needs: checks
15
19
  runs-on: ubuntu-latest
16
20
  permissions:
17
21
  # required for PyPI trusted publishing (OIDC)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.3.2
3
+ Version: 0.4.0
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/
@@ -25,9 +25,11 @@ Classifier: Topic :: System :: Monitoring
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.10
27
27
  Requires-Dist: click>=8.1.0
28
+ Requires-Dist: filelock>=3.12
28
29
  Requires-Dist: loguru>=0.7.3
29
30
  Requires-Dist: pydantic>=2.0
30
31
  Requires-Dist: python-dotenv>=1.0
32
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
31
33
  Provides-Extra: all
32
34
  Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
33
35
  Requires-Dist: pandas>=1.5; extra == 'all'
@@ -87,7 +89,7 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
87
89
  ## Install
88
90
 
89
91
  ```bash
90
- uv add expr_tracker # local-first: click, loguru, pydantic only
92
+ uv add expr_tracker # local-first; no remote backend required
91
93
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
92
94
  uv add "expr_tracker[trackio]" # mirror to trackio
93
95
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
@@ -39,7 +39,7 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
39
39
  ## Install
40
40
 
41
41
  ```bash
42
- uv add expr_tracker # local-first: click, loguru, pydantic only
42
+ uv add expr_tracker # local-first; no remote backend required
43
43
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
44
44
  uv add "expr_tracker[trackio]" # mirror to trackio
45
45
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
@@ -58,7 +58,7 @@ Run.log ──> Summary.observe(data) # last value per metric
58
58
  └──> HistoryStore.log
59
59
  │ encode once (jsonable_encoder)
60
60
  │ merge into the open row for the current step
61
- ▼ commit when the step advances / commit=True / finish / timeout
61
+ ▼ commit when the step advances / commit=True / finish / flush(commit_open=True)
62
62
  HistoryStore._emit
63
63
  ├── _store_row(record, line) (holds the store lock)
64
64
  │ ├── cache.append((row, step, line)) # ordinal allocated here
@@ -125,12 +125,13 @@ plain functions over a sequence, so they hold no lock and are tested directly.
125
125
 
126
126
  ### Rows versus steps
127
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.
128
+ A step normally occupies one physical row, but an explicit commit followed by
129
+ more data for that step writes a *patch line*, so one step can span several rows.
130
130
  The two are kept strictly separate in the read path:
131
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.
132
+ * `JsonlReader.tail_rows(n)` returns **physical rows**; `JsonlReader.tail(n)` selects
133
+ the most recently written **steps** and merges all their patches. Unique-step
134
+ files use reverse reads; arbitrary patch histories require scanning older data.
134
135
  * `HistoryStore._collect_tail(steps)` works in rows, then `_take_steps` merges and
135
136
  trims by *step*, so `get(n)` never returns a half-merged oldest row.
136
137
  * The cache stores each row's step next to its bytes, so `_view_tail` snapshots the
@@ -156,16 +157,38 @@ Disk fallback only happens once rows have been evicted or the run was resumed;
156
157
 
157
158
  | Lock / thread | Owner | Protects |
158
159
  | --- | --- | --- |
159
- | `HistoryStore._lock` (RLock) | store | open row, cache, series, row ordinal, writer enqueue |
160
+ | `HistoryStore._lifecycle_lock` | store | reinitialization and shutdown |
161
+ | `HistoryStore._write_lock` (RLock) | store | accepted write transactions and row admission |
162
+ | `HistoryStore._lock` (RLock) | store | open row, cache, series and row ordinal |
163
+ | notification condition | store | ordered commit tickets, delivery ownership and completion |
160
164
  | `JsonlWriter._lock` (RLock) | writer | buffer, index, meta fields |
161
165
  | `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 |
166
+ | reusable writer worker | writer | asynchronous span writes and timed buffer flushing; drained at shutdown |
164
167
  | `CompiledRule.lock` | engine | one rule's state machine transitions |
165
168
  | dispatch worker thread | dispatcher | drains the send queue; drained at exit |
166
169
  | watchdog thread | engine | evaluates time-based rules when no logs arrive |
167
170
 
168
- Lock order is always `_write_lock` `_lock`; nothing acquires them the other way.
171
+ Store writes enter the transaction lock before the store-state lock. Shutdown
172
+ rejects new writes, waits for accepted metric/span transactions, then flushes and
173
+ closes the writers before closing downstream sinks. Writer workers never acquire
174
+ store locks. Open rows have no timeout and no background commit thread.
175
+ Admission waits outside the store-state lock, so queue backpressure does not
176
+ block history reads. Rejected records do not consume cache row ordinals or reach
177
+ commit callbacks.
178
+ Read snapshots acquire the metric writer lock before the store-state lock, the
179
+ same order used when writer error handlers inspect history.
180
+
181
+ Commit notifications are staged in write order, then delivered after releasing
182
+ transaction/state locks. Committing callers wait for their own tickets; span-only
183
+ writes do not wait for unrelated callbacks. Delivery is serial and supports
184
+ recursive logging/flush, while lifecycle operations are rejected inside callbacks.
185
+ Reinitialization drains the old run's notifications before replacing its metadata.
186
+ Alert-series updates occur during ordered delivery, not admission, preventing
187
+ queued future rows from changing an earlier notification's scalar/window results.
188
+
189
+ Run/store/span/plugin objects are process-owned. Fork resets process-global
190
+ locks and the active run/stack in the child; inherited objects are rejected before
191
+ acquiring their locks, and inherited exit hooks do not flush parent data.
169
192
 
170
193
  ## Extension points
171
194
 
@@ -22,7 +22,7 @@ Three write stages:
22
22
 
23
23
  ```
24
24
  log() ──merge──> open row (current step, in memory)
25
- │ step advances / commit=True / flush / finish / max_open_seconds
25
+ │ step advances / commit=True / flush(commit_open=True) / finish
26
26
 
27
27
  write buffer (adaptive: buffer_size / buffer_interval / max_buffer_seconds)
28
28
 
@@ -45,10 +45,9 @@ log(metrics: dict, step: int | None = None, commit: bool | None = None)
45
45
  - `step_policy="monotonic"` (default) drops backward writes with a warning;
46
46
  `"allow"` accepts them and marks `meta.sorted = false`.
47
47
  - Writing the same key twice in one step is last-wins, warned once per key per run.
48
- - `max_open_seconds` (default 60s) commits an idle open row so a crash cannot lose
49
- it. If more data arrives for that step afterwards it is written as a **patch line**
50
- and `meta.has_duplicate_steps` is set; the reader merges by step, so correctness is
51
- unaffected.
48
+ - An open row has no time limit: elapsed time never commits it or advances the
49
+ step. If more data arrives for an explicitly committed step, it is written as a
50
+ **patch line** and `meta.has_duplicate_steps` is set; the reader merges by step.
52
51
 
53
52
  **The writer guarantees one line per step in ascending order; the reader assumes
54
53
  none of that** — it must cope with older files, stray writers and patch lines.
@@ -102,9 +101,11 @@ commit rules exist in one place regardless of which API produced the metrics.
102
101
  Two differences from a log call:
103
102
 
104
103
  - 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.
104
+ many times in one step: durations and counters sum, memory peaks take the
105
+ maximum, and utilization uses a duration-weighted mean;
106
+ - built-in durations and counts are already floats and ints, so they skip
107
+ `RecordCodec`. Optional plugin measurements and attributes are encoded
108
+ independently, keeping bare spans cheap without accepting unserializable data.
108
109
 
109
110
  Metrics are named `<measurement>/<span path>` — `time_ms/step/forward`, not
110
111
  `step/forward/time_ms`. A tracker UI groups by the segment before the first
@@ -116,12 +117,16 @@ group and leaves the caller's names alone.
116
117
  A span never commits a step: durations ride along with whatever `log()` commits,
117
118
  so timing a region adds no row. The tree, with timestamps and attributes, goes to
118
119
  `spans[.stream][.rankN].jsonl` through a second `JsonlWriter`, enqueued rather
119
- than appended so a span does not pay for a flush decision of its own.
120
+ than committed as metric rows. That writer's reusable background worker flushes completed
121
+ spans even if no further spans or metrics arrive, without advancing the step.
120
122
 
121
123
  The committed history row is also the fan-out boundary. Summary, alerts and
122
124
  remote backends consume the exact row emitted by `HistoryStore`, rather than the
123
125
  individual `log()` calls that assembled it. Span and plugin metrics therefore
124
126
  reach every sink without a second metric path.
127
+ Commit tickets preserve that order while callbacks execute outside the transaction
128
+ lock. A slow sink blocks its committing callers, not unrelated span transactions;
129
+ shutdown and reinitialization drain notifications before closing or replacing sinks.
125
130
 
126
131
  Plugins hang off the same two hooks. `start` runs before the clock starts and
127
132
  `end` after it stops, so a plugin never inflates the duration it reports on; what
@@ -130,12 +135,12 @@ means a plugin needs no knowledge of the store, the codec or the accumulate rule
130
135
  A plugin that raises is logged and dropped: a measurement must not break the
131
136
  thing it measures.
132
137
 
133
- Peak-memory plugins have to fight the fact that `torch.cuda.max_memory_allocated`
134
- is one global high-water mark. Resetting it per span is the only way to attribute
135
- a peak, but a child's reset destroys its parent's history. `TorchMemory` keeps a
136
- per-thread stack and, as each span closes, hands its parent back the peak the
137
- parent had reached before that child opened, so `max(counter, carried)` is
138
- correct at every level.
138
+ Peak-memory plugins have to account for `torch.cuda.max_memory_allocated` being a
139
+ global high-water mark. `TorchMemory` coordinates active spans per device and
140
+ preserves every existing span's peak before a new span resets the counter.
141
+ CPU measurements are paired by span identity rather than a thread stack.
142
+ NVML samplers update bounded per-active-span aggregates, so long spans retain
143
+ their full sampling window without keeping every historical sample.
139
144
 
140
145
  GPU utilisation cannot be attributed by reading NVML at a span boundary: the
141
146
  counter is a rolling average over NVML's own window, so for a 3 ms span it
@@ -451,8 +456,8 @@ Read these before changing the modules they mention.
451
456
  dispatch does not advance the state.
452
457
  9. **Delivery failures are only logged** (`fail_silently`); configuration errors
453
458
  raise at configuration time.
454
- 10. **Open-row timers carry a generation token**: once the step advances, an old
455
- timer must not commit the new open row.
459
+ 10. **Elapsed time never commits an open row**: a training step can run arbitrarily
460
+ long. Writer timers only flush buffered records; they do not advance the step.
456
461
 
457
462
  ---
458
463
 
@@ -26,6 +26,12 @@ Artifacts are **deduplicated by content**: logging the same files again reuses t
26
26
  existing version rather than creating a new one, while any new aliases are still
27
27
  recorded against it.
28
28
 
29
+ Publishers sharing a store serialize version allocation, file copying, and index
30
+ updates with a filesystem lock, including when they run in separate processes.
31
+ On POSIX, filenames represented using Python's filesystem-surrogate characters
32
+ round-trip through artifact indexes and retain distinct digests from literal
33
+ backslash-escape names.
34
+
29
35
  ```python
30
36
  a = et.log_artifact("ckpt.pt", name="model") # v0
31
37
  b = et.log_artifact("ckpt.pt", name="model", aliases=["best"])
@@ -0,0 +1,180 @@
1
+ # Logging metrics
2
+
3
+ ## One step, one line
4
+
5
+ `log()` merges metrics into an **open row** for the current step. The row is written
6
+ when it is committed, so several `log()` calls for one step become one line:
7
+
8
+ ```python
9
+ et.log({"loss": 1.0}, step=5)
10
+ et.log({"acc": 0.9}, step=5) # merged into the same row
11
+ et.log({"loss": 0.9}, step=6) # the step advanced -> step 5 is committed
12
+ ```
13
+
14
+ This matters because training and evaluation usually run at different points in the
15
+ loop but describe the same step.
16
+
17
+ ## Commit semantics
18
+
19
+ `log(data, step=None, commit=None)` mirrors `wandb.log`.
20
+
21
+ | Call | `commit` default | Behaviour |
22
+ | --- | --- | --- |
23
+ | `log(d)` | `True` | Merge, commit, advance the step |
24
+ | `log(d, commit=False)` | — | Merge only |
25
+ | `log(d, step=N)` | `False` | Commit when the step advances |
26
+ | `log(d, step=N, commit=True)` | — | Commit immediately |
27
+
28
+ An open row is also committed by `finish()`, by the process exit hook, and by
29
+ `run.history.flush(commit_open=True)`. It never commits just because time passes,
30
+ so a long training step stays on one row. A plain `run.history.flush()` only writes
31
+ already committed rows; it does not commit the open row.
32
+
33
+ `finish()` waits for accepted writes and their commit callbacks before closing
34
+ downstream sinks. Concurrent `run.finish()` callers wait for the same shutdown
35
+ completion; a failed shutdown is surfaced to waiters rather than reported as
36
+ success. Call it outside commit/print and asynchronous alert-delivery callbacks;
37
+ trying to finish or reinitialize from such a callback raises `RuntimeError` to
38
+ avoid reentrant shutdown.
39
+ Lifecycle calls are also rejected from writer diagnostic callbacks, history
40
+ writer workers, unfinished write transactions, or an already-running lifecycle
41
+ operation; use the caller thread after the logging operation returns.
42
+ Writer diagnostics are emitted after writer locks are released and accounting is
43
+ consistent, allowing error sinks to inspect history without locking out writers.
44
+
45
+ Commit callbacks run outside the write transaction lock. A slow backend or
46
+ metric-print callback therefore does not block another thread recording an
47
+ uncommitted span. Committing callers still wait for their own notifications,
48
+ which are delivered serially in commit order; recursive logging and flush are
49
+ supported. No asynchronous callback worker or unbounded work queue is created.
50
+
51
+ Delivery may occur on another committing caller's thread. Context variables
52
+ come from the originating commit, but callbacks must not assume thread identity.
53
+ `flush()` and `finish()` wait for outstanding notifications before returning.
54
+ Alert-series windows advance with notification delivery, so a newer queued
55
+ commit cannot leak into an earlier alert. Ordinary history queries can already
56
+ see those accepted rows. Recursive logging advances the live alert series; when
57
+ an outer callback resumes, it sees the nested delivered rows, not a frozen snapshot.
58
+
59
+ ```python
60
+ et.log({"train/loss": 1.0}, commit=False)
61
+ et.log({"train/grad_norm": 2.0}, commit=False)
62
+ et.log({"lr": 3e-4}) # commits all three as one step
63
+ ```
64
+
65
+ Once an explicit step is open, subsequent calls without `step=` (including
66
+ completed spans) reuse it:
67
+
68
+ ```python
69
+ et.log({"train/loss": 1.0}, step=100)
70
+ et.log({"lr": 3e-4}) # commits step 100; the next step is 101
71
+ ```
72
+
73
+ ## Timestamps and liveness
74
+
75
+ Every row's `_time` is a Unix timestamp in seconds, captured when its open row
76
+ was first created. Later logs merged into that row do not replace the timestamp,
77
+ and committing or flushing it does not change it.
78
+
79
+ `no_data(duration)` uses the actual time the latest metric row was accepted,
80
+ not its `_time`, so completing a long training step does not immediately look
81
+ stale. Uncommitted updates, trace-only events and rejected rows do not refresh
82
+ this commit clock. `age(metric)` uses the sample timestamp from the row instead.
83
+ On resume, an invalid or non-finite timestamp is skipped for alert-window
84
+ backfill with a warning, rather than preventing later valid samples from being
85
+ restored. The original history rows remain available for inspection.
86
+
87
+ ## Step ordering
88
+
89
+ Steps accept Python integers and integer-index values such as NumPy integer
90
+ scalars. They are normalized to native integers before encoding. Strings and
91
+ floating-point steps are rejected before payload consumption or open-row mutation,
92
+ so an invalid step cannot poison later logs.
93
+
94
+ By default steps must not go backwards. A backward step is dropped with a warning and
95
+ reaches no sink — not the file, not the summary, not the alert rules, not wandb:
96
+
97
+ ```python
98
+ et.log({"loss": 1.0}, step=10)
99
+ et.log({"loss": 2.0}, step=3) # dropped, with a warning
100
+ ```
101
+
102
+ Pass `step_policy="allow"` to keep out-of-order writes. The file then contains
103
+ repeated steps, and the reader merges them on the way out.
104
+
105
+ !!! note
106
+ With `step_policy="allow"`, `history(n)` returns the *n* most recently **written**
107
+ steps rather than the *n* highest-numbered ones. `history(-1)` and `step_range`
108
+ are always step-ordered. A positive `n` combined with `step_range` selects the
109
+ highest matching steps. Live and offline queries use the same selection rules.
110
+
111
+ Patch lines are merged in full even when separated by other steps or split across
112
+ cache and disk. Reads pin an append-prefix boundary and do not flush unrelated
113
+ span records or wait for commit callbacks. A damaged or unreadable metadata
114
+ sidecar produces a warning and a scan of the readable data file.
115
+
116
+ ## What you can log
117
+
118
+ Anything JSON-serialisable, plus the things ML code actually holds:
119
+
120
+ ```python
121
+ et.log({
122
+ "loss": np.float32(0.5), # numpy scalars -> python scalars
123
+ "grads": np.array([1.0, 2.0]), # arrays -> lists
124
+ "config": PydanticModel(), # pydantic models -> dicts
125
+ "when": datetime.now(), # datetime -> ISO string
126
+ "path": Path("ckpt.pt"), # Path -> str
127
+ "stage": Stage.TRAIN, # Enum -> its value
128
+ })
129
+ ```
130
+
131
+ `NaN` and `Infinity` are preserved as-is, because a NaN loss is a signal and
132
+ `isnan()` in an alert rule depends on it. Python and pandas read this back; strict
133
+ JSON parsers such as `jq` do not.
134
+
135
+ A value that cannot be encoded falls back to `repr()` with a one-time warning, per
136
+ field — one bad value never costs you the rest of the row.
137
+ JSON integers too large for floating-point alert buffers remain unchanged in
138
+ history. They register their metric name but are skipped by numeric series with
139
+ a warning; neighboring metrics and their alerts continue to work.
140
+ Filesystem-surrogate characters in strings and paths are written as JSON escapes
141
+ rather than invalid UTF-8, so they do not discard neighboring metrics. Ordinary
142
+ Unicode remains readable UTF-8. Config replacement is atomic: a failed write
143
+ preserves the previous config file.
144
+
145
+ Metric names are free-form. `/` is conventional for grouping (`train/loss`), and the
146
+ alert language accepts `train.loss` as a shorthand for it.
147
+
148
+ ## Reserved keys
149
+
150
+ `_step` and `_time` are written by the tracker. If you log them they are ignored, with
151
+ a warning.
152
+
153
+ ## The summary
154
+
155
+ Every metric's last value is tracked automatically. Explicit assignments win and are
156
+ never overwritten:
157
+
158
+ ```python
159
+ et.log({"acc": 0.91})
160
+ et.summary()["best_acc"] = 0.93 # survives later log() calls
161
+ dict(et.summary()) # {"acc": 0.91, "best_acc": 0.93}
162
+ ```
163
+
164
+ The summary is saved on `finish()` and by the exit hook if the process dies first.
165
+
166
+ ## Printing to screen
167
+
168
+ ```python
169
+ et.init(..., print_to_screen=True) # print every committed row
170
+ et.init(..., print_to_screen=True, print_handle=logger.info)
171
+ ```
172
+
173
+ Only committed rows are printed, so you see one line per step. A failing handle is
174
+ logged and never interrupts training.
175
+
176
+ ## Cost
177
+
178
+ `log()` costs about 26&nbsp;µs (p50) without rules and 74&nbsp;µs with five rules,
179
+ around 33,000 calls/s. The write buffer batches lines, and the p99 stays under
180
+ 5&nbsp;ms so a flush cannot stall the loop.