exptrail 0.1.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.
exptrail-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaden Isaac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ recursive-include tests *.py
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: exptrail
3
+ Version: 0.1.0
4
+ Summary: Local, framework-agnostic ML experiment logging that keeps README numbers verifiable.
5
+ Author: Jaden Isaac
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jadenisaac2005/exptrail
8
+ Project-URL: Source, https://github.com/jadenisaac2005/exptrail
9
+ Project-URL: Issues, https://github.com/jadenisaac2005/exptrail/issues
10
+ Keywords: machine-learning,experiment-tracking,reproducibility,logging,mlops
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Classifier: Topic :: System :: Logging
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: plot
28
+ Requires-Dist: matplotlib; extra == "plot"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest; extra == "dev"
31
+ Requires-Dist: pytest-cov; extra == "dev"
32
+ Requires-Dist: matplotlib; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # exptrail
36
+
37
+ Local, framework-agnostic experiment logging so that **every number in your README can be traced to a saved run and re-checked**.
38
+
39
+ README results tables drift: configs get overwritten, loss curves aren't saved, and nobody remembers which commit produced 0.9807. `exptrail` writes each run to its own folder (config, per-step metrics, final numbers, git commit + diff, environment), generates the README table *from those folders*, and `exptrail verify` fails CI if the table and the runs disagree.
40
+
41
+ - Standard library only (Python 3.10+). `matplotlib` optional, for `plot`.
42
+ - Works with NumPy-from-scratch, PyTorch, sklearn, anything: you just pass numbers.
43
+ - Works without git (e.g. Colab): recorded as `no-git` with a warning, never an error.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install exptrail # or: pip install "exptrail[plot]"
49
+ ```
50
+
51
+ ## Log a run
52
+
53
+ ```python
54
+ from exptrail import Run
55
+
56
+ with Run(name="momentum-lr0.01", config={"lr": 0.01, "momentum": 0.9},
57
+ tags=["digits"], seeds={"numpy": 0}) as run:
58
+ for epoch in range(30):
59
+ ...
60
+ run.log(step=epoch, train_loss=loss, val_acc=acc) # appended + flushed immediately
61
+ run.summary(test_acc=0.9807) # the numbers you report
62
+ run.save_artifact("model.npz") # copied into the run folder
63
+ ```
64
+
65
+ Or as a decorator. The function's arguments become the config, a `run` parameter receives the active run, and a returned dict becomes the summary:
66
+
67
+ ```python
68
+ from exptrail import track
69
+
70
+ @track(name="mlp")
71
+ def train(lr=0.01, epochs=30, run=None):
72
+ ...
73
+ return {"test_acc": acc}
74
+ ```
75
+
76
+ If one `with` block doesn't fit your script (a notebook, a training loop spread over several functions), start and finish the run yourself:
77
+
78
+ ```python
79
+ run = Run(name="momentum-lr0.01", config=cfg).start()
80
+ ... # train, run.log(...), run.summary(...)
81
+ run.finish() # or run.finish("failed"); calling it twice is harmless
82
+ ```
83
+
84
+ `with Run(...)`, `@track` and `start()`/`finish()` share one code path. Wrap **the whole job**, data loading and evaluation included: `duration_s` only covers the run's own span, and anything that crashes outside it isn't recorded.
85
+
86
+ Each run writes `runs/<UTC timestamp>_<name>/` (override with `root=` or `$EXPTRAIL_ROOT`):
87
+
88
+ | file | contents |
89
+ |---|---|
90
+ | `config.json` | the config you passed |
91
+ | `metrics.csv` | one row per `log()` call: `step`, then your metrics |
92
+ | `summary.json` | final numbers, written on every `summary()` call |
93
+ | `meta.json` | status (see [Run status](#run-status)), git commit/branch/remote/dirty flag (and which files set it), Python + numpy/torch/scikit-learn versions, hostname, argv, cwd, seeds, start/end time, and a `liveness` record (hostname, pid, process start time, boot ID, heartbeat interval) |
94
+ | `heartbeat` | touched every 30 s while the run is alive; removed when it finishes |
95
+ | `git_diff.patch` | uncommitted changes (tracked edits plus new untracked code files), only if there were any |
96
+ | `traceback.txt` | only if the run raised |
97
+ | `artifacts/` | anything passed to `save_artifact()` |
98
+
99
+ Paths in `meta.json` (`cwd`, `python_executable`, `argv`, `git.root`) have your home directory replaced with `~`, so committed runs don't leak your username. Pass `Run(..., redact_paths=False)` to keep them verbatim. Credentials in the git remote URL are always stripped.
100
+
101
+ A dirty working tree triggers a loud `DirtyTreeWarning`: that run's numbers can't be reproduced from a commit, so commit first if you plan to report them.
102
+
103
+ ### What counts as dirty
104
+
105
+ - **Modified tracked files** make a run dirty, unless they match `dirty_ignore`.
106
+ - **Untracked files** make a run dirty only if they look like code, i.e. match `untracked_code`. The default patterns are `*.py`, `*.ipynb`, `*.pyx`, `*.yaml`, `*.yml` and `*.toml`. Other untracked files (CSVs, logs, model outputs, the `runs/` folder) are ignored entirely. Files your `.gitignore` excludes never count, and neither does anything inside a virtualenv (a folder containing `pyvenv.cfg`).
107
+ - **`dirty_ignore`** lists glob patterns for changes that shouldn't mark a run dirty, such as a results log you edit by hand. Those changes don't set the flag, don't warn, and don't fail `verify --strict`, but they are still written to `git_diff.patch`, so the record stays complete.
108
+
109
+ ```python
110
+ Run("momentum-lr0.01", dirty_ignore=["*.md", "submissions/**"],
111
+ untracked_code=["*.py", "*.yaml"]) # replaces the default code patterns
112
+ ```
113
+
114
+ A pattern without `/` (`*.md`) matches that file name in any directory. A pattern with `/` (`submissions/**`) matches the path from the repo root, and a trailing `/` (`submissions/`) means everything under that folder.
115
+
116
+ `git_diff.patch` includes untracked code files as new-file diffs, so `git apply git_diff.patch` on the recorded commit rebuilds the tree the run used. Your git index is never touched: the files are added to a temporary copy of it. Untracked files over 1 MB are recorded by path, size and sha256 in `meta.json` (and in the patch header, when there is a patch) instead of being inlined. If such a file is the only change, no `git_diff.patch` is written, since there would be nothing for `git apply` to apply.
117
+
118
+ `meta.json` records what was found under `git`: `dirty_files` (`tracked` and `untracked` lists of the files that made the run dirty), `ignored_files` (changed files that matched `dirty_ignore`), `changed_files` (every changed file that was recorded, in the patch or by hash), `untracked_too_large` (hashed files, if any), and the `dirty_ignore`/`untracked_code` patterns that were in effect.
119
+
120
+ If listing untracked files fails (it times out after 60 s, e.g. on a huge un-ignored data folder or a repo on a network drive), the commit, branch and tracked changes are still recorded, `untracked_scan` says why the scan failed, and the run is treated as dirty because it can't be shown to be clean.
121
+
122
+ On Python 3.11+ you can set the same keys once for the whole repo in `pyproject.toml`. Keyword arguments override it:
123
+
124
+ ```toml
125
+ [tool.exptrail]
126
+ dirty_ignore = ["*.md", "submissions/**"]
127
+ untracked_code = ["*.py", "*.ipynb", "*.yaml"]
128
+ ```
129
+
130
+ Python 3.10 has no built-in TOML parser and exptrail has no dependencies, so on 3.10 a `[tool.exptrail]` section is ignored with a one-time `ConfigWarning`. Pass the keyword arguments instead.
131
+
132
+ ## Run status
133
+
134
+ | status | meaning |
135
+ |---|---|
136
+ | `finished` | `finish()` was called, or the `with` block / `@track` function returned normally |
137
+ | `failed` | an exception escaped the run; the traceback is in `traceback.txt` |
138
+ | `interrupted` | Ctrl-C, SIGTERM, or the interpreter exited while the run was started but not finished (`status_reason` says which) |
139
+ | `running` | still going, as far as the evidence shows |
140
+ | `dead` | certainly gone: the process was killed (SIGKILL, the OOM killer), the pid now belongs to another process, or the machine has rebooted |
141
+ | `stale` | probably gone: no heartbeat for a while |
142
+
143
+ The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab disconnect, a laptop losing power) run no cleanup code, so nothing can write "died" into `meta.json`. Instead, a run records who it is when it starts (hostname, boot ID, pid, process start time) and a background thread touches its `heartbeat` file every 30 seconds, whether or not you call `log()`. `ls`, `show`, `compare` and `verify` turn a stored `running` into one of the statuses above when they read the run, and they never write to the run folder:
144
+
145
+ - **On the machine the run started on**, the answer is certain: the process is alive (`running`) or it isn't (`dead (pid gone)`, `dead (pid reused)`, `dead (machine rebooted)`).
146
+ - **From another machine** (a run folder on a shared drive, or synced back from a cluster), or where the process can't be inspected (Windows, runs from older exptrail versions), only the heartbeat's age is available. If it is older than 3 heartbeat intervals the run shows as `stale (last heartbeat 14m ago)`. That is only *probable*: the other machine may be alive but cut off from the shared drive, or its clock may disagree with yours.
147
+
148
+ Heartbeat age is the `heartbeat` file's modification time (mtime). Copying a run folder resets it: `git clone` and `git checkout`, `cp` without `-p`, and cloud sync (Google Drive, Dropbox) all give the file a fresh mtime. So a crashed run folder that was copied or committed and then read on another machine can show as `running (on gpu-1, last heartbeat 5s ago)` for a few minutes, until the copied heartbeat goes stale. `verify` still fails such a run, because its status isn't `finished`.
149
+
150
+ Change the heartbeat interval with `Run(..., heartbeat_s=60)` or `$EXPTRAIL_HEARTBEAT_S`, and the staleness threshold with `$EXPTRAIL_STALE_S` (seconds). On SIGTERM a run is marked `interrupted` before the process exits as it normally would; exptrail only installs its handler when there isn't one already, and restores the previous handler at `finish()`.
151
+
152
+ To make a `dead` or `stale` status permanent, record what happened by hand:
153
+
154
+ ```bash
155
+ exptrail mark momentum-lr0.01 interrupted # or failed / finished; notes that it was marked by hand, and when
156
+ ```
157
+
158
+ `mark` refuses a run that still shows as `running` unless you pass `--force`.
159
+
160
+ ## CLI
161
+
162
+ ```bash
163
+ exptrail ls # name, status, key metrics, commit (* = dirty)
164
+ exptrail show momentum-lr0.01 # config + summary + meta
165
+ exptrail compare sgd-lr0.1 momentum-lr0.01 # config diff + metric table
166
+ exptrail plot sgd-lr0.1 momentum-lr0.01 --metric val_acc -o val_acc.png
167
+ exptrail table --metric test_acc --runs '*digits*' --readme README.md
168
+ exptrail verify README.md # non-zero exit on any mismatch
169
+ exptrail mark momentum-lr0.01 interrupted # make a dead/stale status permanent
170
+ ```
171
+
172
+ Runs can be referred to by folder name, path, run name (latest wins) or a unique substring. `--root DIR` points any command at another runs directory.
173
+
174
+ **`table`** writes a markdown table between `<!-- results:start -->` and `<!-- results:end -->` marker lines (appended if the markers aren't there yet). Markers only count when each is on a line of its own, and there must be exactly one pair. Each row links to the run folder and commit. Re-running it replaces the block; it never duplicates it. Add columns with repeatable `--metric` and `--config` flags. Floats are shown to `--precision` decimals (default 4). Only finished runs are included unless you pass `--include-failed`. Links to run folders are relative to the README; pass `--absolute-links` to link them on GitHub/GitLab instead (needed for a README shown on PyPI, which can't resolve relative links).
175
+
176
+ **`verify`** re-reads every run linked from the block. It fails if a displayed metric doesn't match `summary.json` at the displayed precision, if a config value doesn't exactly match `config.json`, if the commit differs from `meta.json`, if a linked run folder is missing, or if a linked run isn't `finished` (running, dead, stale, failed or interrupted; the message shows the status). Runs that were dirty or git-less produce warnings, and `--strict` turns them into failures. Put it in CI:
177
+
178
+ ```yaml
179
+ - run: pip install exptrail && exptrail verify README.md
180
+ ```
181
+
182
+ For links in the table to work on GitHub, commit the run folders you report.
183
+
184
+ ## Example
185
+
186
+ [`examples/digits`](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits) trains a NumPy MLP on sklearn's digits with 3 optimiser configs and regenerates its README table. Its results, generated with `exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md`:
187
+
188
+ <!-- results:start -->
189
+ <!-- Generated by exptrail. Do not edit by hand; `exptrail verify` checks these numbers. -->
190
+ <!-- command: exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md -->
191
+ | run | lr | momentum | test_acc | commit |
192
+ |---|---|---|---|---|
193
+ | [momentum-lr0.01](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_momentum-lr0.01/) | 0.01 | 0.9 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
194
+ | [sgd-lr0.1](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_sgd-lr0.1/) | 0.1 | 0.0 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
195
+ | [momentum-lr0.05](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055910_momentum-lr0.05/) | 0.05 | 0.9 | 0.9694 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
196
+ <!-- results:end -->
197
+
198
+ ## License
199
+
200
+ MIT
@@ -0,0 +1,166 @@
1
+ # exptrail
2
+
3
+ Local, framework-agnostic experiment logging so that **every number in your README can be traced to a saved run and re-checked**.
4
+
5
+ README results tables drift: configs get overwritten, loss curves aren't saved, and nobody remembers which commit produced 0.9807. `exptrail` writes each run to its own folder (config, per-step metrics, final numbers, git commit + diff, environment), generates the README table *from those folders*, and `exptrail verify` fails CI if the table and the runs disagree.
6
+
7
+ - Standard library only (Python 3.10+). `matplotlib` optional, for `plot`.
8
+ - Works with NumPy-from-scratch, PyTorch, sklearn, anything: you just pass numbers.
9
+ - Works without git (e.g. Colab): recorded as `no-git` with a warning, never an error.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install exptrail # or: pip install "exptrail[plot]"
15
+ ```
16
+
17
+ ## Log a run
18
+
19
+ ```python
20
+ from exptrail import Run
21
+
22
+ with Run(name="momentum-lr0.01", config={"lr": 0.01, "momentum": 0.9},
23
+ tags=["digits"], seeds={"numpy": 0}) as run:
24
+ for epoch in range(30):
25
+ ...
26
+ run.log(step=epoch, train_loss=loss, val_acc=acc) # appended + flushed immediately
27
+ run.summary(test_acc=0.9807) # the numbers you report
28
+ run.save_artifact("model.npz") # copied into the run folder
29
+ ```
30
+
31
+ Or as a decorator. The function's arguments become the config, a `run` parameter receives the active run, and a returned dict becomes the summary:
32
+
33
+ ```python
34
+ from exptrail import track
35
+
36
+ @track(name="mlp")
37
+ def train(lr=0.01, epochs=30, run=None):
38
+ ...
39
+ return {"test_acc": acc}
40
+ ```
41
+
42
+ If one `with` block doesn't fit your script (a notebook, a training loop spread over several functions), start and finish the run yourself:
43
+
44
+ ```python
45
+ run = Run(name="momentum-lr0.01", config=cfg).start()
46
+ ... # train, run.log(...), run.summary(...)
47
+ run.finish() # or run.finish("failed"); calling it twice is harmless
48
+ ```
49
+
50
+ `with Run(...)`, `@track` and `start()`/`finish()` share one code path. Wrap **the whole job**, data loading and evaluation included: `duration_s` only covers the run's own span, and anything that crashes outside it isn't recorded.
51
+
52
+ Each run writes `runs/<UTC timestamp>_<name>/` (override with `root=` or `$EXPTRAIL_ROOT`):
53
+
54
+ | file | contents |
55
+ |---|---|
56
+ | `config.json` | the config you passed |
57
+ | `metrics.csv` | one row per `log()` call: `step`, then your metrics |
58
+ | `summary.json` | final numbers, written on every `summary()` call |
59
+ | `meta.json` | status (see [Run status](#run-status)), git commit/branch/remote/dirty flag (and which files set it), Python + numpy/torch/scikit-learn versions, hostname, argv, cwd, seeds, start/end time, and a `liveness` record (hostname, pid, process start time, boot ID, heartbeat interval) |
60
+ | `heartbeat` | touched every 30 s while the run is alive; removed when it finishes |
61
+ | `git_diff.patch` | uncommitted changes (tracked edits plus new untracked code files), only if there were any |
62
+ | `traceback.txt` | only if the run raised |
63
+ | `artifacts/` | anything passed to `save_artifact()` |
64
+
65
+ Paths in `meta.json` (`cwd`, `python_executable`, `argv`, `git.root`) have your home directory replaced with `~`, so committed runs don't leak your username. Pass `Run(..., redact_paths=False)` to keep them verbatim. Credentials in the git remote URL are always stripped.
66
+
67
+ A dirty working tree triggers a loud `DirtyTreeWarning`: that run's numbers can't be reproduced from a commit, so commit first if you plan to report them.
68
+
69
+ ### What counts as dirty
70
+
71
+ - **Modified tracked files** make a run dirty, unless they match `dirty_ignore`.
72
+ - **Untracked files** make a run dirty only if they look like code, i.e. match `untracked_code`. The default patterns are `*.py`, `*.ipynb`, `*.pyx`, `*.yaml`, `*.yml` and `*.toml`. Other untracked files (CSVs, logs, model outputs, the `runs/` folder) are ignored entirely. Files your `.gitignore` excludes never count, and neither does anything inside a virtualenv (a folder containing `pyvenv.cfg`).
73
+ - **`dirty_ignore`** lists glob patterns for changes that shouldn't mark a run dirty, such as a results log you edit by hand. Those changes don't set the flag, don't warn, and don't fail `verify --strict`, but they are still written to `git_diff.patch`, so the record stays complete.
74
+
75
+ ```python
76
+ Run("momentum-lr0.01", dirty_ignore=["*.md", "submissions/**"],
77
+ untracked_code=["*.py", "*.yaml"]) # replaces the default code patterns
78
+ ```
79
+
80
+ A pattern without `/` (`*.md`) matches that file name in any directory. A pattern with `/` (`submissions/**`) matches the path from the repo root, and a trailing `/` (`submissions/`) means everything under that folder.
81
+
82
+ `git_diff.patch` includes untracked code files as new-file diffs, so `git apply git_diff.patch` on the recorded commit rebuilds the tree the run used. Your git index is never touched: the files are added to a temporary copy of it. Untracked files over 1 MB are recorded by path, size and sha256 in `meta.json` (and in the patch header, when there is a patch) instead of being inlined. If such a file is the only change, no `git_diff.patch` is written, since there would be nothing for `git apply` to apply.
83
+
84
+ `meta.json` records what was found under `git`: `dirty_files` (`tracked` and `untracked` lists of the files that made the run dirty), `ignored_files` (changed files that matched `dirty_ignore`), `changed_files` (every changed file that was recorded, in the patch or by hash), `untracked_too_large` (hashed files, if any), and the `dirty_ignore`/`untracked_code` patterns that were in effect.
85
+
86
+ If listing untracked files fails (it times out after 60 s, e.g. on a huge un-ignored data folder or a repo on a network drive), the commit, branch and tracked changes are still recorded, `untracked_scan` says why the scan failed, and the run is treated as dirty because it can't be shown to be clean.
87
+
88
+ On Python 3.11+ you can set the same keys once for the whole repo in `pyproject.toml`. Keyword arguments override it:
89
+
90
+ ```toml
91
+ [tool.exptrail]
92
+ dirty_ignore = ["*.md", "submissions/**"]
93
+ untracked_code = ["*.py", "*.ipynb", "*.yaml"]
94
+ ```
95
+
96
+ Python 3.10 has no built-in TOML parser and exptrail has no dependencies, so on 3.10 a `[tool.exptrail]` section is ignored with a one-time `ConfigWarning`. Pass the keyword arguments instead.
97
+
98
+ ## Run status
99
+
100
+ | status | meaning |
101
+ |---|---|
102
+ | `finished` | `finish()` was called, or the `with` block / `@track` function returned normally |
103
+ | `failed` | an exception escaped the run; the traceback is in `traceback.txt` |
104
+ | `interrupted` | Ctrl-C, SIGTERM, or the interpreter exited while the run was started but not finished (`status_reason` says which) |
105
+ | `running` | still going, as far as the evidence shows |
106
+ | `dead` | certainly gone: the process was killed (SIGKILL, the OOM killer), the pid now belongs to another process, or the machine has rebooted |
107
+ | `stale` | probably gone: no heartbeat for a while |
108
+
109
+ The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab disconnect, a laptop losing power) run no cleanup code, so nothing can write "died" into `meta.json`. Instead, a run records who it is when it starts (hostname, boot ID, pid, process start time) and a background thread touches its `heartbeat` file every 30 seconds, whether or not you call `log()`. `ls`, `show`, `compare` and `verify` turn a stored `running` into one of the statuses above when they read the run, and they never write to the run folder:
110
+
111
+ - **On the machine the run started on**, the answer is certain: the process is alive (`running`) or it isn't (`dead (pid gone)`, `dead (pid reused)`, `dead (machine rebooted)`).
112
+ - **From another machine** (a run folder on a shared drive, or synced back from a cluster), or where the process can't be inspected (Windows, runs from older exptrail versions), only the heartbeat's age is available. If it is older than 3 heartbeat intervals the run shows as `stale (last heartbeat 14m ago)`. That is only *probable*: the other machine may be alive but cut off from the shared drive, or its clock may disagree with yours.
113
+
114
+ Heartbeat age is the `heartbeat` file's modification time (mtime). Copying a run folder resets it: `git clone` and `git checkout`, `cp` without `-p`, and cloud sync (Google Drive, Dropbox) all give the file a fresh mtime. So a crashed run folder that was copied or committed and then read on another machine can show as `running (on gpu-1, last heartbeat 5s ago)` for a few minutes, until the copied heartbeat goes stale. `verify` still fails such a run, because its status isn't `finished`.
115
+
116
+ Change the heartbeat interval with `Run(..., heartbeat_s=60)` or `$EXPTRAIL_HEARTBEAT_S`, and the staleness threshold with `$EXPTRAIL_STALE_S` (seconds). On SIGTERM a run is marked `interrupted` before the process exits as it normally would; exptrail only installs its handler when there isn't one already, and restores the previous handler at `finish()`.
117
+
118
+ To make a `dead` or `stale` status permanent, record what happened by hand:
119
+
120
+ ```bash
121
+ exptrail mark momentum-lr0.01 interrupted # or failed / finished; notes that it was marked by hand, and when
122
+ ```
123
+
124
+ `mark` refuses a run that still shows as `running` unless you pass `--force`.
125
+
126
+ ## CLI
127
+
128
+ ```bash
129
+ exptrail ls # name, status, key metrics, commit (* = dirty)
130
+ exptrail show momentum-lr0.01 # config + summary + meta
131
+ exptrail compare sgd-lr0.1 momentum-lr0.01 # config diff + metric table
132
+ exptrail plot sgd-lr0.1 momentum-lr0.01 --metric val_acc -o val_acc.png
133
+ exptrail table --metric test_acc --runs '*digits*' --readme README.md
134
+ exptrail verify README.md # non-zero exit on any mismatch
135
+ exptrail mark momentum-lr0.01 interrupted # make a dead/stale status permanent
136
+ ```
137
+
138
+ Runs can be referred to by folder name, path, run name (latest wins) or a unique substring. `--root DIR` points any command at another runs directory.
139
+
140
+ **`table`** writes a markdown table between `<!-- results:start -->` and `<!-- results:end -->` marker lines (appended if the markers aren't there yet). Markers only count when each is on a line of its own, and there must be exactly one pair. Each row links to the run folder and commit. Re-running it replaces the block; it never duplicates it. Add columns with repeatable `--metric` and `--config` flags. Floats are shown to `--precision` decimals (default 4). Only finished runs are included unless you pass `--include-failed`. Links to run folders are relative to the README; pass `--absolute-links` to link them on GitHub/GitLab instead (needed for a README shown on PyPI, which can't resolve relative links).
141
+
142
+ **`verify`** re-reads every run linked from the block. It fails if a displayed metric doesn't match `summary.json` at the displayed precision, if a config value doesn't exactly match `config.json`, if the commit differs from `meta.json`, if a linked run folder is missing, or if a linked run isn't `finished` (running, dead, stale, failed or interrupted; the message shows the status). Runs that were dirty or git-less produce warnings, and `--strict` turns them into failures. Put it in CI:
143
+
144
+ ```yaml
145
+ - run: pip install exptrail && exptrail verify README.md
146
+ ```
147
+
148
+ For links in the table to work on GitHub, commit the run folders you report.
149
+
150
+ ## Example
151
+
152
+ [`examples/digits`](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits) trains a NumPy MLP on sklearn's digits with 3 optimiser configs and regenerates its README table. Its results, generated with `exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md`:
153
+
154
+ <!-- results:start -->
155
+ <!-- Generated by exptrail. Do not edit by hand; `exptrail verify` checks these numbers. -->
156
+ <!-- command: exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md -->
157
+ | run | lr | momentum | test_acc | commit |
158
+ |---|---|---|---|---|
159
+ | [momentum-lr0.01](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_momentum-lr0.01/) | 0.01 | 0.9 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
160
+ | [sgd-lr0.1](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_sgd-lr0.1/) | 0.1 | 0.0 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
161
+ | [momentum-lr0.05](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055910_momentum-lr0.05/) | 0.05 | 0.9 | 0.9694 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
162
+ <!-- results:end -->
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "exptrail"
7
+ version = "0.1.0"
8
+ description = "Local, framework-agnostic ML experiment logging that keeps README numbers verifiable."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Jaden Isaac" }]
14
+ keywords = ["machine-learning", "experiment-tracking", "reproducibility", "logging", "mlops"]
15
+ dependencies = []
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Science/Research",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ "Topic :: System :: Logging",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/jadenisaac2005/exptrail"
34
+ Source = "https://github.com/jadenisaac2005/exptrail"
35
+ Issues = "https://github.com/jadenisaac2005/exptrail/issues"
36
+
37
+ [project.optional-dependencies]
38
+ plot = ["matplotlib"]
39
+ dev = ["pytest", "pytest-cov", "matplotlib"]
40
+
41
+ [project.scripts]
42
+ exptrail = "exptrail.cli:main"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """exptrail: local experiment logging that keeps README numbers verifiable."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .run import ConfigWarning, DirtyTreeWarning, NoGitWarning, Run, track # noqa: E402
6
+
7
+ __all__ = ["Run", "track", "DirtyTreeWarning", "NoGitWarning", "ConfigWarning", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,27 @@
1
+ """Point warnings at the user's line, however many exptrail frames are in between."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ _PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
9
+
10
+
11
+ def _in_package(filename: str) -> bool:
12
+ return os.path.abspath(filename).startswith(_PACKAGE_DIR)
13
+
14
+
15
+ def user_stacklevel() -> int:
16
+ """``stacklevel`` for a ``warnings.warn`` made by the caller of this function.
17
+
18
+ Walks up to the first frame outside the exptrail package, so the warning
19
+ names the user's ``with Run(...)``, ``run.start()`` or ``@track``-decorated
20
+ call, whichever path led here.
21
+ """
22
+ frame = sys._getframe(1) # the function that is about to call warnings.warn
23
+ level = 1
24
+ while frame is not None and _in_package(frame.f_code.co_filename):
25
+ frame = frame.f_back
26
+ level += 1
27
+ return level