trainproof 0.2.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.2.0 → trainproof-0.4.0}/PKG-INFO +84 -8
  2. {trainproof-0.2.0 → trainproof-0.4.0}/README.md +83 -7
  3. {trainproof-0.2.0 → trainproof-0.4.0}/pyproject.toml +1 -1
  4. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/cli.py +25 -0
  5. trainproof-0.4.0/src/trainproof/compare.py +132 -0
  6. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/epoch.py +12 -4
  7. trainproof-0.4.0/src/trainproof/integrations/__init__.py +3 -0
  8. trainproof-0.4.0/src/trainproof/integrations/hf.py +89 -0
  9. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/rules.py +27 -0
  10. trainproof-0.4.0/src/trainproof/watch.py +57 -0
  11. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/PKG-INFO +84 -8
  12. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/SOURCES.txt +8 -1
  13. trainproof-0.4.0/tests/test_compare.py +47 -0
  14. {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_epoch.py +8 -0
  15. trainproof-0.4.0/tests/test_hf_callback.py +66 -0
  16. trainproof-0.4.0/tests/test_watch.py +42 -0
  17. {trainproof-0.2.0 → trainproof-0.4.0}/setup.cfg +0 -0
  18. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/__init__.py +0 -0
  19. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/adapters.py +0 -0
  20. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/report.py +0 -0
  21. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/__init__.py +0 -0
  22. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/data.py +0 -0
  23. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/tokenizer.py +0 -0
  24. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/dependency_links.txt +0 -0
  25. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/entry_points.txt +0 -0
  26. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/requires.txt +0 -0
  27. {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/top_level.txt +0 -0
  28. {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_adapters.py +0 -0
  29. {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_data.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainproof
3
- Version: 0.2.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
@@ -41,7 +41,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
41
41
  [FAIL] Critical checks failed:
42
42
  [FAIL] Loss curve is diverging.
43
43
  Evidence: End loss 7.492 vs Min loss 1.398
44
- [FAIL] Loss never improved over the run (dead run).
45
44
  [WARN] Gradient norm spikes detected.
46
45
  Evidence: Max gn 2649.75 > 10.0x median (0.55)
47
46
  ```
@@ -68,7 +67,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
68
67
  | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
69
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 |
70
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) |
71
- | `bad_labels` | labels shuffled per-sequence | **WARN only** — see below | grad spike 23.3 vs median 1.09 |
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 |
72
71
 
73
72
  ### The honest finding: loss curves cannot see corrupted data
74
73
 
@@ -80,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
80
79
  class of failure** — its real signature is *relative*: a loss floor ~6x higher
81
80
  than a known-good run of the same task (5.59 vs 0.94).
82
81
 
83
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
84
- deterministic ratio rules against the healthy baseline you already have.
85
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
86
- 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).
87
92
 
88
- ## The three commands
93
+ ## The commands
89
94
 
90
95
  ```bash
91
96
  # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
@@ -99,11 +104,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
99
104
  # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
100
105
  # LR sanity, throughput — from log files, any framework
101
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
+
102
125
  ```
103
126
 
104
127
  Each command prints the verdict, writes a self-contained HTML report, and sets
105
128
  the process exit code — so it works as a CI gate out of the box.
106
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
+
107
183
  ## Supported log formats
108
184
 
109
185
  - HuggingFace Trainer (`trainer_state.json`)
@@ -29,7 +29,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
29
29
  [FAIL] Critical checks failed:
30
30
  [FAIL] Loss curve is diverging.
31
31
  Evidence: End loss 7.492 vs Min loss 1.398
32
- [FAIL] Loss never improved over the run (dead run).
33
32
  [WARN] Gradient norm spikes detected.
34
33
  Evidence: Max gn 2649.75 > 10.0x median (0.55)
35
34
  ```
@@ -56,7 +55,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
56
55
  | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
57
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 |
58
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) |
59
- | `bad_labels` | labels shuffled per-sequence | **WARN only** — see below | grad spike 23.3 vs median 1.09 |
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 |
60
59
 
61
60
  ### The honest finding: loss curves cannot see corrupted data
62
61
 
@@ -68,12 +67,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
68
67
  class of failure** — its real signature is *relative*: a loss floor ~6x higher
69
68
  than a known-good run of the same task (5.59 vs 0.94).
70
69
 
71
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
72
- deterministic ratio rules against the healthy baseline you already have.
73
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
74
- dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
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).
75
80
 
76
- ## The three commands
81
+ ## The commands
77
82
 
78
83
  ```bash
79
84
  # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
@@ -87,11 +92,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
87
92
  # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
88
93
  # LR sanity, throughput — from log files, any framework
89
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
+
90
113
  ```
91
114
 
92
115
  Each command prints the verdict, writes a self-contained HTML report, and sets
93
116
  the process exit code — so it works as a CI gate out of the box.
94
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
+
95
171
  ## Supported log formats
96
172
 
97
173
  - HuggingFace Trainer (`trainer_state.json`)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trainproof"
3
- version = "0.2.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,6 +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
8
+ from .compare import check_compare
9
+ from .watch import watch_loop
7
10
  from .report import print_verdict_console, write_html_report
8
11
 
9
12
  def main():
@@ -24,6 +27,19 @@ def main():
24
27
  epoch_parser.add_argument("logfile", type=str, help="Path to JSONL or CSV log file")
25
28
  epoch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
26
29
 
30
+ # Compare Command
31
+ compare_parser = subparsers.add_parser("compare", help="Compare a run log against a baseline log.")
32
+ compare_parser.add_argument("run", type=str, help="Path to run log file")
33
+ compare_parser.add_argument("baseline", type=str, help="Path to baseline log file")
34
+ compare_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
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
+
27
43
  args = parser.parse_args()
28
44
 
29
45
  report_dict = {}
@@ -38,6 +54,15 @@ def main():
38
54
  except Exception as e:
39
55
  print(f"Error checking epoch: {e}")
40
56
  sys.exit(1)
57
+ elif args.command == "compare":
58
+ try:
59
+ report_dict = check_compare(args.run, args.baseline, fmt=args.format)
60
+ except Exception as e:
61
+ print(f"Error checking compare: {e}")
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)
41
66
 
42
67
  print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
43
68
 
@@ -0,0 +1,132 @@
1
+ import math
2
+ from pathlib import Path
3
+ from typing import Any
4
+ from . import rules
5
+ from .adapters import parse_log_with_format
6
+ from .epoch import check_epoch
7
+
8
+ def _get_val(row, aliases):
9
+ for a in aliases:
10
+ for k in row:
11
+ if a in k: return row[k]
12
+ return None
13
+
14
+ def extract_metrics(records):
15
+ losses = []
16
+ grad_norms = []
17
+
18
+ for r in records:
19
+ loss = _get_val(r, ["loss", "train_loss"])
20
+ gn = _get_val(r, ["grad_norm", "gnorm", "grad"])
21
+
22
+ if loss is not None and not math.isnan(loss) and not math.isinf(loss):
23
+ losses.append(loss)
24
+ if gn is not None and not math.isnan(gn) and not math.isinf(gn):
25
+ grad_norms.append(gn)
26
+
27
+ if len(losses) < 10:
28
+ return None
29
+
30
+ floor = min(losses)
31
+
32
+ w = rules.LOSS_IMPROVEMENT_WINDOW
33
+ start_med = sorted(losses[:w])[w // 2]
34
+ end_med = sorted(losses[-w:])[w // 2]
35
+
36
+ improvement = 1.0 - (end_med / start_med) if start_med > 0 else 0.0
37
+
38
+ gn_median = None
39
+ if len(grad_norms) > 5:
40
+ sorted_gns = sorted(grad_norms)
41
+ gn_median = sorted_gns[len(sorted_gns) // 2]
42
+
43
+ return {
44
+ "floor": floor,
45
+ "start_med": start_med,
46
+ "end_med": end_med,
47
+ "improvement": improvement,
48
+ "gn_median": gn_median,
49
+ "losses_len": len(losses)
50
+ }
51
+
52
+ def check_compare(run_path: str | Path, base_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
53
+ findings = []
54
+ verdict = "PASS"
55
+
56
+ # Baseline sanity — a suspect baseline downgrades the whole comparison
57
+ base_epoch_res = check_epoch(base_path, fmt=fmt)
58
+ if base_epoch_res["verdict"] == "FAIL":
59
+ verdict = "WARN"
60
+ findings.append({
61
+ "level": "WARN",
62
+ "message": "baseline itself fails single-run checks - comparison may be meaningless",
63
+ "evidence": str(base_path)
64
+ })
65
+
66
+ run_records = parse_log_with_format(run_path, fmt)
67
+ base_records = parse_log_with_format(base_path, fmt)
68
+
69
+ run_metrics = extract_metrics(run_records) if run_records else None
70
+ base_metrics = extract_metrics(base_records) if base_records else None
71
+
72
+ if not run_metrics:
73
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Run log has fewer than 10 valid loss points.", "evidence": str(run_path)}]}
74
+ if not base_metrics:
75
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Baseline log has fewer than 10 valid loss points.", "evidence": str(base_path)}]}
76
+
77
+ # Floor ratio
78
+ if run_metrics["floor"] > base_metrics["floor"] * rules.MAX_FLOOR_RATIO:
79
+ verdict = "FAIL"
80
+ findings.append({
81
+ "level": "FAIL",
82
+ "message": "loss floor ratio exceeded limit",
83
+ "evidence": f"Run floor {run_metrics['floor']:.3f} vs Baseline floor {base_metrics['floor']:.3f} (ratio {(run_metrics['floor'] / base_metrics['floor']):.1f}x > {rules.MAX_FLOOR_RATIO})"
84
+ })
85
+
86
+ # End-loss ratio: where the run landed vs where the baseline landed.
87
+ # Robust to corrupted starts (see rules.MAX_END_RATIO comment).
88
+ if base_metrics["end_med"] > 0 and run_metrics["end_med"] > base_metrics["end_med"] * rules.MAX_END_RATIO:
89
+ verdict = "FAIL"
90
+ findings.append({
91
+ "level": "FAIL",
92
+ "message": "end loss ratio exceeded limit",
93
+ "evidence": f"Run end {run_metrics['end_med']:.3f} vs Baseline end {base_metrics['end_med']:.3f} (ratio {(run_metrics['end_med'] / base_metrics['end_med']):.1f}x > {rules.MAX_END_RATIO})"
94
+ })
95
+
96
+ # Improvement deficit
97
+ run_imp = run_metrics["improvement"]
98
+ base_imp = base_metrics["improvement"]
99
+
100
+ if run_imp < 0:
101
+ verdict = "FAIL"
102
+ findings.append({
103
+ "level": "FAIL",
104
+ "message": "negative improvement",
105
+ "evidence": f"Run improvement is {run_imp * 100:.1f}% vs Baseline {base_imp * 100:.1f}%"
106
+ })
107
+ elif base_imp > 0 and run_imp < base_imp * rules.MIN_IMPROVEMENT_FRACTION:
108
+ verdict = "FAIL"
109
+ findings.append({
110
+ "level": "FAIL",
111
+ "message": "improvement deficit",
112
+ "evidence": f"Run improvement {run_imp * 100:.1f}% vs Baseline {base_imp * 100:.1f}% (ratio {(run_imp / base_imp):.2f}x < {rules.MIN_IMPROVEMENT_FRACTION})"
113
+ })
114
+
115
+ # Gradnorm median ratio
116
+ if run_metrics["gn_median"] is not None and base_metrics["gn_median"] is not None and base_metrics["gn_median"] > 0:
117
+ if run_metrics["gn_median"] > base_metrics["gn_median"] * rules.MAX_GRADNORM_MEDIAN_RATIO:
118
+ if verdict == "PASS": verdict = "WARN"
119
+ findings.append({
120
+ "level": "WARN",
121
+ "message": "gradient norm median significantly higher than baseline",
122
+ "evidence": f"Run gn median {run_metrics['gn_median']:.2f} vs Baseline gn median {base_metrics['gn_median']:.2f} (ratio {(run_metrics['gn_median'] / base_metrics['gn_median']):.1f}x > {rules.MAX_GRADNORM_MEDIAN_RATIO})"
123
+ })
124
+
125
+ if verdict == "PASS" and not any(f["level"] == "WARN" for f in findings):
126
+ findings.append({
127
+ "level": "PASS",
128
+ "message": "Run compares favorably to baseline.",
129
+ "evidence": f"Floor ratio {(run_metrics['floor'] / base_metrics['floor']):.2f}x, Improvement {run_imp*100:.1f}% vs {base_imp*100:.1f}%"
130
+ })
131
+
132
+ return {"verdict": verdict, "findings": findings}
@@ -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):
@@ -92,7 +91,10 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
92
91
  if lrs:
93
92
  zeros = sum(1 for lr in lrs if lr <= 0)
94
93
  zero_frac = zeros / len(lrs)
95
- if zero_frac > rules.MAX_ZERO_LR_FRACTION:
94
+ if zero_frac >= rules.ZERO_LR_FAIL_FRACTION:
95
+ findings.append({"level": "FAIL", "message": "Learning rate is zero for the entire run - the optimizer never steps.", "evidence": f"{zero_frac*100:.1f}% of steps have lr=0"})
96
+ verdict = "FAIL"
97
+ elif zero_frac > rules.MAX_ZERO_LR_FRACTION:
96
98
  findings.append({"level": "WARN", "message": "Learning rate is zero for a large fraction of the run.", "evidence": f"{zero_frac*100:.1f}% of steps have lr=0"})
97
99
  if verdict == "PASS": verdict = "WARN"
98
100
 
@@ -109,3 +111,9 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
109
111
  findings.append({"level": "PASS", "message": "Loss curve shows healthy shape, grad norms are stable.", "evidence": f"{len(valid_losses)} steps analyzed."})
110
112
 
111
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
@@ -52,6 +52,12 @@ MAX_GRAD_NORM_SPIKE_RATIO = 10.0
52
52
  # If the learning rate is strictly zero for more than this fraction of the epoch, it's a warning.
53
53
  MAX_ZERO_LR_FRACTION = 0.1
54
54
 
55
+ # If the learning rate is zero for essentially the ENTIRE run, the optimizer
56
+ # never steps — the run cannot train, regardless of how the loss wiggles
57
+ # (multi-seed testing showed batch-order noise can fake >5% "improvement"
58
+ # under lr=0). Total zero-LR is a fatality, not a warning.
59
+ ZERO_LR_FAIL_FRACTION = 0.99
60
+
55
61
  # Dead-run detection (added in v0.2 after fault-injection testing showed that
56
62
  # zero-LR and garbage-label runs — which can never learn — only earned WARNs):
57
63
  # the median of the last LOSS_IMPROVEMENT_WINDOW losses must be at least
@@ -61,3 +67,24 @@ MAX_ZERO_LR_FRACTION = 0.1
61
67
  MIN_LOSS_IMPROVEMENT = 0.05
62
68
  LOSS_IMPROVEMENT_WINDOW = 5
63
69
  MIN_POINTS_FOR_IMPROVEMENT_CHECK = 10
70
+
71
+ # -----------------
72
+ # COMPARE SUBCOMMAND
73
+ # -----------------
74
+
75
+ # FAIL "loss floor ratio" if run_floor / baseline_floor > 2.0
76
+ MAX_FLOOR_RATIO = 2.0
77
+
78
+ # FAIL "end loss ratio" if the run's final loss (median of last window) lands
79
+ # more than this factor above the baseline's. Judges where the run LANDED —
80
+ # robust both to noisy first steps and to corruption inside the first window
81
+ # (e.g. an LR explosion at step 10 inflates the run's own start median, making
82
+ # self-relative improvement look positive; the end ratio still catches it).
83
+ MAX_END_RATIO = 2.0
84
+
85
+ # FAIL "improvement deficit" if the run's relative improvement is < 25% of the baseline's
86
+ # (only evaluated when baseline improvement > 0). A run with NEGATIVE improvement always trips this rule.
87
+ MIN_IMPROVEMENT_FRACTION = 0.25
88
+
89
+ # WARN if run's grad-norm median > 5x baseline's (skip if either lacks grad norms)
90
+ MAX_GRADNORM_MEDIAN_RATIO = 5.0
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainproof
3
- Version: 0.2.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
@@ -41,7 +41,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
41
41
  [FAIL] Critical checks failed:
42
42
  [FAIL] Loss curve is diverging.
43
43
  Evidence: End loss 7.492 vs Min loss 1.398
44
- [FAIL] Loss never improved over the run (dead run).
45
44
  [WARN] Gradient norm spikes detected.
46
45
  Evidence: Max gn 2649.75 > 10.0x median (0.55)
47
46
  ```
@@ -68,7 +67,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
68
67
  | `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
69
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 |
70
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) |
71
- | `bad_labels` | labels shuffled per-sequence | **WARN only** — see below | grad spike 23.3 vs median 1.09 |
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 |
72
71
 
73
72
  ### The honest finding: loss curves cannot see corrupted data
74
73
 
@@ -80,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
80
79
  class of failure** — its real signature is *relative*: a loss floor ~6x higher
81
80
  than a known-good run of the same task (5.59 vs 0.94).
82
81
 
83
- That finding sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
84
- deterministic ratio rules against the healthy baseline you already have.
85
- See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
86
- 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).
87
92
 
88
- ## The three commands
93
+ ## The commands
89
94
 
90
95
  ```bash
91
96
  # 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
@@ -99,11 +104,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
99
104
  # 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
100
105
  # LR sanity, throughput — from log files, any framework
101
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
+
102
125
  ```
103
126
 
104
127
  Each command prints the verdict, writes a self-contained HTML report, and sets
105
128
  the process exit code — so it works as a CI gate out of the box.
106
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
+
107
183
  ## Supported log formats
108
184
 
109
185
  - HuggingFace Trainer (`trainer_state.json`)
@@ -3,18 +3,25 @@ pyproject.toml
3
3
  src/trainproof/__init__.py
4
4
  src/trainproof/adapters.py
5
5
  src/trainproof/cli.py
6
+ src/trainproof/compare.py
6
7
  src/trainproof/epoch.py
7
8
  src/trainproof/report.py
8
9
  src/trainproof/rules.py
10
+ src/trainproof/watch.py
9
11
  src/trainproof.egg-info/PKG-INFO
10
12
  src/trainproof.egg-info/SOURCES.txt
11
13
  src/trainproof.egg-info/dependency_links.txt
12
14
  src/trainproof.egg-info/entry_points.txt
13
15
  src/trainproof.egg-info/requires.txt
14
16
  src/trainproof.egg-info/top_level.txt
17
+ src/trainproof/integrations/__init__.py
18
+ src/trainproof/integrations/hf.py
15
19
  src/trainproof/speech/__init__.py
16
20
  src/trainproof/speech/data.py
17
21
  src/trainproof/speech/tokenizer.py
18
22
  tests/test_adapters.py
23
+ tests/test_compare.py
19
24
  tests/test_data.py
20
- tests/test_epoch.py
25
+ tests/test_epoch.py
26
+ tests/test_hf_callback.py
27
+ tests/test_watch.py
@@ -0,0 +1,47 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from trainproof.compare import check_compare
4
+
5
+ def get_gallery_log(name: str) -> str:
6
+ path = Path(__file__).parent.parent / "examples" / "gallery" / name / "trainer_state.json"
7
+ return str(path.resolve())
8
+
9
+ def test_compare_healthy_vs_healthy():
10
+ healthy = get_gallery_log("healthy")
11
+ res = check_compare(healthy, healthy, fmt="hf")
12
+ assert res["verdict"] == "PASS"
13
+
14
+ def test_compare_bad_labels_vs_healthy():
15
+ bad_labels = get_gallery_log("bad_labels")
16
+ healthy = get_gallery_log("healthy")
17
+ res = check_compare(bad_labels, healthy, fmt="hf")
18
+ assert res["verdict"] == "FAIL"
19
+ findings = [f for f in res["findings"] if "loss floor ratio" in f["message"]]
20
+ assert len(findings) > 0
21
+
22
+ def test_compare_lr_zero_vs_healthy():
23
+ lr_zero = get_gallery_log("lr_zero")
24
+ healthy = get_gallery_log("healthy")
25
+ res = check_compare(lr_zero, healthy, fmt="hf")
26
+ assert res["verdict"] == "FAIL"
27
+ findings = [f for f in res["findings"] if "improvement deficit" in f["message"]]
28
+ assert len(findings) > 0
29
+
30
+ def test_compare_lr_hot_vs_healthy():
31
+ # lr_hot's explosion happens INSIDE the first-5 window, inflating its own
32
+ # start median — self-relative improvement looks positive (+62%). Only the
33
+ # end-loss ratio (where the run LANDED: ~7x the baseline) catches it.
34
+ lr_hot = get_gallery_log("lr_hot")
35
+ healthy = get_gallery_log("healthy")
36
+ res = check_compare(lr_hot, healthy, fmt="hf")
37
+ assert res["verdict"] == "FAIL"
38
+ findings = [f for f in res["findings"] if "end loss ratio" in f["message"]]
39
+ assert len(findings) > 0
40
+
41
+ def test_compare_fp16_nan_vs_healthy():
42
+ fp16_nan = get_gallery_log("fp16_nan")
43
+ healthy = get_gallery_log("healthy")
44
+ res = check_compare(fp16_nan, healthy, fmt="hf")
45
+ assert res["verdict"] == "FAIL"
46
+ findings = [f for f in res["findings"] if "negative improvement" in f["message"]]
47
+ assert len(findings) > 0
@@ -41,3 +41,11 @@ def test_epoch_dead_noisy():
41
41
  report = check_epoch(FIXTURES_DIR / "dead_noisy.jsonl")
42
42
  assert report["verdict"] == "FAIL"
43
43
  assert any("never improved" in str(f) for f in report["findings"])
44
+
45
+ def test_epoch_total_zero_lr_is_fatal():
46
+ # lr=0 on every step: the optimizer never moves — FAIL regardless of how
47
+ # batch-order noise makes the loss wiggle (found by multi-seed testing)
48
+ gallery = Path(__file__).parent.parent / "examples" / "gallery"
49
+ report = check_epoch(gallery / "lr_zero" / "trainer_state.json", fmt="hf")
50
+ assert report["verdict"] == "FAIL"
51
+ assert any("optimizer never steps" in str(f) for f in report["findings"])
@@ -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