trainproof 0.1.0__py3-none-any.whl

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.
trainproof/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ trainproof: A deterministic linter for ML training runs.
3
+ """
4
+
5
+ __version__ = "0.1.0"
trainproof/adapters.py ADDED
@@ -0,0 +1,143 @@
1
+ import json
2
+ import csv
3
+ import re
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+
7
+ ANSI_ESCAPE_PATTERN = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
8
+
9
+ def parse_hf_trainer_state(text: str) -> list[dict[str, float]]:
10
+ try:
11
+ data = json.loads(text)
12
+ except json.JSONDecodeError:
13
+ return []
14
+
15
+ history = data.get("log_history", [])
16
+ records = []
17
+ for entry in history:
18
+ if "loss" not in entry:
19
+ continue
20
+ record = {}
21
+ for k in ["loss", "grad_norm", "step"]:
22
+ if k in entry and entry[k] is not None:
23
+ try:
24
+ record[k] = float(entry[k])
25
+ except (ValueError, TypeError):
26
+ pass
27
+ if "learning_rate" in entry and entry["learning_rate"] is not None:
28
+ try:
29
+ record["lr"] = float(entry["learning_rate"])
30
+ except (ValueError, TypeError):
31
+ pass
32
+ records.append(record)
33
+ return records
34
+
35
+ def parse_coqui_trainer_log(text: str) -> list[dict[str, float]]:
36
+ text = ANSI_ESCAPE_PATTERN.sub('', text)
37
+ records = []
38
+ current_record = None
39
+
40
+ for line in text.splitlines():
41
+ line = line.strip()
42
+ if not line:
43
+ continue
44
+
45
+ if line.startswith("--> TIME:") and "-- GLOBAL_STEP:" in line:
46
+ if current_record is not None and "loss" in current_record:
47
+ records.append(current_record)
48
+ current_record = {}
49
+
50
+ m_time = re.search(r'TIME:\s*([\d-]+\s[\d:]+)', line)
51
+ if m_time:
52
+ try:
53
+ dt = datetime.strptime(m_time.group(1), "%Y-%m-%d %H:%M:%S")
54
+ current_record["time"] = dt.timestamp()
55
+ except ValueError:
56
+ pass
57
+
58
+ m_step = re.search(r'GLOBAL_STEP:\s*(\d+)', line)
59
+ if m_step:
60
+ current_record["step"] = float(m_step.group(1))
61
+
62
+ elif current_record is not None and line.startswith("| > "):
63
+ parts = line[4:].split(":")
64
+ if len(parts) >= 2:
65
+ key = parts[0].strip()
66
+ if key in ("loss", "current_lr"):
67
+ val_str = parts[1].strip().split()[0]
68
+ try:
69
+ val = float(val_str)
70
+ if key == "loss":
71
+ current_record["loss"] = val
72
+ elif key == "current_lr":
73
+ current_record["lr"] = val
74
+ except ValueError:
75
+ pass
76
+
77
+ if current_record is not None and "loss" in current_record:
78
+ records.append(current_record)
79
+
80
+ return records
81
+
82
+ def parse_generic_log(text: str, is_csv: bool) -> list[dict[str, float]]:
83
+ records = []
84
+ if is_csv:
85
+ reader = csv.DictReader(text.splitlines())
86
+ if reader.fieldnames:
87
+ for row in reader:
88
+ norm_row = {}
89
+ for k, v in row.items():
90
+ if v is None or not str(v).strip():
91
+ continue
92
+ try:
93
+ norm_row[k.strip().lower()] = float(v)
94
+ except (ValueError, TypeError):
95
+ pass
96
+ if norm_row:
97
+ records.append(norm_row)
98
+ else:
99
+ for line in text.splitlines():
100
+ line = line.strip()
101
+ if not line: continue
102
+ try:
103
+ data = json.loads(line)
104
+ norm_row = {}
105
+ for k, v in data.items():
106
+ try:
107
+ norm_row[k.strip().lower()] = float(v)
108
+ except (ValueError, TypeError):
109
+ pass
110
+ records.append(norm_row)
111
+ except Exception:
112
+ pass
113
+ return records
114
+
115
+ def parse_log_with_format(path: str | Path, fmt: str = "auto") -> list[dict[str, float]]:
116
+ path = Path(path)
117
+ if not path.exists():
118
+ raise FileNotFoundError(f"Log file not found: {path}")
119
+
120
+ text = path.read_text(encoding="utf-8", errors="ignore").strip()
121
+
122
+ if fmt == "auto":
123
+ if path.name == "trainer_state.json":
124
+ fmt = "hf"
125
+ elif path.suffix == ".json" and '"log_history"' in text:
126
+ fmt = "hf"
127
+ elif "-- GLOBAL_STEP:" in text:
128
+ fmt = "coqui"
129
+ elif path.suffix == ".csv" or (text and text[0] != '{'):
130
+ fmt = "csv"
131
+ else:
132
+ fmt = "jsonl"
133
+
134
+ if fmt == "hf":
135
+ return parse_hf_trainer_state(text)
136
+ elif fmt == "coqui":
137
+ return parse_coqui_trainer_log(text)
138
+ elif fmt == "csv":
139
+ return parse_generic_log(text, is_csv=True)
140
+ elif fmt == "jsonl":
141
+ return parse_generic_log(text, is_csv=False)
142
+ else:
143
+ raise ValueError(f"Unknown format: {fmt}")
trainproof/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+ from .speech.data import check_data
5
+ from .speech.tokenizer import check_tokenizer
6
+ from .epoch import check_epoch
7
+ from .report import print_verdict_console, write_html_report
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="Trainproof: A deterministic linter for ML training runs.")
11
+ subparsers = parser.add_subparsers(dest="command", required=True)
12
+
13
+ # Data Command
14
+ data_parser = subparsers.add_parser("data", help="Dataset preflight for speech/TTS corpora.")
15
+ data_parser.add_argument("input", type=str, help="Directory or manifest.jsonl")
16
+
17
+ # Tokenizer Command
18
+ tok_parser = subparsers.add_parser("tokenizer", help="Tokenizer preflight.")
19
+ tok_parser.add_argument("model", type=str, help="Path to tokenizer model (e.g., SentencePiece .model)")
20
+ tok_parser.add_argument("transcripts", type=str, help="Path to transcripts text file or JSONL")
21
+
22
+ # Epoch Command
23
+ epoch_parser = subparsers.add_parser("epoch", help="First-epoch verdict from training logs.")
24
+ epoch_parser.add_argument("logfile", type=str, help="Path to JSONL or CSV log file")
25
+ epoch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
26
+
27
+ args = parser.parse_args()
28
+
29
+ report_dict = {}
30
+
31
+ if args.command == "data":
32
+ report_dict = check_data(args.input)
33
+ elif args.command == "tokenizer":
34
+ report_dict = check_tokenizer(args.model, args.transcripts)
35
+ elif args.command == "epoch":
36
+ try:
37
+ report_dict = check_epoch(args.logfile, fmt=args.format)
38
+ except Exception as e:
39
+ print(f"Error checking epoch: {e}")
40
+ sys.exit(1)
41
+
42
+ print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
43
+
44
+ # Write self-contained HTML report
45
+ html_out = Path("trainproof_report.html")
46
+ write_html_report(report_dict, html_out)
47
+ print(f"\nSaved detailed HTML report to {html_out.absolute()}")
48
+
49
+ if report_dict.get("verdict") == "FAIL":
50
+ sys.exit(1)
51
+ else:
52
+ sys.exit(0)
53
+
54
+ if __name__ == "__main__":
55
+ main()
trainproof/epoch.py ADDED
@@ -0,0 +1,101 @@
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
+
7
+ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
8
+ records = parse_log_with_format(log_path, fmt)
9
+ findings = []
10
+ verdict = "PASS"
11
+
12
+ if not records:
13
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": str(log_path)}]}
14
+
15
+ # Find relevant keys
16
+ def get_val(row, aliases):
17
+ for a in aliases:
18
+ for k in row:
19
+ if a in k: return row[k]
20
+ return None
21
+
22
+ losses = []
23
+ loss_steps = [] # kept aligned with losses; records may lack a loss field
24
+ lrs = []
25
+ grad_norms = []
26
+ times = []
27
+ time_steps = []
28
+
29
+ for i, r in enumerate(records):
30
+ step = get_val(r, ["step", "iter"])
31
+ step = i if step is None else step
32
+ loss = get_val(r, ["loss", "train_loss"])
33
+ lr = get_val(r, ["lr", "learning_rate"])
34
+ gn = get_val(r, ["grad_norm", "gnorm", "grad"])
35
+ t = get_val(r, ["elapsed", "time", "timestamp"])
36
+
37
+ if loss is not None:
38
+ losses.append(loss)
39
+ loss_steps.append(step)
40
+ if lr is not None: lrs.append(lr)
41
+ if gn is not None: grad_norms.append(gn)
42
+ if t is not None:
43
+ times.append(t)
44
+ time_steps.append(step)
45
+
46
+ if not losses:
47
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Could not find loss metric in logs.", "evidence": ""}]}
48
+
49
+ # Check NaN / Inf in loss
50
+ nan_steps = [s for s, l in zip(loss_steps, losses) if math.isnan(l) or math.isinf(l)]
51
+ if nan_steps:
52
+ findings.append({"level": "FAIL", "message": "NaN or Inf detected in loss.", "evidence": f"Steps: {nan_steps[:5]}..."})
53
+ verdict = "FAIL"
54
+
55
+ # Check flat loss
56
+ valid_losses = [l for l in losses if not math.isnan(l) and not math.isinf(l)]
57
+ if valid_losses:
58
+ mean_loss = sum(valid_losses) / len(valid_losses)
59
+ std_loss = math.sqrt(sum((l - mean_loss)**2 for l in valid_losses) / len(valid_losses))
60
+ if mean_loss > 0 and (std_loss / mean_loss) < rules.MIN_LOSS_VARIATION:
61
+ findings.append({"level": "FAIL", "message": "Loss curve is completely flat (dead run).", "evidence": f"Variation {std_loss/mean_loss:.5f} < {rules.MIN_LOSS_VARIATION}"})
62
+ verdict = "FAIL"
63
+
64
+ # Check divergence
65
+ min_loss = min(valid_losses)
66
+ if min_loss > 0 and valid_losses[-1] > min_loss * rules.MAX_LOSS_DIVERGENCE_RATIO:
67
+ findings.append({"level": "FAIL", "message": "Loss curve is diverging.", "evidence": f"End loss {valid_losses[-1]:.3f} vs Min loss {min_loss:.3f}"})
68
+ verdict = "FAIL"
69
+
70
+ # Check Grad Norm
71
+ valid_gns = [g for g in grad_norms if not math.isnan(g) and not math.isinf(g)]
72
+ if valid_gns and len(valid_gns) > 5:
73
+ sorted_gns = sorted(valid_gns)
74
+ median_gn = sorted_gns[len(sorted_gns)//2]
75
+ if median_gn > 0:
76
+ spikes = [g for g in valid_gns if g > median_gn * rules.MAX_GRAD_NORM_SPIKE_RATIO]
77
+ if spikes:
78
+ findings.append({"level": "WARN", "message": "Gradient norm spikes detected.", "evidence": f"Max gn {max(spikes):.2f} > {rules.MAX_GRAD_NORM_SPIKE_RATIO}x median ({median_gn:.2f})"})
79
+ if verdict == "PASS": verdict = "WARN"
80
+
81
+ # Check LR
82
+ if lrs:
83
+ zeros = sum(1 for lr in lrs if lr <= 0)
84
+ zero_frac = zeros / len(lrs)
85
+ if zero_frac > rules.MAX_ZERO_LR_FRACTION:
86
+ 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"})
87
+ if verdict == "PASS": verdict = "WARN"
88
+
89
+ # Throughput — only when the log carries a time column; no guessing
90
+ if len(times) >= 2 and times[-1] > times[0]:
91
+ span = times[-1] - times[0]
92
+ steps_covered = time_steps[-1] - time_steps[0]
93
+ if steps_covered > 0:
94
+ rate = steps_covered / span
95
+ findings.append({"level": "INFO", "message": "Throughput measured from log timestamps.",
96
+ "evidence": f"{rate:.2f} steps/sec over {span:.0f}s observed."})
97
+
98
+ if verdict == "PASS":
99
+ findings.append({"level": "PASS", "message": "Loss curve shows healthy shape, grad norms are stable.", "evidence": f"{len(valid_losses)} steps analyzed."})
100
+
101
+ return {"verdict": verdict, "findings": findings}
trainproof/report.py ADDED
@@ -0,0 +1,56 @@
1
+ import html as H
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ def print_verdict_console(verdict: str, findings: list[dict[str, Any]]):
6
+ """Prints an ASCII-safe verdict summary to the console."""
7
+ print("=" * 40)
8
+ print("TRAINPROOF VERDICT")
9
+ print("=" * 40)
10
+
11
+ if verdict == "PASS":
12
+ print("[PASS] All checks passed.")
13
+ elif verdict == "WARN":
14
+ print("[WARN] Some checks triggered warnings:")
15
+ else:
16
+ print("[FAIL] Critical checks failed:")
17
+
18
+ for finding in findings:
19
+ level = finding.get("level", "INFO")
20
+ msg = finding.get("message", "")
21
+ evidence = finding.get("evidence", "")
22
+ print(f" [{level}] {msg}")
23
+ if evidence:
24
+ print(f" Evidence: {evidence}")
25
+
26
+ print("=" * 40)
27
+
28
+ def write_html_report(report: dict[str, Any], path: str | Path) -> None:
29
+ """Writes a self-contained HTML report."""
30
+ verdict = report.get("verdict", "UNKNOWN")
31
+ findings = report.get("findings", [])
32
+
33
+ doc = f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
34
+ <title>Trainproof report</title>
35
+ <style>
36
+ body{{font-family:Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0d1117;color:#e6edf3;line-height:1.5}}
37
+ .wrap{{max-width:960px;margin:0 auto;padding:28px 18px 60px}}
38
+ h1{{font-size:26px;margin:0 0 4px}} h2{{font-size:19px;margin:32px 0 10px}}
39
+ .stat{{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:12px 18px;min-width:110px; display:inline-block;}}
40
+ .stat b{{display:block;font-size:22px}} .stat.pass b{{color:#3fb950}} .stat.fail b{{color:#f85149}} .stat.warn b{{color:#d29922}}
41
+ .card{{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:12px 14px;margin:10px 0}}
42
+ .level-FAIL{{color:#f85149;font-weight:bold;}} .level-WARN{{color:#d29922;font-weight:bold;}} .level-PASS{{color:#3fb950;font-weight:bold;}}
43
+ .evidence{{color:#8b949e;font-size:13px;margin-top:4px}}
44
+ </style></head><body><div class="wrap">
45
+ <h1>Trainproof Report</h1>
46
+ <div class="stat {verdict.lower()}"><b>{verdict}</b><span>Overall Verdict</span></div>
47
+ <h2>Findings</h2>
48
+ """
49
+ for finding in findings:
50
+ level = finding.get("level", "INFO")
51
+ msg = H.escape(str(finding.get("message", "")))
52
+ evidence = H.escape(str(finding.get("evidence", "")))
53
+ doc += f'<div class="card"><div class="level-{level}">[{level}] {msg}</div><div class="evidence">Evidence: {evidence}</div></div>\n'
54
+
55
+ doc += "</div></body></html>"
56
+ Path(path).write_text(doc, encoding="utf-8")
trainproof/rules.py ADDED
@@ -0,0 +1,53 @@
1
+ """Deterministic thresholds for verdicts."""
2
+
3
+ # -----------------
4
+ # DATA SUBCOMMAND
5
+ # -----------------
6
+
7
+ # Maximum allowed duration for a single audio file in seconds.
8
+ MAX_AUDIO_DURATION_SEC = 25.0
9
+
10
+ # Minimum allowed duration for a single audio file in seconds.
11
+ MIN_AUDIO_DURATION_SEC = 0.5
12
+
13
+ # Peak amplitude threshold above which we consider audio to be clipping.
14
+ MAX_CLIPPING_PEAK = 0.99
15
+
16
+ # Maximum allowed continuous silence in seconds before warning.
17
+ MAX_SILENCE_SEC = 2.0
18
+
19
+ # Amplitude threshold for considering a frame as "silent" (for silence detection).
20
+ SILENCE_AMPLITUDE = 0.005
21
+
22
+ # Flag text/audio alignment outliers whose chars-per-second rate deviates from
23
+ # the corpus median by more than this ratio (in either direction).
24
+ CHARS_PER_SEC_OUTLIER_RATIO = 3.0
25
+
26
+ # -----------------
27
+ # TOKENIZER SUBCOMMAND
28
+ # -----------------
29
+
30
+ # Minimum fraction of the corpus vocabulary that must be covered by the tokenizer.
31
+ MIN_VOCAB_COVERAGE = 0.999
32
+
33
+ # Maximum acceptable Out-Of-Vocabulary (OOV) rate.
34
+ MAX_OOV_RATE = 0.001
35
+
36
+ # Maximum expected tokens per second of audio (blowout warning).
37
+ MAX_TOKENS_PER_SEC = 50.0
38
+
39
+ # -----------------
40
+ # EPOCH SUBCOMMAND
41
+ # -----------------
42
+
43
+ # If loss increases by more than this ratio from the minimum loss, flag as diverging.
44
+ MAX_LOSS_DIVERGENCE_RATIO = 1.5
45
+
46
+ # Minimum required variation (std/mean) in the loss curve to not be considered "flat/dead".
47
+ MIN_LOSS_VARIATION = 0.001
48
+
49
+ # Maximum allowable spike in gradient norm compared to the median grad norm.
50
+ MAX_GRAD_NORM_SPIKE_RATIO = 10.0
51
+
52
+ # If the learning rate is strictly zero for more than this fraction of the epoch, it's a warning.
53
+ MAX_ZERO_LR_FRACTION = 0.1
@@ -0,0 +1,9 @@
1
+ """Speech/TTS domain pack — dataset and tokenizer preflight.
2
+
3
+ The trainproof core (epoch log linter, rules, reports) is model-agnostic;
4
+ domain-specific checks live in packs like this one so future packs
5
+ (e.g. LLM fine-tuning) plug in beside it.
6
+ """
7
+
8
+ from .data import check_data
9
+ from .tokenizer import check_tokenizer
@@ -0,0 +1,190 @@
1
+ import os
2
+ import json
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import numpy as np
7
+ import soundfile as sf
8
+ from ttsproof.normalize import normalize_text, plain_token
9
+ from .. import rules
10
+
11
+ def get_audio_hash(filepath: str | Path) -> str:
12
+ hasher = hashlib.md5()
13
+ with open(filepath, 'rb') as f:
14
+ hasher.update(f.read())
15
+ return hasher.hexdigest()
16
+
17
+ def check_data(input_path: str | Path) -> dict[str, Any]:
18
+ path = Path(input_path)
19
+ findings = []
20
+ verdict = "PASS"
21
+
22
+ records = []
23
+ if path.is_file() and path.suffix == ".jsonl":
24
+ for line in path.read_text(encoding="utf-8").splitlines():
25
+ if not line.strip(): continue
26
+ records.append(json.loads(line))
27
+ elif path.is_dir():
28
+ for wav_path in path.rglob("*.wav"):
29
+ txt_path = wav_path.with_suffix(".txt")
30
+ txt = txt_path.read_text(encoding="utf-8").strip() if txt_path.exists() else ""
31
+ records.append({"audio_filepath": str(wav_path), "text": txt})
32
+ else:
33
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Input must be a directory or manifest.jsonl.", "evidence": str(path)}]}
34
+
35
+ if not records:
36
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No data found.", "evidence": ""}]}
37
+
38
+ # Audio corpus stats
39
+ sample_rates = set()
40
+ channels = set()
41
+ bit_depths = set()
42
+ audio_hashes = {}
43
+ duplicates = 0
44
+ durations = []
45
+
46
+ clipping_count = 0
47
+ silent_count = 0
48
+ missing_audio = 0
49
+ unreadable_audio = 0
50
+ chars_per_sec = [] # (file, rate) pairs for text/audio alignment check
51
+
52
+ # Text stats
53
+ empty_transcripts = 0
54
+ unnormalized_count = 0
55
+ mixed_scripts_count = 0
56
+ text_lengths = []
57
+
58
+ for r in records:
59
+ audio_file = Path(r.get("audio_filepath", r.get("audio", "")))
60
+ text = r.get("text", r.get("transcript", ""))
61
+
62
+ # Transcript checks
63
+ if not text:
64
+ empty_transcripts += 1
65
+ else:
66
+ text_lengths.append(len(text))
67
+ normalized = normalize_text(text)
68
+ # if normalization significantly changes the text, we flag it as unnormalized
69
+ if normalized != text and plain_token(normalized) != plain_token(text):
70
+ unnormalized_count += 1
71
+
72
+ # naive charset check for mixed scripts (e.g., Cyrillic + Latin)
73
+ has_latin = any('a' <= c.lower() <= 'z' for c in text)
74
+ has_cyrillic = any('\u0400' <= c <= '\u04FF' for c in text)
75
+ has_greek = any('\u0370' <= c <= '\u03FF' for c in text)
76
+ if sum([has_latin, has_cyrillic, has_greek]) > 1:
77
+ mixed_scripts_count += 1
78
+
79
+ # Audio checks (records without an audio field are transcript-only — allowed)
80
+ if str(audio_file) in ("", "."):
81
+ pass
82
+ elif not audio_file.exists():
83
+ missing_audio += 1
84
+ elif audio_file.exists():
85
+ ahash = get_audio_hash(audio_file)
86
+ if ahash in audio_hashes:
87
+ duplicates += 1
88
+ else:
89
+ audio_hashes[ahash] = str(audio_file)
90
+
91
+ try:
92
+ info = sf.info(str(audio_file))
93
+ sample_rates.add(info.samplerate)
94
+ channels.add(info.channels)
95
+ bit_depths.add(info.subtype)
96
+ duration = info.frames / info.samplerate
97
+ durations.append(duration)
98
+ if text and duration > 0:
99
+ chars_per_sec.append((str(audio_file), len(text) / duration))
100
+
101
+ audio_data, sr = sf.read(str(audio_file), dtype="float32", always_2d=True)
102
+ mono = np.mean(audio_data, axis=1)
103
+ peak = float(np.max(np.abs(mono))) if len(mono) else 0.0
104
+ if peak >= rules.MAX_CLIPPING_PEAK:
105
+ clipping_count += 1
106
+
107
+ silent = np.abs(mono) <= rules.SILENCE_AMPLITUDE
108
+ max_run = 0
109
+ current = 0
110
+ for is_silent in silent:
111
+ if is_silent: current += 1
112
+ else:
113
+ max_run = max(max_run, current)
114
+ current = 0
115
+ max_run = max(max_run, current)
116
+ max_silence_sec = max_run / float(sr)
117
+ if max_silence_sec > rules.MAX_SILENCE_SEC:
118
+ silent_count += 1
119
+ except Exception:
120
+ unreadable_audio += 1
121
+
122
+ if len(sample_rates) > 1:
123
+ findings.append({"level": "FAIL", "message": "Inconsistent sample rates found.", "evidence": f"Rates: {sample_rates}"})
124
+ verdict = "FAIL"
125
+
126
+ if len(channels) > 1:
127
+ findings.append({"level": "FAIL", "message": "Inconsistent channel counts found.", "evidence": f"Channels: {channels}"})
128
+ verdict = "FAIL"
129
+
130
+ if missing_audio > 0:
131
+ findings.append({"level": "FAIL", "message": "Manifest references audio files that do not exist.", "evidence": f"{missing_audio} of {len(records)} records."})
132
+ verdict = "FAIL"
133
+
134
+ if unreadable_audio > 0:
135
+ findings.append({"level": "WARN", "message": "Audio files could not be read/decoded.", "evidence": f"{unreadable_audio} files."})
136
+ if verdict == "PASS": verdict = "WARN"
137
+
138
+ if empty_transcripts > 0:
139
+ findings.append({"level": "WARN", "message": "Empty transcripts.", "evidence": f"{empty_transcripts} of {len(records)} records."})
140
+ if verdict == "PASS": verdict = "WARN"
141
+
142
+ if durations:
143
+ too_long = sum(1 for d in durations if d > rules.MAX_AUDIO_DURATION_SEC)
144
+ too_short = sum(1 for d in durations if d < rules.MIN_AUDIO_DURATION_SEC)
145
+ sorted_d = sorted(durations)
146
+ dist = f"min {sorted_d[0]:.2f}s / median {sorted_d[len(sorted_d)//2]:.2f}s / max {sorted_d[-1]:.2f}s"
147
+ if too_long > 0:
148
+ findings.append({"level": "WARN", "message": "Audio duration outliers above MAX_AUDIO_DURATION_SEC.", "evidence": f"{too_long} files > {rules.MAX_AUDIO_DURATION_SEC}s ({dist})"})
149
+ if verdict == "PASS": verdict = "WARN"
150
+ if too_short > 0:
151
+ findings.append({"level": "WARN", "message": "Audio duration outliers below MIN_AUDIO_DURATION_SEC.", "evidence": f"{too_short} files < {rules.MIN_AUDIO_DURATION_SEC}s ({dist})"})
152
+ if verdict == "PASS": verdict = "WARN"
153
+
154
+ if len(chars_per_sec) >= 10:
155
+ rates = sorted(r for _, r in chars_per_sec)
156
+ median_rate = rates[len(rates) // 2]
157
+ if median_rate > 0:
158
+ outliers = [(f, r) for f, r in chars_per_sec
159
+ if r > median_rate * rules.CHARS_PER_SEC_OUTLIER_RATIO
160
+ or r < median_rate / rules.CHARS_PER_SEC_OUTLIER_RATIO]
161
+ if outliers:
162
+ worst = max(outliers, key=lambda x: abs(x[1] - median_rate))
163
+ findings.append({"level": "WARN", "message": "Text-length vs audio-duration outliers (possible transcript/audio mismatch).",
164
+ "evidence": f"{len(outliers)} records deviate >{rules.CHARS_PER_SEC_OUTLIER_RATIO}x from median {median_rate:.1f} chars/sec; worst: {worst[0]} ({worst[1]:.1f} chars/sec)"})
165
+ if verdict == "PASS": verdict = "WARN"
166
+
167
+ if duplicates > 0:
168
+ findings.append({"level": "WARN", "message": "Duplicate audio content detected.", "evidence": f"{duplicates} files have identical hashes."})
169
+ if verdict == "PASS": verdict = "WARN"
170
+
171
+ if clipping_count > 0:
172
+ findings.append({"level": "WARN", "message": "Audio clipping detected.", "evidence": f"{clipping_count} files exceed MAX_CLIPPING_PEAK ({rules.MAX_CLIPPING_PEAK})."})
173
+ if verdict == "PASS": verdict = "WARN"
174
+
175
+ if silent_count > 0:
176
+ findings.append({"level": "WARN", "message": "Excessive silence detected.", "evidence": f"{silent_count} files exceed MAX_SILENCE_SEC ({rules.MAX_SILENCE_SEC}s)."})
177
+ if verdict == "PASS": verdict = "WARN"
178
+
179
+ if unnormalized_count > 0:
180
+ findings.append({"level": "WARN", "message": "Unnormalized text that hurts TTS (digits, dates).", "evidence": f"{unnormalized_count} transcripts need normalization."})
181
+ if verdict == "PASS": verdict = "WARN"
182
+
183
+ if mixed_scripts_count > 0:
184
+ findings.append({"level": "WARN", "message": "Mixed character scripts in transcripts.", "evidence": f"{mixed_scripts_count} transcripts."})
185
+ if verdict == "PASS": verdict = "WARN"
186
+
187
+ if verdict == "PASS":
188
+ findings.append({"level": "PASS", "message": "Dataset preflight completed successfully.", "evidence": f"Processed {len(records)} records."})
189
+
190
+ return {"verdict": verdict, "findings": findings}
@@ -0,0 +1,93 @@
1
+ import json
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from .. import rules
6
+
7
+ def load_tokenizer(model_path: str):
8
+ """Returns (tokenizer, error_finding). Never silently degrades: a linter
9
+ that swaps in a fake tokenizer would produce fake verdicts."""
10
+ try:
11
+ import sentencepiece as spm
12
+ except ImportError:
13
+ return None, {"level": "FAIL",
14
+ "message": "sentencepiece is not installed - cannot lint this tokenizer.",
15
+ "evidence": "pip install sentencepiece"}
16
+ try:
17
+ sp = spm.SentencePieceProcessor()
18
+ sp.load(model_path)
19
+ return sp, None
20
+ except Exception as e:
21
+ return None, {"level": "FAIL",
22
+ "message": "Failed to load SentencePiece model.",
23
+ "evidence": f"{model_path}: {e}"}
24
+
25
+ def check_tokenizer(model_path: str | Path, transcripts_path: str | Path) -> dict[str, Any]:
26
+ tokenizer, load_error = load_tokenizer(str(model_path))
27
+ if load_error is not None:
28
+ return {"verdict": "FAIL", "findings": [load_error]}
29
+ findings = []
30
+ verdict = "PASS"
31
+
32
+ path = Path(transcripts_path)
33
+ if not path.exists():
34
+ return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Transcripts file not found.", "evidence": str(transcripts_path)}]}
35
+
36
+ lines = path.read_text(encoding="utf-8").splitlines()
37
+ total_tokens = 0
38
+ total_chars = 0
39
+ total_unks = 0
40
+ total_duration = 0.0
41
+
42
+ suspicious_splits = 0
43
+
44
+ for line in lines:
45
+ if not line.strip(): continue
46
+ text = line
47
+ duration = 0.0
48
+ if line.startswith("{"):
49
+ try:
50
+ data = json.loads(line)
51
+ text = data.get("text", "") or data.get("transcript", "")
52
+ duration = data.get("duration", 0.0)
53
+ except Exception:
54
+ pass
55
+
56
+ pieces = tokenizer.encode_as_pieces(text)
57
+ total_tokens += len(pieces)
58
+ total_chars += len(text)
59
+ total_duration += duration
60
+ total_unks += sum(1 for p in pieces if p == "<unk>")
61
+
62
+ # heuristic for suspicious split: if a token has multiple digits separated but not grouped?
63
+ # A simple check: if the sequence length blows up compared to char length for numbers
64
+ numbers = re.findall(r'\b\d+\b', text)
65
+ for num in numbers:
66
+ num_pieces = tokenizer.encode_as_pieces(num)
67
+ if len(num_pieces) > len(num) / 2 + 1:
68
+ suspicious_splits += 1
69
+
70
+ oov_rate = total_unks / max(1, total_tokens)
71
+ if oov_rate > rules.MAX_OOV_RATE:
72
+ findings.append({"level": "FAIL", "message": "High OOV (Out-Of-Vocabulary) rate.", "evidence": f"{oov_rate*100:.3f}% > {rules.MAX_OOV_RATE*100:.3f}%"})
73
+ verdict = "FAIL"
74
+
75
+ coverage = 1.0 - oov_rate
76
+ if coverage < rules.MIN_VOCAB_COVERAGE:
77
+ findings.append({"level": "WARN", "message": "Vocabulary coverage is below recommended threshold.", "evidence": f"{coverage*100:.3f}% < {rules.MIN_VOCAB_COVERAGE*100:.3f}%"})
78
+ if verdict == "PASS": verdict = "WARN"
79
+
80
+ if total_duration > 0:
81
+ tps = total_tokens / total_duration
82
+ if tps > rules.MAX_TOKENS_PER_SEC:
83
+ findings.append({"level": "WARN", "message": "High tokens per second of audio (possible sequence length blowout).", "evidence": f"{tps:.1f} tokens/sec > {rules.MAX_TOKENS_PER_SEC}"})
84
+ if verdict == "PASS": verdict = "WARN"
85
+
86
+ if suspicious_splits > len(lines) * 0.01:
87
+ findings.append({"level": "WARN", "message": "Suspicious splits detected on numbers/dates.", "evidence": f"{suspicious_splits} instances."})
88
+ if verdict == "PASS": verdict = "WARN"
89
+
90
+ if verdict == "PASS":
91
+ findings.append({"level": "PASS", "message": "Tokenizer vocabulary coverage and splits look healthy.", "evidence": f"{total_tokens} tokens evaluated."})
92
+
93
+ return {"verdict": verdict, "findings": findings}
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: trainproof
3
+ Version: 0.1.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
+ A deterministic linter for ML training runs. Run it on your dataset, tokenizer, and first-epoch logs — it gives a deterministic PASS/WARN/FAIL verdict with named findings and suggested fixes, BEFORE you burn weeks of GPU time.
16
+
17
+ ## Installation
18
+ ```bash
19
+ pip install .
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Exactly three subcommands:
25
+
26
+ ### 1. Dataset Preflight
27
+ ```bash
28
+ trainproof data /path/to/dataset
29
+ ```
30
+ Checks audio integrity (clipping, silence, duration distribution) and transcript quality (unnormalized text, charset audit, duration correlation).
31
+
32
+ ### 2. Tokenizer Preflight
33
+ ```bash
34
+ trainproof tokenizer my_model.model transcripts.txt
35
+ ```
36
+ Checks vocabulary coverage, tokens-per-second, and suspicious splits on numbers/dates.
37
+
38
+ ### 3. First-Epoch Verification
39
+ ```bash
40
+ trainproof epoch logs/epoch1.jsonl
41
+ ```
42
+ Analyzes loss curves (divergence, flatlines, NaN), grad norms, learning rate response, and throughput.
43
+
44
+ ## Supported log formats
45
+ - **Generic JSONL / CSV**
46
+ - **HuggingFace** (`trainer_state.json`)
47
+ - **Coqui TTS Trainer** (plain text `trainer_0_log.txt`)
48
+
49
+ *Roadmap: TensorBoard event files planned (v0.2) — Lightning console captures are TTY dumps, not logs, and will not be supported.*
50
+
51
+ ## Verdict Rules
52
+ All verdict rules are deterministic thresholds defined centrally in `src/trainproof/rules.py`. Examples include:
53
+ - `MAX_CLIPPING_PEAK = 0.99`
54
+ - `MAX_LOSS_DIVERGENCE_RATIO = 1.5`
55
+ - `MIN_VOCAB_COVERAGE = 0.999`
56
+
57
+ ## Explicit Non-Goals
58
+ - No live learning-rate auto-adjustment.
59
+ - No PyTorch/Lightning callbacks or framework hooks (log files only).
60
+ - No MCP server, no LLM integration.
61
+ - No dashboards/wandb-style UI.
62
+ - No extra features beyond this spec.
@@ -0,0 +1,14 @@
1
+ trainproof/__init__.py,sha256=U9rhJrmsaS-NrLEI6fOq3Bn0uShf5aSZe-wQq2ofXac,88
2
+ trainproof/adapters.py,sha256=GHDZdlTP6608dhpyEbqtUhsOzj1u8-1SOQ8hCMS4-dw,4963
3
+ trainproof/cli.py,sha256=eAZDeOnrhCWu3FqCOERebCrqcgeN37wTmCvelYULvQI,2175
4
+ trainproof/epoch.py,sha256=86ZEw9WuE8EN5rY70GVlzDELAMwuYFm8B99ZuDa3zaw,4542
5
+ trainproof/report.py,sha256=w7StbzYckqWlPKJEilfykdsX7jgS1Ivy4mdvSaZhKn0,2552
6
+ trainproof/rules.py,sha256=WRVKGuJmkwAbUR6NUOGqejl9hdiOI4XZNpT5gZj8LDk,1644
7
+ trainproof/speech/__init__.py,sha256=hGEY9HhXPULa_bMdbHkR1DuMC6U1d5BWNAFou3hcfHY,320
8
+ trainproof/speech/data.py,sha256=GmPdWeiJaskcsnAzpxOhXg2ytqvinNVEhviMwAXjHJU,8778
9
+ trainproof/speech/tokenizer.py,sha256=yinpEU-s7KYjioBd2vxg4WKqjkPt3_RsWM48Clv1Pj4,3995
10
+ trainproof-0.1.0.dist-info/METADATA,sha256=Yfl6DNEUEDDReiGZxzq_S3vPH1wKD-MXEjyuDam7_NA,2077
11
+ trainproof-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
12
+ trainproof-0.1.0.dist-info/entry_points.txt,sha256=OaPU4KxhS8FP4JuzV03pd44O8IW0EIqDJzqYeBpLae0,51
13
+ trainproof-0.1.0.dist-info/top_level.txt,sha256=YIUZaRRIq-WHsqi5nY0OKTzp_yTDnn2J7NeGg-Be438,11
14
+ trainproof-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trainproof = trainproof.cli:main
@@ -0,0 +1 @@
1
+ trainproof