trainproof 0.3.0__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 (29) hide show
  1. {trainproof-0.3.0/src/trainproof.egg-info → trainproof-0.4.0}/PKG-INFO +67 -6
  2. trainproof-0.3.0/PKG-INFO → trainproof-0.4.0/README.md +202 -153
  3. {trainproof-0.3.0 → trainproof-0.4.0}/pyproject.toml +1 -1
  4. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/cli.py +12 -0
  5. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/epoch.py +8 -3
  6. trainproof-0.4.0/src/trainproof/integrations/__init__.py +3 -0
  7. trainproof-0.4.0/src/trainproof/integrations/hf.py +89 -0
  8. trainproof-0.4.0/src/trainproof/watch.py +57 -0
  9. trainproof-0.3.0/README.md → trainproof-0.4.0/src/trainproof.egg-info/PKG-INFO +214 -141
  10. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/SOURCES.txt +6 -1
  11. trainproof-0.4.0/tests/test_hf_callback.py +66 -0
  12. trainproof-0.4.0/tests/test_watch.py +42 -0
  13. {trainproof-0.3.0 → trainproof-0.4.0}/setup.cfg +0 -0
  14. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/__init__.py +0 -0
  15. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/adapters.py +0 -0
  16. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/compare.py +0 -0
  17. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/report.py +0 -0
  18. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/rules.py +0 -0
  19. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/__init__.py +0 -0
  20. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/data.py +0 -0
  21. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/tokenizer.py +0 -0
  22. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/dependency_links.txt +0 -0
  23. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/entry_points.txt +0 -0
  24. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/requires.txt +0 -0
  25. {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/top_level.txt +0 -0
  26. {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_adapters.py +0 -0
  27. {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_compare.py +0 -0
  28. {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_data.py +0 -0
  29. {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_epoch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainproof
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs.
5
5
  Author-email: "Panagiotis (Panos) Gkilis" <bedvibe@bedvibe.studio>
6
6
  License: MIT
@@ -79,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
79
79
  class of failure** — its real signature is *relative*: a loss floor ~6x higher
80
80
  than a known-good run of the same task (5.59 vs 0.94).
81
81
 
82
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
83
- deterministic ratio rules against the healthy baseline you already have.
84
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
85
- dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
82
+ That finding produced v0.3: `trainproof compare <run> <baseline>` —
83
+ deterministic ratio rules against the healthy baseline you already have
84
+ which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
85
+ The full study was repeated with three random seeds (15 runs):
86
+ see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
87
+ honest miss (compare alone overlooks one lr_zero seed — the single-run
88
+ zero-LR fatality rule owns that case; the two commands cover each other's
89
+ blind spots). The gallery also improved the tool itself twice: the dead-run
90
+ rule and the total-zero-LR fatality rule both exist because runs escaped
91
+ earlier rule versions. See [ROADMAP.md](ROADMAP.md).
86
92
 
87
- ## The three commands
93
+ ## The commands
88
94
 
89
95
  ```bash
90
96
  # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
@@ -116,9 +122,64 @@ TRAINPROOF VERDICT
116
122
  ========================================
117
123
  ```
118
124
 
125
+ ```
126
+
119
127
  Each command prints the verdict, writes a self-contained HTML report, and sets
120
128
  the process exit code — so it works as a CI gate out of the box.
121
129
 
130
+ ## Live guardian (v0.4)
131
+
132
+ Don't wait for the post-mortem — catch a doomed run *while it is still burning
133
+ GPU*. Add one line to a HuggingFace `Trainer`:
134
+
135
+ ```python
136
+ from transformers import Trainer
137
+ from trainproof.integrations.hf import TrainproofCallback
138
+
139
+ trainer = Trainer(
140
+ ...,
141
+ callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
142
+ )
143
+ ```
144
+
145
+ Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
146
+ guardian aborts it 20 steps into a 300-step schedule — on its own:
147
+
148
+ ```text
149
+ {'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
150
+ {'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
151
+ {'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
152
+ {'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
153
+ ...
154
+ TRAINPROOF ABORT - stopping training at step 20. Findings:
155
+ [FAIL] Loss curve is diverging.
156
+ Evidence: End loss 22.952 vs Min loss 1.358
157
+ [FAIL] Loss never improved over the run (dead run).
158
+ Evidence: median of first 5 losses 1.502 vs last 5 22.952
159
+
160
+ scheduled steps : 300
161
+ stopped at step : 20
162
+ run saved : 93% of the scheduled steps never ran
163
+ ```
164
+
165
+ On a two-day pre-training run, that fraction is days of GPU time. Or watch a
166
+ growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
167
+
168
+ ```bash
169
+ trainproof watch logs/run.jsonl --interval 10 --until-fail
170
+ # [21:37:44] warming up (5 records)
171
+ # [21:37:44] n_records=15 verdict=PASS findings=1
172
+ ```
173
+
174
+ **The default is safe.** `policy="warn"` (the default) only observes and reports
175
+ — it never interrupts your run, so you can leave it on even for experiments you
176
+ expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
177
+ mode that takes an irreversible action. trainproof does not make that decision
178
+ for you unless you ask.
179
+
180
+ The guardian applies the same deterministic rules as `trainproof epoch`, so it
181
+ inherits their documented single-run limitations.
182
+
122
183
  ## Supported log formats
123
184
 
124
185
  - HuggingFace Trainer (`trainer_state.json`)
@@ -1,153 +1,202 @@
1
- Metadata-Version: 2.4
2
- Name: trainproof
3
- Version: 0.3.0
4
- Summary: A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs.
5
- Author-email: "Panagiotis (Panos) Gkilis" <bedvibe@bedvibe.studio>
6
- License: MIT
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- Requires-Dist: numpy
10
- Requires-Dist: soundfile
11
- Requires-Dist: ttsproof
12
-
13
- # trainproof
14
-
15
- [![PyPI](https://img.shields.io/pypi/v/trainproof)](https://pypi.org/project/trainproof/)
16
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
17
-
18
- **A deterministic linter for ML training runs.** Point it at your dataset, your
19
- tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
20
- with named findings and cited evidence, before you burn days of GPU time on a
21
- run that was doomed at step 50.
22
-
23
- No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
24
- threshold in [one auditable module](src/trainproof/rules.py), and every finding
25
- cites the numbers that triggered it.
26
-
27
- ```bash
28
- pip install trainproof
29
- ```
30
-
31
- ## See a verdict in 60 seconds
32
-
33
- This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
34
- RTX 5080 — see the gallery below). Judge one right now:
35
-
36
- ```bash
37
- trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
38
- ```
39
-
40
- ```text
41
- [FAIL] Critical checks failed:
42
- [FAIL] Loss curve is diverging.
43
- Evidence: End loss 7.492 vs Min loss 1.398
44
- [WARN] Gradient norm spikes detected.
45
- Evidence: Max gn 2649.75 > 10.0x median (0.55)
46
- ```
47
-
48
- ## Why this exists
49
-
50
- The author lost a real 11-hour fine-tune to a failure nothing warned about.
51
- Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
52
- trainproof's verdict: **FAIL diverging**. The loss reached its minimum at 82%
53
- of the run and ended 1.9x above it. Translation: the final two hours of GPU
54
- time made the model measurably worse, and the checkpoint worth keeping had
55
- already existed for hours. No tool in the stack said a word.
56
-
57
- ## The fault-injection gallery
58
-
59
- To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
60
- LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
61
- times with exactly one knob deliberately broken. Real runs, real logs, all
62
- shipped in [`examples/gallery/`](examples/gallery/):
63
-
64
- | Run | Sabotage | Verdict | Key evidence |
65
- |---|---|---|---|
66
- | `healthy` | none | **PASS** | loss 1.52 0.94, stable gradients |
67
- | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
68
- | `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
69
- | `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
70
- | `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
71
-
72
- ### The honest finding: loss curves cannot see corrupted data
73
-
74
- The `bad_labels` run whose shuffled labels make real learning impossible —
75
- *reduced its loss by 62%* (18.9 5.75). The model was genuinely learning: not
76
- the task, but the marginal token statistics of the garbage. From its own loss
77
- curve, that is indistinguishable from healthy training (neural networks
78
- famously fit random labels). **No single-run, loss-only rule can catch this
79
- class of failure** its real signature is *relative*: a loss floor ~6x higher
80
- than a known-good run of the same task (5.59 vs 0.94).
81
-
82
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
83
- deterministic ratio rules against the healthy baseline you already have.
84
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
85
- dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
86
-
87
- ## The three commands
88
-
89
- ```bash
90
- # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
91
- # duplicates, text-vs-audio duration mismatches
92
- trainproof data /path/to/dataset_or_manifest.jsonl
93
-
94
- # 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
95
- # suspicious splits on numbers/dates
96
- trainproof tokenizer my_tokenizer.model transcripts.txt
97
-
98
- # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
99
- # LR sanity, throughput — from log files, any framework
100
- trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
101
-
102
- # 4. Compare against a baseline
103
- # Catch relative pathologies like the `bad_labels` run that evade single-run rules.
104
- trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
105
- ```
106
-
107
- ```text
108
- ========================================
109
- TRAINPROOF VERDICT
110
- ========================================
111
- [FAIL] Critical checks failed:
112
- [FAIL] loss floor ratio exceeded limit
113
- Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
114
- [FAIL] end loss ratio exceeded limit
115
- Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
116
- ========================================
117
- ```
118
-
119
- Each command prints the verdict, writes a self-contained HTML report, and sets
120
- the process exit codeso it works as a CI gate out of the box.
121
-
122
- ## Supported log formats
123
-
124
- - HuggingFace Trainer (`trainer_state.json`)
125
- - Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
126
- - Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
127
-
128
- Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
129
- files are planned (Lightning console captures are TTY dumps, not logs, and
130
- will not be supported).
131
-
132
- ## Philosophy
133
-
134
- 1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
135
- module, commented, tunable.
136
- 2. **Evidence-cited.** Every finding names the steps and values that triggered
137
- it.
138
- 3. **Honest about limits.** What the tool cannot detect is documented in the
139
- README, not discovered by the user in production.
140
-
141
- ## Family
142
-
143
- trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
144
- judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
145
- published method with DOI) and trainproof builds on it for the speech
146
- dataset checks.
147
-
148
- ## Author
149
-
150
- Panagiotis (Panos) Gkilis [portfolio](https://tts.bedvibe.studio/portfolio/) ·
151
- [bedvibe.studio](https://bedvibe.studio/)
152
-
153
- MIT license.
1
+ # trainproof
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/trainproof)](https://pypi.org/project/trainproof/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ **A deterministic linter for ML training runs.** Point it at your dataset, your
7
+ tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
8
+ with named findings and cited evidence, before you burn days of GPU time on a
9
+ run that was doomed at step 50.
10
+
11
+ No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
12
+ threshold in [one auditable module](src/trainproof/rules.py), and every finding
13
+ cites the numbers that triggered it.
14
+
15
+ ```bash
16
+ pip install trainproof
17
+ ```
18
+
19
+ ## See a verdict in 60 seconds
20
+
21
+ This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
22
+ RTX 5080 — see the gallery below). Judge one right now:
23
+
24
+ ```bash
25
+ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
26
+ ```
27
+
28
+ ```text
29
+ [FAIL] Critical checks failed:
30
+ [FAIL] Loss curve is diverging.
31
+ Evidence: End loss 7.492 vs Min loss 1.398
32
+ [WARN] Gradient norm spikes detected.
33
+ Evidence: Max gn 2649.75 > 10.0x median (0.55)
34
+ ```
35
+
36
+ ## Why this exists
37
+
38
+ The author lost a real 11-hour fine-tune to a failure nothing warned about.
39
+ Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
40
+ trainproof's verdict: **FAIL — diverging**. The loss reached its minimum at 82%
41
+ of the run and ended 1.9x above it. Translation: the final two hours of GPU
42
+ time made the model measurably worse, and the checkpoint worth keeping had
43
+ already existed for hours. No tool in the stack said a word.
44
+
45
+ ## The fault-injection gallery
46
+
47
+ To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
48
+ LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
49
+ times with exactly one knob deliberately broken. Real runs, real logs, all
50
+ shipped in [`examples/gallery/`](examples/gallery/):
51
+
52
+ | Run | Sabotage | Verdict | Key evidence |
53
+ |---|---|---|---|
54
+ | `healthy` | none | **PASS** | loss 1.52 0.94, stable gradients |
55
+ | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
56
+ | `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
57
+ | `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
58
+ | `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
59
+
60
+ ### The honest finding: loss curves cannot see corrupted data
61
+
62
+ The `bad_labels` run — whose shuffled labels make real learning impossible —
63
+ *reduced its loss by 62%* (18.9 → 5.75). The model was genuinely learning: not
64
+ the task, but the marginal token statistics of the garbage. From its own loss
65
+ curve, that is indistinguishable from healthy training (neural networks
66
+ famously fit random labels). **No single-run, loss-only rule can catch this
67
+ class of failure** its real signature is *relative*: a loss floor ~6x higher
68
+ than a known-good run of the same task (5.59 vs 0.94).
69
+
70
+ That finding produced v0.3: `trainproof compare <run> <baseline>`
71
+ deterministic ratio rules against the healthy baseline you already have —
72
+ which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
73
+ The full study was repeated with three random seeds (15 runs):
74
+ see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
75
+ honest miss (compare alone overlooks one lr_zero seed the single-run
76
+ zero-LR fatality rule owns that case; the two commands cover each other's
77
+ blind spots). The gallery also improved the tool itself twice: the dead-run
78
+ rule and the total-zero-LR fatality rule both exist because runs escaped
79
+ earlier rule versions. See [ROADMAP.md](ROADMAP.md).
80
+
81
+ ## The commands
82
+
83
+ ```bash
84
+ # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
85
+ # duplicates, text-vs-audio duration mismatches
86
+ trainproof data /path/to/dataset_or_manifest.jsonl
87
+
88
+ # 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
89
+ # suspicious splits on numbers/dates
90
+ trainproof tokenizer my_tokenizer.model transcripts.txt
91
+
92
+ # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
93
+ # LR sanity, throughput — from log files, any framework
94
+ trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
95
+
96
+ # 4. Compare against a baseline
97
+ # Catch relative pathologies like the `bad_labels` run that evade single-run rules.
98
+ trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
99
+ ```
100
+
101
+ ```text
102
+ ========================================
103
+ TRAINPROOF VERDICT
104
+ ========================================
105
+ [FAIL] Critical checks failed:
106
+ [FAIL] loss floor ratio exceeded limit
107
+ Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
108
+ [FAIL] end loss ratio exceeded limit
109
+ Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
110
+ ========================================
111
+ ```
112
+
113
+ ```
114
+
115
+ Each command prints the verdict, writes a self-contained HTML report, and sets
116
+ the process exit code — so it works as a CI gate out of the box.
117
+
118
+ ## Live guardian (v0.4)
119
+
120
+ Don't wait for the post-mortem catch a doomed run *while it is still burning
121
+ GPU*. Add one line to a HuggingFace `Trainer`:
122
+
123
+ ```python
124
+ from transformers import Trainer
125
+ from trainproof.integrations.hf import TrainproofCallback
126
+
127
+ trainer = Trainer(
128
+ ...,
129
+ callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
130
+ )
131
+ ```
132
+
133
+ Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
134
+ guardian aborts it 20 steps into a 300-step schedule on its own:
135
+
136
+ ```text
137
+ {'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
138
+ {'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
139
+ {'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
140
+ {'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
141
+ ...
142
+ TRAINPROOF ABORT - stopping training at step 20. Findings:
143
+ [FAIL] Loss curve is diverging.
144
+ Evidence: End loss 22.952 vs Min loss 1.358
145
+ [FAIL] Loss never improved over the run (dead run).
146
+ Evidence: median of first 5 losses 1.502 vs last 5 22.952
147
+
148
+ scheduled steps : 300
149
+ stopped at step : 20
150
+ run saved : 93% of the scheduled steps never ran
151
+ ```
152
+
153
+ On a two-day pre-training run, that fraction is days of GPU time. Or watch a
154
+ growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
155
+
156
+ ```bash
157
+ trainproof watch logs/run.jsonl --interval 10 --until-fail
158
+ # [21:37:44] warming up (5 records)
159
+ # [21:37:44] n_records=15 verdict=PASS findings=1
160
+ ```
161
+
162
+ **The default is safe.** `policy="warn"` (the default) only observes and reports
163
+ — it never interrupts your run, so you can leave it on even for experiments you
164
+ expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
165
+ mode that takes an irreversible action. trainproof does not make that decision
166
+ for you unless you ask.
167
+
168
+ The guardian applies the same deterministic rules as `trainproof epoch`, so it
169
+ inherits their documented single-run limitations.
170
+
171
+ ## Supported log formats
172
+
173
+ - HuggingFace Trainer (`trainer_state.json`)
174
+ - Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
175
+ - Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
176
+
177
+ Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
178
+ files are planned (Lightning console captures are TTY dumps, not logs, and
179
+ will not be supported).
180
+
181
+ ## Philosophy
182
+
183
+ 1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
184
+ module, commented, tunable.
185
+ 2. **Evidence-cited.** Every finding names the steps and values that triggered
186
+ it.
187
+ 3. **Honest about limits.** What the tool cannot detect is documented in the
188
+ README, not discovered by the user in production.
189
+
190
+ ## Family
191
+
192
+ trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
193
+ judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
194
+ published method with DOI) — and trainproof builds on it for the speech
195
+ dataset checks.
196
+
197
+ ## Author
198
+
199
+ Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
200
+ [bedvibe.studio](https://bedvibe.studio/)
201
+
202
+ MIT license.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trainproof"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs."
5
5
  authors = [{name = "Panagiotis (Panos) Gkilis", email = "bedvibe@bedvibe.studio"}]
6
6
  readme = "README.md"
@@ -4,7 +4,9 @@ from pathlib import Path
4
4
  from .speech.data import check_data
5
5
  from .speech.tokenizer import check_tokenizer
6
6
  from .epoch import check_epoch
7
+ from .epoch import check_epoch
7
8
  from .compare import check_compare
9
+ from .watch import watch_loop
8
10
  from .report import print_verdict_console, write_html_report
9
11
 
10
12
  def main():
@@ -31,6 +33,13 @@ def main():
31
33
  compare_parser.add_argument("baseline", type=str, help="Path to baseline log file")
32
34
  compare_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
33
35
 
36
+ # Watch Command
37
+ watch_parser = subparsers.add_parser("watch", help="Live guardian: poll a training log.")
38
+ watch_parser.add_argument("logfile", type=str, help="Path to run log file")
39
+ watch_parser.add_argument("--interval", type=int, default=10, help="Polling interval in seconds")
40
+ watch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
41
+ watch_parser.add_argument("--until-fail", action="store_true", help="Exit with code 1 as soon as verdict becomes FAIL")
42
+
34
43
  args = parser.parse_args()
35
44
 
36
45
  report_dict = {}
@@ -51,6 +60,9 @@ def main():
51
60
  except Exception as e:
52
61
  print(f"Error checking compare: {e}")
53
62
  sys.exit(1)
63
+ elif args.command == "watch":
64
+ watch_loop(args.logfile, interval=args.interval, fmt=args.format, until_fail=args.until_fail)
65
+ sys.exit(0)
54
66
 
55
67
  print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
56
68
 
@@ -4,13 +4,12 @@ from typing import Any
4
4
  from . import rules
5
5
  from .adapters import parse_log_with_format
6
6
 
7
- def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
8
- records = parse_log_with_format(log_path, fmt)
7
+ def check_records(records: list[dict]) -> dict[str, Any]:
9
8
  findings = []
10
9
  verdict = "PASS"
11
10
 
12
11
  if not records:
13
- return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": str(log_path)}]}
12
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": ""}]}
14
13
 
15
14
  # Find relevant keys
16
15
  def get_val(row, aliases):
@@ -112,3 +111,9 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
112
111
  findings.append({"level": "PASS", "message": "Loss curve shows healthy shape, grad norms are stable.", "evidence": f"{len(valid_losses)} steps analyzed."})
113
112
 
114
113
  return {"verdict": verdict, "findings": findings}
114
+
115
+ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
116
+ records = parse_log_with_format(log_path, fmt)
117
+ if not records:
118
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": str(log_path)}]}
119
+ return check_records(records)
@@ -0,0 +1,3 @@
1
+ from .hf import TrainproofCallback
2
+
3
+ __all__ = ["TrainproofCallback"]
@@ -0,0 +1,89 @@
1
+ from trainproof.epoch import check_records
2
+
3
+ def _convert_state_to_records(state) -> list[dict]:
4
+ history = getattr(state, "log_history", [])
5
+ records = []
6
+ for entry in history:
7
+ if not isinstance(entry, dict) or "loss" not in entry:
8
+ continue
9
+ record = {}
10
+ for k in ["loss", "grad_norm", "step"]:
11
+ if k in entry and entry[k] is not None:
12
+ try:
13
+ record[k] = float(entry[k])
14
+ except (ValueError, TypeError):
15
+ pass
16
+ if "learning_rate" in entry and entry["learning_rate"] is not None:
17
+ try:
18
+ record["lr"] = float(entry["learning_rate"])
19
+ except (ValueError, TypeError):
20
+ pass
21
+ records.append(record)
22
+ return records
23
+
24
+ try:
25
+ from transformers import TrainerCallback
26
+ except ImportError:
27
+ class TrainerCallback:
28
+ pass
29
+ _HAS_TRANSFORMERS = False
30
+ else:
31
+ _HAS_TRANSFORMERS = True
32
+
33
+
34
+ class TrainproofCallback(TrainerCallback):
35
+ """Judge a live HuggingFace training run with trainproof's deterministic rules.
36
+
37
+ policy:
38
+ "warn" (DEFAULT): observe and report only. On a FAIL verdict it prints the
39
+ findings and lets training continue. It NEVER interrupts your run — safe
40
+ to leave on by default, including for experiments you expect to fail.
41
+ "stop_on_fail": opt-in. Additionally sets control.should_training_stop on a
42
+ FAIL verdict, aborting the run to save GPU time. This is the only mode
43
+ that takes an irreversible action, and you must ask for it explicitly.
44
+
45
+ check_every: minimum steps between checks. min_points: minimum logged loss
46
+ points before any verdict is issued (avoids judging warm-up noise).
47
+ A FAIL is announced once, not re-announced every subsequent check.
48
+ """
49
+
50
+ def __init__(self, policy="warn", check_every=25, min_points=10):
51
+ if not _HAS_TRANSFORMERS:
52
+ raise ImportError("pip install transformers is required to use TrainproofCallback")
53
+ self.policy = policy
54
+ self.check_every = check_every
55
+ self.min_points = min_points
56
+ self.last_checked_step = 0
57
+ self.last_verdict = None
58
+
59
+ def on_log(self, args, state, control, **kwargs):
60
+ step = getattr(state, "global_step", 0)
61
+ if step < self.last_checked_step + self.check_every:
62
+ return
63
+
64
+ records = _convert_state_to_records(state)
65
+ if len(records) < self.min_points:
66
+ return
67
+
68
+ self.last_checked_step = step
69
+
70
+ report = check_records(records)
71
+ verdict = report.get("verdict", "UNKNOWN")
72
+ findings = report.get("findings", [])
73
+
74
+ if verdict == "FAIL" and self.last_verdict != "FAIL":
75
+ if self.policy == "stop_on_fail":
76
+ setattr(control, "should_training_stop", True)
77
+ print(f"\nTRAINPROOF ABORT - stopping training at step {step}. Findings:")
78
+ else:
79
+ print("\nTRAINPROOF WARNING - this run looks doomed:")
80
+
81
+ for f in findings:
82
+ level = f.get("level", "INFO")
83
+ msg = f.get("message", "")
84
+ ev = f.get("evidence", "")
85
+ print(f" [{level}] {msg}")
86
+ if ev:
87
+ print(f" Evidence: {ev}")
88
+
89
+ self.last_verdict = verdict
@@ -0,0 +1,57 @@
1
+ import time
2
+ import sys
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from .adapters import parse_log_with_format
6
+ from .epoch import check_records
7
+ from .report import print_verdict_console
8
+
9
+ def poll_once(path: str | Path, fmt: str, prev_verdict: str | None) -> tuple[str | None, bool, int]:
10
+ try:
11
+ records = parse_log_with_format(path, fmt)
12
+ except Exception:
13
+ records = []
14
+
15
+ n_records = len(records)
16
+
17
+ valid_losses = 0
18
+ for r in records:
19
+ if "loss" in r and r["loss"] is not None:
20
+ valid_losses += 1
21
+
22
+ now_str = datetime.now().strftime("%H:%M:%S")
23
+
24
+ if valid_losses < 10:
25
+ print(f"[{now_str}] warming up ({n_records} records)")
26
+ return (None, False, n_records)
27
+
28
+ report = check_records(records)
29
+ verdict = report.get("verdict", "UNKNOWN")
30
+ n_findings = len(report.get("findings", []))
31
+
32
+ changed = (verdict != prev_verdict)
33
+
34
+ print(f"[{now_str}] n_records={n_records} verdict={verdict} findings={n_findings}")
35
+
36
+ if changed:
37
+ print_verdict_console(verdict, report.get("findings", []))
38
+
39
+ return (verdict, changed, n_records)
40
+
41
+ def watch_loop(path: str | Path, interval: int = 10, fmt: str = "auto", until_fail: bool = False):
42
+ prev_verdict = None
43
+ try:
44
+ while True:
45
+ verdict, changed, n = poll_once(path, fmt, prev_verdict)
46
+ if verdict is not None:
47
+ prev_verdict = verdict
48
+
49
+ if until_fail and verdict == "FAIL":
50
+ sys.exit(1)
51
+
52
+ time.sleep(interval)
53
+ except KeyboardInterrupt:
54
+ print(f"\nStopped watching. Final verdict: {prev_verdict}")
55
+ if prev_verdict == "FAIL":
56
+ sys.exit(1)
57
+ sys.exit(0)
@@ -1,141 +1,214 @@
1
- # trainproof
2
-
3
- [![PyPI](https://img.shields.io/pypi/v/trainproof)](https://pypi.org/project/trainproof/)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
-
6
- **A deterministic linter for ML training runs.** Point it at your dataset, your
7
- tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
8
- with named findings and cited evidence, before you burn days of GPU time on a
9
- run that was doomed at step 50.
10
-
11
- No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
12
- threshold in [one auditable module](src/trainproof/rules.py), and every finding
13
- cites the numbers that triggered it.
14
-
15
- ```bash
16
- pip install trainproof
17
- ```
18
-
19
- ## See a verdict in 60 seconds
20
-
21
- This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
22
- RTX 5080 — see the gallery below). Judge one right now:
23
-
24
- ```bash
25
- trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
26
- ```
27
-
28
- ```text
29
- [FAIL] Critical checks failed:
30
- [FAIL] Loss curve is diverging.
31
- Evidence: End loss 7.492 vs Min loss 1.398
32
- [WARN] Gradient norm spikes detected.
33
- Evidence: Max gn 2649.75 > 10.0x median (0.55)
34
- ```
35
-
36
- ## Why this exists
37
-
38
- The author lost a real 11-hour fine-tune to a failure nothing warned about.
39
- Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
40
- trainproof's verdict: **FAIL — diverging**. The loss reached its minimum at 82%
41
- of the run and ended 1.9x above it. Translation: the final two hours of GPU
42
- time made the model measurably worse, and the checkpoint worth keeping had
43
- already existed for hours. No tool in the stack said a word.
44
-
45
- ## The fault-injection gallery
46
-
47
- To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
48
- LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
49
- times with exactly one knob deliberately broken. Real runs, real logs, all
50
- shipped in [`examples/gallery/`](examples/gallery/):
51
-
52
- | Run | Sabotage | Verdict | Key evidence |
53
- |---|---|---|---|
54
- | `healthy` | none | **PASS** | loss 1.52 0.94, stable gradients |
55
- | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
56
- | `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
57
- | `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
58
- | `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
59
-
60
- ### The honest finding: loss curves cannot see corrupted data
61
-
62
- The `bad_labels` run — whose shuffled labels make real learning impossible —
63
- *reduced its loss by 62%* (18.9 → 5.75). The model was genuinely learning: not
64
- the task, but the marginal token statistics of the garbage. From its own loss
65
- curve, that is indistinguishable from healthy training (neural networks
66
- famously fit random labels). **No single-run, loss-only rule can catch this
67
- class of failure** its real signature is *relative*: a loss floor ~6x higher
68
- than a known-good run of the same task (5.59 vs 0.94).
69
-
70
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>`
71
- deterministic ratio rules against the healthy baseline you already have.
72
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
73
- dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
74
-
75
- ## The three commands
76
-
77
- ```bash
78
- # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
79
- # duplicates, text-vs-audio duration mismatches
80
- trainproof data /path/to/dataset_or_manifest.jsonl
81
-
82
- # 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
83
- # suspicious splits on numbers/dates
84
- trainproof tokenizer my_tokenizer.model transcripts.txt
85
-
86
- # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
87
- # LR sanity, throughput from log files, any framework
88
- trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
89
-
90
- # 4. Compare against a baseline
91
- # Catch relative pathologies like the `bad_labels` run that evade single-run rules.
92
- trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
93
- ```
94
-
95
- ```text
96
- ========================================
97
- TRAINPROOF VERDICT
98
- ========================================
99
- [FAIL] Critical checks failed:
100
- [FAIL] loss floor ratio exceeded limit
101
- Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
102
- [FAIL] end loss ratio exceeded limit
103
- Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
104
- ========================================
105
- ```
106
-
107
- Each command prints the verdict, writes a self-contained HTML report, and sets
108
- the process exit code — so it works as a CI gate out of the box.
109
-
110
- ## Supported log formats
111
-
112
- - HuggingFace Trainer (`trainer_state.json`)
113
- - Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
114
- - Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
115
-
116
- Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
117
- files are planned (Lightning console captures are TTY dumps, not logs, and
118
- will not be supported).
119
-
120
- ## Philosophy
121
-
122
- 1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
123
- module, commented, tunable.
124
- 2. **Evidence-cited.** Every finding names the steps and values that triggered
125
- it.
126
- 3. **Honest about limits.** What the tool cannot detect is documented in the
127
- README, not discovered by the user in production.
128
-
129
- ## Family
130
-
131
- trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
132
- judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
133
- published method with DOI) and trainproof builds on it for the speech
134
- dataset checks.
135
-
136
- ## Author
137
-
138
- Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
139
- [bedvibe.studio](https://bedvibe.studio/)
140
-
141
- MIT license.
1
+ Metadata-Version: 2.4
2
+ Name: trainproof
3
+ Version: 0.4.0
4
+ Summary: A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs.
5
+ Author-email: "Panagiotis (Panos) Gkilis" <bedvibe@bedvibe.studio>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy
10
+ Requires-Dist: soundfile
11
+ Requires-Dist: ttsproof
12
+
13
+ # trainproof
14
+
15
+ [![PyPI](https://img.shields.io/pypi/v/trainproof)](https://pypi.org/project/trainproof/)
16
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
17
+
18
+ **A deterministic linter for ML training runs.** Point it at your dataset, your
19
+ tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
20
+ with named findings and cited evidence, before you burn days of GPU time on a
21
+ run that was doomed at step 50.
22
+
23
+ No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
24
+ threshold in [one auditable module](src/trainproof/rules.py), and every finding
25
+ cites the numbers that triggered it.
26
+
27
+ ```bash
28
+ pip install trainproof
29
+ ```
30
+
31
+ ## See a verdict in 60 seconds
32
+
33
+ This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
34
+ RTX 5080 — see the gallery below). Judge one right now:
35
+
36
+ ```bash
37
+ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
38
+ ```
39
+
40
+ ```text
41
+ [FAIL] Critical checks failed:
42
+ [FAIL] Loss curve is diverging.
43
+ Evidence: End loss 7.492 vs Min loss 1.398
44
+ [WARN] Gradient norm spikes detected.
45
+ Evidence: Max gn 2649.75 > 10.0x median (0.55)
46
+ ```
47
+
48
+ ## Why this exists
49
+
50
+ The author lost a real 11-hour fine-tune to a failure nothing warned about.
51
+ Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
52
+ trainproof's verdict: **FAIL diverging**. The loss reached its minimum at 82%
53
+ of the run and ended 1.9x above it. Translation: the final two hours of GPU
54
+ time made the model measurably worse, and the checkpoint worth keeping had
55
+ already existed for hours. No tool in the stack said a word.
56
+
57
+ ## The fault-injection gallery
58
+
59
+ To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
60
+ LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
61
+ times with exactly one knob deliberately broken. Real runs, real logs, all
62
+ shipped in [`examples/gallery/`](examples/gallery/):
63
+
64
+ | Run | Sabotage | Verdict | Key evidence |
65
+ |---|---|---|---|
66
+ | `healthy` | none | **PASS** | loss 1.52 0.94, stable gradients |
67
+ | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
68
+ | `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
69
+ | `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
70
+ | `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
71
+
72
+ ### The honest finding: loss curves cannot see corrupted data
73
+
74
+ The `bad_labels` run — whose shuffled labels make real learning impossible —
75
+ *reduced its loss by 62%* (18.9 → 5.75). The model was genuinely learning: not
76
+ the task, but the marginal token statistics of the garbage. From its own loss
77
+ curve, that is indistinguishable from healthy training (neural networks
78
+ famously fit random labels). **No single-run, loss-only rule can catch this
79
+ class of failure** — its real signature is *relative*: a loss floor ~6x higher
80
+ than a known-good run of the same task (5.59 vs 0.94).
81
+
82
+ That finding produced v0.3: `trainproof compare <run> <baseline>`
83
+ deterministic ratio rules against the healthy baseline you already have —
84
+ which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
85
+ The full study was repeated with three random seeds (15 runs):
86
+ see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
87
+ honest miss (compare alone overlooks one lr_zero seed — the single-run
88
+ zero-LR fatality rule owns that case; the two commands cover each other's
89
+ blind spots). The gallery also improved the tool itself twice: the dead-run
90
+ rule and the total-zero-LR fatality rule both exist because runs escaped
91
+ earlier rule versions. See [ROADMAP.md](ROADMAP.md).
92
+
93
+ ## The commands
94
+
95
+ ```bash
96
+ # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
97
+ # duplicates, text-vs-audio duration mismatches
98
+ trainproof data /path/to/dataset_or_manifest.jsonl
99
+
100
+ # 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
101
+ # suspicious splits on numbers/dates
102
+ trainproof tokenizer my_tokenizer.model transcripts.txt
103
+
104
+ # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
105
+ # LR sanity, throughput — from log files, any framework
106
+ trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
107
+
108
+ # 4. Compare against a baseline
109
+ # Catch relative pathologies like the `bad_labels` run that evade single-run rules.
110
+ trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
111
+ ```
112
+
113
+ ```text
114
+ ========================================
115
+ TRAINPROOF VERDICT
116
+ ========================================
117
+ [FAIL] Critical checks failed:
118
+ [FAIL] loss floor ratio exceeded limit
119
+ Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
120
+ [FAIL] end loss ratio exceeded limit
121
+ Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
122
+ ========================================
123
+ ```
124
+
125
+ ```
126
+
127
+ Each command prints the verdict, writes a self-contained HTML report, and sets
128
+ the process exit code — so it works as a CI gate out of the box.
129
+
130
+ ## Live guardian (v0.4)
131
+
132
+ Don't wait for the post-mortem — catch a doomed run *while it is still burning
133
+ GPU*. Add one line to a HuggingFace `Trainer`:
134
+
135
+ ```python
136
+ from transformers import Trainer
137
+ from trainproof.integrations.hf import TrainproofCallback
138
+
139
+ trainer = Trainer(
140
+ ...,
141
+ callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
142
+ )
143
+ ```
144
+
145
+ Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
146
+ guardian aborts it 20 steps into a 300-step schedule — on its own:
147
+
148
+ ```text
149
+ {'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
150
+ {'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
151
+ {'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
152
+ {'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
153
+ ...
154
+ TRAINPROOF ABORT - stopping training at step 20. Findings:
155
+ [FAIL] Loss curve is diverging.
156
+ Evidence: End loss 22.952 vs Min loss 1.358
157
+ [FAIL] Loss never improved over the run (dead run).
158
+ Evidence: median of first 5 losses 1.502 vs last 5 22.952
159
+
160
+ scheduled steps : 300
161
+ stopped at step : 20
162
+ run saved : 93% of the scheduled steps never ran
163
+ ```
164
+
165
+ On a two-day pre-training run, that fraction is days of GPU time. Or watch a
166
+ growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
167
+
168
+ ```bash
169
+ trainproof watch logs/run.jsonl --interval 10 --until-fail
170
+ # [21:37:44] warming up (5 records)
171
+ # [21:37:44] n_records=15 verdict=PASS findings=1
172
+ ```
173
+
174
+ **The default is safe.** `policy="warn"` (the default) only observes and reports
175
+ — it never interrupts your run, so you can leave it on even for experiments you
176
+ expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
177
+ mode that takes an irreversible action. trainproof does not make that decision
178
+ for you unless you ask.
179
+
180
+ The guardian applies the same deterministic rules as `trainproof epoch`, so it
181
+ inherits their documented single-run limitations.
182
+
183
+ ## Supported log formats
184
+
185
+ - HuggingFace Trainer (`trainer_state.json`)
186
+ - Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
187
+ - Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
188
+
189
+ Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
190
+ files are planned (Lightning console captures are TTY dumps, not logs, and
191
+ will not be supported).
192
+
193
+ ## Philosophy
194
+
195
+ 1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
196
+ module, commented, tunable.
197
+ 2. **Evidence-cited.** Every finding names the steps and values that triggered
198
+ it.
199
+ 3. **Honest about limits.** What the tool cannot detect is documented in the
200
+ README, not discovered by the user in production.
201
+
202
+ ## Family
203
+
204
+ trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
205
+ judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
206
+ published method with DOI) — and trainproof builds on it for the speech
207
+ dataset checks.
208
+
209
+ ## Author
210
+
211
+ Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
212
+ [bedvibe.studio](https://bedvibe.studio/)
213
+
214
+ MIT license.
@@ -7,16 +7,21 @@ src/trainproof/compare.py
7
7
  src/trainproof/epoch.py
8
8
  src/trainproof/report.py
9
9
  src/trainproof/rules.py
10
+ src/trainproof/watch.py
10
11
  src/trainproof.egg-info/PKG-INFO
11
12
  src/trainproof.egg-info/SOURCES.txt
12
13
  src/trainproof.egg-info/dependency_links.txt
13
14
  src/trainproof.egg-info/entry_points.txt
14
15
  src/trainproof.egg-info/requires.txt
15
16
  src/trainproof.egg-info/top_level.txt
17
+ src/trainproof/integrations/__init__.py
18
+ src/trainproof/integrations/hf.py
16
19
  src/trainproof/speech/__init__.py
17
20
  src/trainproof/speech/data.py
18
21
  src/trainproof/speech/tokenizer.py
19
22
  tests/test_adapters.py
20
23
  tests/test_compare.py
21
24
  tests/test_data.py
22
- tests/test_epoch.py
25
+ tests/test_epoch.py
26
+ tests/test_hf_callback.py
27
+ tests/test_watch.py
@@ -0,0 +1,66 @@
1
+ import trainproof.integrations.hf as hf_mod
2
+ from trainproof.integrations.hf import _convert_state_to_records, TrainproofCallback
3
+
4
+ # Patch so tests can run without transformers installed
5
+ hf_mod._HAS_TRANSFORMERS = True
6
+ class MockState:
7
+ def __init__(self):
8
+ self.global_step = 0
9
+ self.log_history = []
10
+
11
+ class MockControl:
12
+ def __init__(self):
13
+ self.should_training_stop = False
14
+
15
+ def test_convert_state_to_records():
16
+ state = MockState()
17
+ state.log_history = [
18
+ {"loss": 1.0, "step": 10, "learning_rate": 0.01, "eval_loss": 2.0},
19
+ {"eval_loss": 1.5, "step": 10},
20
+ {"loss": 0.5, "step": 20, "grad_norm": 0.1}
21
+ ]
22
+ records = _convert_state_to_records(state)
23
+ assert len(records) == 2
24
+ assert records[0] == {"loss": 1.0, "step": 10.0, "lr": 0.01}
25
+ assert records[1] == {"loss": 0.5, "step": 20.0, "grad_norm": 0.1}
26
+
27
+ def test_hf_callback_warn_policy():
28
+ callback = TrainproofCallback(policy="warn", check_every=10, min_points=10)
29
+ state = MockState()
30
+ control = MockControl()
31
+
32
+ # Add < 10 points -> should do nothing
33
+ for i in range(5):
34
+ state.log_history.append({"loss": 10.0, "step": i})
35
+ state.global_step = 10
36
+ callback.on_log(None, state, control)
37
+ assert callback.last_verdict is None
38
+
39
+ # Add up to 15 points (healthy) -> PASS
40
+ for i in range(5, 15):
41
+ state.log_history.append({"loss": 1.0, "step": i, "learning_rate": 1e-4})
42
+ state.global_step = 20
43
+ callback.on_log(None, state, control)
44
+ assert callback.last_verdict == "PASS"
45
+ assert not control.should_training_stop
46
+
47
+ # Diverge -> FAIL but don't stop because policy=warn
48
+ for i in range(15, 25):
49
+ state.log_history.append({"loss": 50.0, "step": i, "learning_rate": 1e-4})
50
+ state.global_step = 30
51
+ callback.on_log(None, state, control)
52
+ assert callback.last_verdict == "FAIL"
53
+ assert not control.should_training_stop
54
+
55
+ def test_hf_callback_stop_on_fail():
56
+ callback = TrainproofCallback(policy="stop_on_fail", check_every=10, min_points=10)
57
+ state = MockState()
58
+ control = MockControl()
59
+
60
+ for i in range(20):
61
+ state.log_history.append({"loss": 50.0, "step": i, "learning_rate": 1e-4})
62
+ state.global_step = 20
63
+
64
+ callback.on_log(None, state, control)
65
+ assert callback.last_verdict == "FAIL"
66
+ assert control.should_training_stop
@@ -0,0 +1,42 @@
1
+ import json
2
+ from pathlib import Path
3
+ from trainproof.watch import poll_once
4
+
5
+ def test_watch_poll_once(tmp_path: Path):
6
+ log_file = tmp_path / "test.jsonl"
7
+
8
+ # Phase 1: warming up (less than 10 valid losses)
9
+ logs = []
10
+ for i in range(5):
11
+ logs.append(json.dumps({"loss": 5.0, "step": i, "lr": 1e-4}))
12
+ log_file.write_text("\n".join(logs))
13
+
14
+ verdict, changed, n = poll_once(log_file, "jsonl", None)
15
+ assert verdict is None
16
+ assert not changed
17
+ assert n == 5
18
+
19
+ # Phase 2: healthy phase -> PASS
20
+ for i in range(5, 15):
21
+ logs.append(json.dumps({"loss": 1.0, "step": i, "lr": 1e-4}))
22
+ log_file.write_text("\n".join(logs))
23
+
24
+ verdict, changed, n = poll_once(log_file, "jsonl", None)
25
+ assert verdict == "PASS"
26
+ assert changed
27
+ assert n == 15
28
+
29
+ # Check that it doesn't say "changed" if prev_verdict was PASS
30
+ verdict2, changed2, n2 = poll_once(log_file, "jsonl", "PASS")
31
+ assert verdict2 == "PASS"
32
+ assert not changed2
33
+
34
+ # Phase 3: append diverging losses -> verdict changes to FAIL
35
+ for i in range(15, 25):
36
+ logs.append(json.dumps({"loss": 20.0, "step": i, "lr": 1e-4}))
37
+ log_file.write_text("\n".join(logs))
38
+
39
+ verdict3, changed3, n3 = poll_once(log_file, "jsonl", "PASS")
40
+ assert verdict3 == "FAIL"
41
+ assert changed3
42
+ assert n3 == 25
File without changes