silentbug 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.
silentbug/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """silentbug: find silent PyTorch training bugs and the line that caused them."""
2
+
3
+ from .report import BUG, CHECK, Finding, Report
4
+ from .session import Session, check
5
+
6
+ __all__ = ["check", "Session", "Report", "Finding", "BUG", "CHECK"]
7
+ __version__ = "0.1.0"
silentbug/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
silentbug/_where.py ADDED
@@ -0,0 +1,100 @@
1
+ """Find the line of user code that called the current torch op."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import linecache
6
+ import os
7
+ import sys
8
+ import sysconfig
9
+ from dataclasses import dataclass
10
+
11
+ import torch
12
+
13
+ _TORCH_DIR = os.path.dirname(os.path.realpath(torch.__file__)) + os.sep
14
+ _OWN_DIR = os.path.dirname(os.path.realpath(__file__)) + os.sep
15
+ _LIB_DIRS = tuple(
16
+ {os.path.realpath(p) + os.sep for p in (sysconfig.get_paths().get("purelib"), sysconfig.get_paths().get("platlib")) if p}
17
+ )
18
+ _STDLIB = os.path.realpath(sysconfig.get_paths()["stdlib"]) + os.sep
19
+
20
+ _real_cache: dict[str, str] = {}
21
+
22
+
23
+ def _real(path: str) -> str:
24
+ r = _real_cache.get(path)
25
+ if r is None:
26
+ r = os.path.realpath(path) if not path.startswith("<") else path
27
+ _real_cache[path] = r
28
+ return r
29
+
30
+
31
+ def _skip(path: str) -> bool:
32
+ if path.startswith("<"):
33
+ return True
34
+ p = _real(path)
35
+ return p.startswith(_TORCH_DIR) or p.startswith(_OWN_DIR) or p.startswith(_STDLIB) and not any(
36
+ p.startswith(d) for d in _LIB_DIRS
37
+ )
38
+
39
+
40
+ def _is_lib(path: str) -> bool:
41
+ p = _real(path)
42
+ return any(p.startswith(d) for d in _LIB_DIRS)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Where:
47
+ file: str
48
+ line: int
49
+ func: str
50
+ code: str
51
+ lib: bool = False # True if the line is inside an installed package
52
+
53
+ def short(self) -> str:
54
+ f = self.file
55
+ try:
56
+ rel = os.path.relpath(f)
57
+ if not rel.startswith(".."):
58
+ f = rel
59
+ except ValueError:
60
+ pass
61
+ return f"{f}:{self.line}"
62
+
63
+ def to_dict(self) -> dict:
64
+ return {"file": self.file, "line": self.line, "function": self.func, "code": self.code, "library": self.lib}
65
+
66
+
67
+ # torch packages that copy, sync or update parameters as bookkeeping, not as part of the model
68
+ _INFRA = tuple(_TORCH_DIR + d + os.sep for d in (
69
+ "distributed", os.path.join("nn", "parallel"), "optim", os.path.join("nn", "utils"),
70
+ "amp", os.path.join("cuda", "amp")))
71
+
72
+
73
+ def torch_caller(skip: int = 2) -> str | None:
74
+ """'infra' or 'torch' when the nearest frame outside silentbug is PyTorch's own code."""
75
+ f = sys._getframe(skip)
76
+ while f is not None:
77
+ p = _real(f.f_code.co_filename)
78
+ if not p.startswith(_OWN_DIR):
79
+ if p.startswith(_INFRA) or p.startswith(_TORCH_DIR + "_tensor_str"):
80
+ return "infra"
81
+ return "torch" if p.startswith(_TORCH_DIR) else None
82
+ f = f.f_back
83
+ return None
84
+
85
+
86
+ def where(skip: int = 2) -> tuple[Where | None, Where | None]:
87
+ """Return (first frame outside torch, first frame outside installed packages)."""
88
+ f = sys._getframe(skip)
89
+ first = None
90
+ while f is not None:
91
+ path = f.f_code.co_filename
92
+ if not _skip(path):
93
+ lib = _is_lib(path)
94
+ w = Where(path, f.f_lineno, f.f_code.co_name, linecache.getline(path, f.f_lineno).strip(), lib)
95
+ if first is None:
96
+ first = w
97
+ if not lib:
98
+ return first, (w if w != first else None)
99
+ f = f.f_back
100
+ return first, None
silentbug/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command line: `silentbug run train.py [args...]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import runpy
8
+ import sys
9
+
10
+ from .session import Session, _Done
11
+
12
+ USAGE = """silentbug run [--steps N] [--keep-going] [--json FILE] (script.py | -m module) [args...]
13
+
14
+ Runs your training script with silentbug watching the first N optimizer steps
15
+ (default 3), prints what it found, and stops the script. Exit code: 0 nothing
16
+ found, 1 likely bugs found, 2 only things to check."""
17
+
18
+
19
+ def _parse(argv: list[str]):
20
+ ap = argparse.ArgumentParser(prog="silentbug run", usage=USAGE)
21
+ ap.add_argument("--steps", type=int, default=3)
22
+ ap.add_argument("--keep-going", action="store_true", help="let the script keep running after the report")
23
+ ap.add_argument("--json", metavar="FILE", help="also write the report as JSON ('-' for stdout only JSON)")
24
+ ap.add_argument("-m", dest="module", action="store_true", help="run a module, like python -m")
25
+ ap.add_argument("target")
26
+ ap.add_argument("args", nargs=argparse.REMAINDER)
27
+ return ap.parse_args(argv)
28
+
29
+
30
+ def run(argv: list[str]) -> int:
31
+ a = _parse(argv)
32
+ s = Session(max_steps=a.steps, stop_at_limit=not a.keep_going)
33
+ old_argv = sys.argv[:]
34
+ sys.argv = [a.target, *a.args]
35
+ if not a.module:
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(a.target)))
37
+ error = None
38
+ s.start()
39
+ try:
40
+ if a.module:
41
+ runpy.run_module(a.target, run_name="__main__", alter_sys=True)
42
+ else:
43
+ runpy.run_path(a.target, run_name="__main__")
44
+ except _Done:
45
+ pass
46
+ except SystemExit:
47
+ pass
48
+ except BaseException as e: # the script crashed: still report what we saw
49
+ error = e
50
+ finally:
51
+ sys.argv = old_argv
52
+ rep = s.stop()
53
+ rank = os.environ.get("RANK", "0") # under torchrun every rank sees the same code
54
+ if rank != "0":
55
+ pass
56
+ elif a.json == "-":
57
+ print(rep.to_json())
58
+ else:
59
+ print(rep, file=sys.stderr if a.keep_going else sys.stdout)
60
+ if a.json:
61
+ with open(a.json, "w") as f:
62
+ f.write(rep.to_json())
63
+ if error is not None:
64
+ print(f"\n(your script raised {type(error).__name__}: {error})", file=sys.stderr)
65
+ return 1 if rep.bugs else (2 if rep.findings else 0)
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ argv = sys.argv[1:] if argv is None else argv
70
+ if not argv or argv[0] in ("-h", "--help"):
71
+ print(USAGE)
72
+ return 0
73
+ if argv[0] == "run":
74
+ return run(argv[1:])
75
+ if argv[0] in ("--version", "version"):
76
+ from . import __version__
77
+ print(__version__)
78
+ return 0
79
+ print(f"unknown command {argv[0]!r}\n\n{USAGE}", file=sys.stderr)
80
+ return 64
silentbug/explain.py ADDED
@@ -0,0 +1,225 @@
1
+ """Plain-language findings: what went wrong, why it matters, how to fix it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from ._where import Where
8
+ from .report import BUG, CHECK, Finding
9
+
10
+
11
+ def _s(shape) -> str:
12
+ return "(" + ", ".join(str(x) for x in shape) + ("," if len(shape) == 1 else "") + ")"
13
+
14
+
15
+ # ---------------------------------------------------------------- shapes
16
+
17
+ def broadcast(op: str, sym: str | None, sa, sb, out, same: bool, w: Where | None, c: Where | None) -> Finding:
18
+ expr = f"a {sym} b" if sym else f"{op}(a, b)"
19
+ big = math.prod(out)
20
+ small = max(math.prod(sa), math.prod(sb))
21
+ lo, hi = (sa, sb) if len(sa) < len(sb) else (sb, sa)
22
+ title = f"{expr} broadcasts {_s(sa)} with {_s(sb)} into {_s(out)}"
23
+ if same:
24
+ why = (f"the shapes have different ranks, so PyTorch puts a new leading axis on {_s(lo)} and stretches both "
25
+ f"tensors: you get {big} values instead of {small}. This is the classic pred (N,1) vs target (N,) "
26
+ "mistake, where every row is paired with every other row. Nothing errors; the loss is just wrong.")
27
+ level = BUG
28
+ else:
29
+ why = (f"both tensors get stretched ({_s(lo)} gets a new leading axis), giving {big} values from inputs of at "
30
+ f"most {small}. That is right for an outer product, and wrong anywhere you meant to combine matching "
31
+ "elements.")
32
+ level = CHECK
33
+ fix = (f"make the shapes match, e.g. `.squeeze(-1)` on the {_s(hi)} tensor or `.unsqueeze(-1)` on the {_s(lo)} one. "
34
+ "If you do want all pairs, write the new axis yourself (`x[:, None]`, `unsqueeze`) so the intent is visible; "
35
+ "silentbug then stays quiet.")
36
+ return Finding("broadcast", level, title, why, fix, w, c, detail={"a": list(sa), "b": list(sb), "out": list(out)})
37
+
38
+
39
+ def loss_shape(op: str, sx, sy, so, w, c) -> Finding:
40
+ grows = math.prod(so) > max(math.prod(sx), math.prod(sy))
41
+ title = f"{op} got input {_s(sx)} and target {_s(sy)}"
42
+ if grows:
43
+ why = (f"they broadcast to {_s(so)}, so each prediction is compared with many targets instead of its own. "
44
+ "The loss still goes down a little, which hides the bug. PyTorch only prints a UserWarning.")
45
+ else:
46
+ why = (f"they broadcast to {_s(so)}: one tensor is reused across a dimension of the other "
47
+ "(for example a batch of 1 compared with every sample).")
48
+ fix = "reshape one side so input and target have the same shape (often `.squeeze(-1)` or `.view_as(input)`)"
49
+ return Finding("broadcast", BUG, title, why, fix, w, c, detail={"input": list(sx), "target": list(sy), "out": list(so)})
50
+
51
+
52
+ # ---------------------------------------------------------------- activations into losses
53
+
54
+ def activation_into_loss(loss: str, act: str, act_w: Where | None, w: Where | None, c: Where | None) -> Finding | None:
55
+ at = f" (made at {act_w.short()})" if act_w else ""
56
+ if loss == "cross_entropy" and act in ("softmax", "softmax-mix"):
57
+ what = "softmax probabilities" if act == "softmax" else "values built from softmax probabilities"
58
+ return Finding("double-activation", BUG, f"cross_entropy got {what}{at}",
59
+ "cross_entropy applies log_softmax itself, so softmax runs twice. Outputs squash toward uniform, "
60
+ "gradients shrink, and training stalls at a poor accuracy without any error.",
61
+ "pass raw logits to the loss; apply softmax only for predictions and metrics", w, c)
62
+ if loss == "cross_entropy" and act == "sigmoid":
63
+ return Finding("double-activation", BUG, f"cross_entropy got sigmoid outputs{at}",
64
+ "cross_entropy expects raw logits; squashing them into (0, 1) first caps how confident the "
65
+ "model can get and weakens the gradient.",
66
+ "pass the logits before sigmoid", w, c)
67
+ if loss == "nll_loss" and act in ("softmax", "sigmoid", "softmax-mix"):
68
+ return Finding("double-activation", BUG, f"nll_loss got {act} probabilities{at}, not log-probabilities",
69
+ "nll_loss just picks -input[target]. With probabilities the loss becomes -p instead of -log p, "
70
+ "which goes negative and gives tiny gradients.",
71
+ "use log_softmax instead of softmax, or call cross_entropy on the logits", w, c)
72
+ if loss == "binary_cross_entropy_with_logits" and act == "sigmoid":
73
+ return Finding("double-activation", BUG, f"binary_cross_entropy_with_logits got sigmoid outputs{at}",
74
+ "the loss applies sigmoid itself, so it runs twice: predictions are squeezed into (0.5, 0.73) "
75
+ "and the loss can never reach zero.",
76
+ "pass the logits, or switch to binary_cross_entropy if you must pass probabilities", w, c)
77
+ if loss == "kl_div" and act in ("softmax", "sigmoid"):
78
+ return Finding("double-activation", BUG, f"kl_div got {act} probabilities{at} as input",
79
+ "F.kl_div expects log-probabilities as its first argument; with probabilities the value "
80
+ "and its gradient are wrong.",
81
+ "use log_softmax for the input (the target stays probabilities unless log_target=True)", w, c)
82
+ if loss in ("softmax", "log_softmax") and act == "softmax":
83
+ return Finding("double-activation", BUG, f"{loss} applied to an output that is already softmax{at}",
84
+ "softmax runs twice, which squashes the distribution toward uniform and shrinks gradients.",
85
+ "drop the first softmax and work from the logits", w, c)
86
+ return None
87
+
88
+
89
+ # ---------------------------------------------------------------- gradients
90
+
91
+ _CUT_TEXT = {
92
+ "detach": ("`.detach()` here cuts the graph, so no gradient flows back to these parameters",
93
+ "remove `.detach()` if these parameters should learn; keep it only for targets, logging, or a frozen branch"),
94
+ "data": ("`.data` gives a tensor with no gradient history, so everything computed from it is cut off",
95
+ "use the tensor itself (or `.detach()` where you really mean to stop gradients)"),
96
+ "value": ("`.{op}()` turns the tensor into a Python number or NumPy array; anything rebuilt from it has no gradient",
97
+ "keep the math in torch ops on tensors instead of going through Python numbers or NumPy"),
98
+ "new_tensor": ("`{op}(...)` makes a brand-new tensor with no gradient history",
99
+ "use the existing tensor (or torch.stack/torch.cat to combine tensors) instead of building a new one"),
100
+ "no_grad": ("this line runs with gradients turned off (inside torch.no_grad() or a @torch.no_grad() function)",
101
+ "move this computation out of the no_grad block if its parameters should learn"),
102
+ "inference_mode": ("this line runs inside torch.inference_mode(), which records no gradients",
103
+ "move this computation out of inference_mode if its parameters should learn"),
104
+ "requires_grad_false": ("`requires_grad_(False)` is called on a tensor computed from these parameters",
105
+ "remove the requires_grad_(False) call"),
106
+ "int": ("`{op}` returns integer or boolean values (indices, comparisons, casts), which carry no gradient",
107
+ "use a differentiable path: return the scores as well as the indices and add them to the loss, use soft "
108
+ "weights (softmax) instead of argmax/topk indices, or a straight-through estimator"),
109
+ "other": ("`{op}` returns a tensor with no gradient history",
110
+ "check whether this op is differentiable; if not, route the loss through a differentiable alternative"),
111
+ }
112
+
113
+
114
+ def cut(params: list[str], ev) -> Finding:
115
+ why, fix = _CUT_TEXT.get(ev.kind, _CUT_TEXT["other"])
116
+ op = ev.op.replace("attr:", "")
117
+ why = why.format(op=op)
118
+ n = len(params)
119
+ title = f"{n} parameter{'s' if n != 1 else ''} never get a gradient: cut by {op}"
120
+ return Finding("dead-param", BUG, title, why + ". They will never train, and nothing errors (unless you use DDP).",
121
+ fix, ev.where, ev.caller, sorted(params), {"cause": ev.kind, "op": op})
122
+
123
+
124
+ def unused(params: list[str], module: str, called: bool, has_opt: bool,
125
+ w: Where | None = None, c: Where | None = None) -> Finding:
126
+ n = len(params)
127
+ mod = f"`{module}`" if module else "the model"
128
+ if module and not called:
129
+ title = f"{n} parameter{'s' if n != 1 else ''} never get a gradient: {mod} is never called"
130
+ if w is not None:
131
+ why = (f"{mod} is defined, but forward() never calls it: its weights are read directly at the line "
132
+ "shown, which skips its forward() and anything it wraps (such as a LoRA adapter)")
133
+ else:
134
+ why = (f"{mod} is defined, but forward() never calls it (it may read its weights directly, like "
135
+ "F.linear(x, layer.weight), which skips any wrapper such as a LoRA adapter)")
136
+ fix = f"call {mod} in forward(), or delete it / set requires_grad=False so it stops looking trainable"
137
+ else:
138
+ title = f"{n} parameter{'s' if n != 1 else ''} never get a gradient: never used in forward"
139
+ why = f"{mod} runs, but no op ever reads these parameters"
140
+ fix = "use them in forward(), or delete them / set requires_grad=False"
141
+ level = BUG if has_opt else CHECK
142
+ why += ". They sit in the model and the optimizer but never learn; DDP would fail with 'did not receive grad'."
143
+ return Finding("dead-param", level, title, why, fix, w, c, sorted(params), {"cause": "unused", "module": module})
144
+
145
+
146
+ def orphan(params: list[str], w: Where | None, c: Where | None) -> Finding:
147
+ n = len(params)
148
+ title = f"{n} parameter{'s' if n != 1 else ''} never get a gradient: their output never reaches the loss"
149
+ why = ("they are used (first at the line shown), but nothing computed from them ends up in the value you call "
150
+ "backward() on: for example an extra output that is returned but never put into the loss, or a "
151
+ "feature map the next stage skips")
152
+ fix = "add that output to the loss, or remove the unused branch (or set requires_grad=False on it)"
153
+ return Finding("dead-param", BUG, title, why, fix, w, c, sorted(params), {"cause": "orphan"})
154
+
155
+
156
+ def zero_grad(params: list[str], ev) -> Finding:
157
+ n = len(params)
158
+ if ev is not None:
159
+ title = f"{n} parameter{'s' if n != 1 else ''} only ever get a zero gradient: through {ev.op}()"
160
+ why = f"{ev.op}() has a zero derivative almost everywhere, so the gradient that reaches these parameters is exactly 0"
161
+ fix = f"use a straight-through estimator, e.g. `x + (x.{ev.op}() - x).detach()`"
162
+ return Finding("zero-grad", BUG, title, why, fix, ev.where, ev.caller, sorted(params), {"op": ev.op})
163
+ title = f"{n} parameter{'s' if n != 1 else ''} only ever get a zero gradient"
164
+ why = "every gradient element was exactly 0 in every step: they are masked out, behind a dead ReLU, or multiplied by zero"
165
+ fix = "check masks and activations on the path from these parameters to the loss"
166
+ return Finding("zero-grad", CHECK, title, why, fix, None, None, sorted(params))
167
+
168
+
169
+ # ---------------------------------------------------------------- optimizer and modes
170
+
171
+ def not_in_optimizer(params: list[str], numel: int) -> Finding:
172
+ n = len(params)
173
+ return Finding(
174
+ "not-in-optimizer", BUG, f"{n} trainable parameter{'s' if n != 1 else ''} ({numel:,} values) are in no optimizer",
175
+ "they get gradients but optimizer.step() never updates them. This usually means a layer was created or "
176
+ "replaced after the optimizer was built (for example in a subclass __init__ after super().__init__()).",
177
+ "build the optimizer after the whole model exists, or add these parameters with optimizer.add_param_group()",
178
+ params=sorted(params))
179
+
180
+
181
+ def stale_optimizer(count: int, numel: int, replacements: list[str] | None = None) -> Finding:
182
+ why = ("these tensors are not part of any model that ran: a layer or parameter was probably replaced after the "
183
+ "optimizer was built, so the optimizer keeps updating the old copy.")
184
+ if replacements:
185
+ why += " The model now uses new parameters that no optimizer updates, so they never train."
186
+ return Finding(
187
+ "stale-optimizer", BUG, f"the optimizer updates {count} tensor{'s' if count != 1 else ''} ({numel:,} values) that the model no longer uses",
188
+ why, "rebuild the optimizer after replacing layers, or update parameters in place (param.data.copy_(new))",
189
+ params=sorted(replacements or []))
190
+
191
+
192
+ def eval_mode(mods: list[str], whole: bool, w, c) -> Finding:
193
+ kinds = sorted(set(mods))
194
+ if whole:
195
+ title = "the model is in eval() mode while it trains"
196
+ why = (f"gradients flow, but the model's training flag is off, so {', '.join(kinds)} behave as at inference "
197
+ "(dropout does nothing, batch norm uses frozen running stats). Usually model.eval() was called for "
198
+ "validation and model.train() was never called again.")
199
+ level = BUG
200
+ else:
201
+ title = f"{len(mods)} layer{'s' if len(mods) != 1 else ''} ({', '.join(kinds)}) run in eval mode during training"
202
+ why = "part of the model is in eval mode while gradients flow. This is fine if you froze them on purpose."
203
+ level = CHECK
204
+ return Finding("eval-mode", level, title, why, "call model.train() before the training step (e.g. at the top of each epoch)", w, c)
205
+
206
+
207
+ def batchnorm_batch1(shape, w, c) -> Finding:
208
+ return Finding("batchnorm-batch1", CHECK, f"batch norm in training mode got a batch of 1 {_s(shape)}",
209
+ "with one sample, batch statistics are just that sample's own values, which makes the running "
210
+ "stats noisy and the output nearly constant per channel",
211
+ "use a larger batch, drop_last=True in the DataLoader, or GroupNorm/LayerNorm", w, c)
212
+
213
+
214
+ def grad_not_cleared(w, c, manual: bool = False) -> Finding:
215
+ if manual:
216
+ return Finding("grad-not-cleared", BUG, "gradients from the previous step were not cleared",
217
+ "this weight update used gradients that still held the previous step's values: backward() adds onto "
218
+ ".grad, so every update uses the sum of all past gradients and the effective learning rate keeps growing",
219
+ "clear the gradients once per step, e.g. model.zero_grad() or p.grad = None for each parameter, "
220
+ "before backward()", w, c)
221
+ return Finding("grad-not-cleared", BUG, "gradients from the previous step were not cleared",
222
+ "this optimizer.step() used gradients that still held the previous step's values: backward() adds onto .grad, so every step uses the "
223
+ "sum of all past gradients and the effective learning rate keeps growing",
224
+ "call optimizer.zero_grad() once per step (before backward() or after step()). For gradient "
225
+ "accumulation, zero right after each optimizer.step().", w, c)
silentbug/report.py ADDED
@@ -0,0 +1,105 @@
1
+ """Findings and the printed report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+
8
+ from ._where import Where
9
+
10
+ # How sure we are that a finding is a real bug.
11
+ BUG = "bug" # almost certainly wrong
12
+ CHECK = "check" # often wrong, sometimes intended
13
+
14
+
15
+ @dataclass
16
+ class Finding:
17
+ kind: str # broadcast | dead-param | zero-grad | not-in-optimizer | stale-optimizer | double-activation | eval-mode | batchnorm-batch1 | grad-not-cleared
18
+ level: str # BUG or CHECK
19
+ title: str
20
+ why: str
21
+ fix: str
22
+ where: Where | None = None
23
+ caller: Where | None = None # your own line, when `where` is inside a library
24
+ params: list[str] = field(default_factory=list)
25
+ detail: dict = field(default_factory=dict)
26
+
27
+ def key(self) -> tuple:
28
+ loc = (self.where.file, self.where.line) if self.where else None
29
+ return (self.kind, loc, self.title)
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "kind": self.kind,
34
+ "level": self.level,
35
+ "title": self.title,
36
+ "why": self.why,
37
+ "fix": self.fix,
38
+ "where": self.where.to_dict() if self.where else None,
39
+ "caller": self.caller.to_dict() if self.caller else None,
40
+ "params": self.params,
41
+ "detail": self.detail,
42
+ }
43
+
44
+
45
+ def _plist(names: list[str], limit: int = 6) -> str:
46
+ if len(names) <= limit:
47
+ return ", ".join(names)
48
+ return ", ".join(names[:limit]) + f" and {len(names) - limit} more"
49
+
50
+
51
+ @dataclass
52
+ class Report:
53
+ findings: list[Finding] = field(default_factory=list)
54
+ steps: int = 0 # optimizer steps seen
55
+ backwards: int = 0 # backward passes seen
56
+ models: list[str] = field(default_factory=list)
57
+
58
+ @property
59
+ def bugs(self) -> list[Finding]:
60
+ return [f for f in self.findings if f.level == BUG]
61
+
62
+ @property
63
+ def checks(self) -> list[Finding]:
64
+ return [f for f in self.findings if f.level == CHECK]
65
+
66
+ def kinds(self) -> set[str]:
67
+ return {f.kind for f in self.findings}
68
+
69
+ def __bool__(self) -> bool: # truthy when something was found
70
+ return bool(self.findings)
71
+
72
+ def to_dict(self) -> dict:
73
+ return {
74
+ "findings": [f.to_dict() for f in self.findings],
75
+ "steps": self.steps,
76
+ "backwards": self.backwards,
77
+ "models": self.models,
78
+ }
79
+
80
+ def to_json(self) -> str:
81
+ return json.dumps(self.to_dict(), indent=2)
82
+
83
+ def __str__(self) -> str:
84
+ head = f"silentbug: checked {self.backwards} backward pass(es), {self.steps} optimizer step(s)"
85
+ if self.models:
86
+ head += f" on {', '.join(self.models)}"
87
+ if not self.findings:
88
+ msg = head + "\n no silent bugs found"
89
+ if self.backwards == 0:
90
+ msg += " (but no backward pass ran, so gradient checks were skipped)"
91
+ return msg
92
+ out = [head, f" {len(self.bugs)} likely bug(s), {len(self.checks)} thing(s) to check", ""]
93
+ for i, f in enumerate(sorted(self.findings, key=lambda f: f.level != BUG), 1):
94
+ tag = "BUG " if f.level == BUG else "CHECK"
95
+ out.append(f"[{tag}] {i}. {f.title}")
96
+ if f.where:
97
+ out.append(f" at {f.where.short()}: {f.where.code}")
98
+ if f.caller:
99
+ out.append(f" called from {f.caller.short()}: {f.caller.code}")
100
+ if f.params:
101
+ out.append(f" parameters: {_plist(f.params)}")
102
+ out.append(f" why: {f.why}")
103
+ out.append(f" fix: {f.fix}")
104
+ out.append("")
105
+ return "\n".join(out).rstrip()