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 +5 -0
- trainproof/adapters.py +143 -0
- trainproof/cli.py +55 -0
- trainproof/epoch.py +101 -0
- trainproof/report.py +56 -0
- trainproof/rules.py +53 -0
- trainproof/speech/__init__.py +9 -0
- trainproof/speech/data.py +190 -0
- trainproof/speech/tokenizer.py +93 -0
- trainproof-0.1.0.dist-info/METADATA +62 -0
- trainproof-0.1.0.dist-info/RECORD +14 -0
- trainproof-0.1.0.dist-info/WHEEL +5 -0
- trainproof-0.1.0.dist-info/entry_points.txt +2 -0
- trainproof-0.1.0.dist-info/top_level.txt +1 -0
trainproof/__init__.py
ADDED
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 @@
|
|
|
1
|
+
trainproof
|