ulpwise 0.3.2__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ulpwise-0.3.2 → ulpwise-0.3.3}/CHANGELOG.md +55 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/Cargo.lock +1 -1
- {ulpwise-0.3.2 → ulpwise-0.3.3}/Cargo.toml +1 -1
- {ulpwise-0.3.2 → ulpwise-0.3.3}/PKG-INFO +20 -6
- {ulpwise-0.3.2 → ulpwise-0.3.3}/README.md +19 -5
- {ulpwise-0.3.2 → ulpwise-0.3.3}/pyproject.toml +1 -1
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/__init__.py +1 -1
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/corpus/cases.json +64 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/scan.py +330 -9
- {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_scan.py +119 -4
- {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/dependabot.yml +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/workflows/ci.yml +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/workflows/release.yml +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/.gitignore +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/CONTRIBUTING.md +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/LICENSE-APACHE +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/LICENSE-MIT +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/examples/sqrt_knife_values.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/examples/torch_sqrt_conformance.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/__main__.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/corpus/__init__.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/plugin.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/survey.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/edge.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/exact.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/knife.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/lib.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/py.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/src/ulp.rs +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/README.md +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.csv +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.md +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/README.md +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/results.csv +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/results.md +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_core.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_corpus.py +0 -0
- {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_survey.py +0 -0
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 (2026-10-08)
|
|
4
|
+
|
|
5
|
+
- Two regression corpus cases for peft, both open upstream with a fix PR: peft #3769, `add_weighted_adapter` with
|
|
6
|
+
`combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers because only Conv2d deltas were flattened before
|
|
7
|
+
the SVD (the case combines two rank 2 adapters at `svd_rank=4` and expects the output of both adapters active, which
|
|
8
|
+
the fix matches to 2e-6), and peft #3830, OFT `module_dropout` was never applied, so ten training forwards of the
|
|
9
|
+
same input were identical where the fix spreads them by 0.56.
|
|
10
|
+
- `ulpwise scan`: new `where-nan-gradient` rule (medium) for `where(d > eps, f(d), other)` with `f` a division by `d`
|
|
11
|
+
or a `sqrt`, `log`, `acos` or `asin` of it. `where` evaluates both branches and hands the discarded one a zero
|
|
12
|
+
gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the
|
|
13
|
+
value and not the gradient. kornia #5579, found by the kornia conventions audit, is the example: the mutual
|
|
14
|
+
information losses divided by the range of the signal inside `where(diff > eps, ...)` and a constant input or
|
|
15
|
+
target got a nan gradient. The condition may be a comparison, a name bound to one, or `~`, `&`, `|` and a subscript
|
|
16
|
+
of those, and the method form `a.where(cond, b)` counts. Quiet for `numpy.where`, which has no autograd, for a
|
|
17
|
+
comparison against a number above 1 (`strength < 50` in kornia's JPEG scale is a piecewise definition), for a
|
|
18
|
+
floor named `eps`, `tol`, `floor`, `thresh`, `tiny`, `bound` or `limit` on the other side of the comparison, and
|
|
19
|
+
once `d` is re-bound to a `where`, `clamp` or `maximum` of itself, which is the recommended fix and the shape of
|
|
20
|
+
kornia's `fit_line` and `rotation_matrix_to_quaternion`. On kornia main d15741e2 the rule reports four lines, on
|
|
21
|
+
ultralytics main 8df3534 two, both in metric code, and nothing in torchvision.
|
|
22
|
+
- `ulpwise scan`: new `clamp-at-singularity` rule (medium) for `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`,
|
|
23
|
+
`clamp(c, -1, 1).acos()` and the `clip`, `clamp_min`, `clamp_max`, `asin` and `log` forms: the bound is the point
|
|
24
|
+
where the next function has an infinite derivative, and clamp's derivative at its own bound is 1 on torch 2.5.1 and
|
|
25
|
+
2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range. kornia #4229,
|
|
26
|
+
found by the kornia conventions audit, is the example, `_cdist`'s `clamp(min=0.0).sqrt()` with identical float16
|
|
27
|
+
descriptors, and kornia #5500's `clamp(-1, 1).acos()` the second. A bound strictly inside the domain, `min=1e-8`,
|
|
28
|
+
is a floor and is not reported, a bound given by a name is not read, and numpy is quiet. On kornia main d15741e2
|
|
29
|
+
the rule reports the three lines of `_solve_cubic_real`, on ultralytics main 8df3534 one line of `DepthMetrics`.
|
|
30
|
+
- `ulpwise scan`: a finding inside a nested function is reported once, under the nested function, instead of once
|
|
31
|
+
more under the enclosing one, and the enclosing function's call and division counts no longer include it.
|
|
32
|
+
- `ulpwise scan`: new `eps-floor` rule (medium) for `x * (1 - eps) + eps`, in either order and with `self.eps` too: the
|
|
33
|
+
floor moves every value, 0 becomes `eps` and 1 stays 1, so the entries of a one-hot sum to `1 + (C - 1) eps` and a
|
|
34
|
+
perfect prediction scores a loss that grows with the image. kornia #5538, found by the kornia conventions audit, is
|
|
35
|
+
the example: `one_hot` floored its zeros with 1e-6 and a perfect prediction of a 256 × 384 image with a one-pixel
|
|
36
|
+
class scored a macro Dice loss of 0.0234. On kornia main 9e188e08 the rule reports that line and nothing else, and
|
|
37
|
+
nothing in ultralytics or torchvision; `(p + eps) / (q + eps)` is not a floor and stays quiet.
|
|
38
|
+
- `ulpwise scan`: the `hypot-by-hand` advice no longer offers `norm` as the replacement. `torch.linalg.vector_norm` and
|
|
39
|
+
`numpy.linalg.norm` square the entries first too, so in float32 both return `inf` for a vector with two entries of
|
|
40
|
+
1.8e19 while `hypot` returns 2.5e19; the advice now says to divide a vector by its largest absolute entry when the
|
|
41
|
+
point is a direction. The kornia maintainer caught the case in review of kornia #5505, where `v / v.norm()` turned
|
|
42
|
+
such vectors into zeros and the angle between them read 0.
|
|
43
|
+
- `ulpwise scan`: `acos-for-angle` moves from `info` to `medium` and names the mechanism: next to 1 the cosine has no
|
|
44
|
+
digits left for a small angle, so `acos` recovers the angle with an absolute error of `sqrt(eps)`, 0.02 degrees in
|
|
45
|
+
float32, whatever the angle. The kornia scan listed `kornia/metrics/pose.py` under this rule, and both functions
|
|
46
|
+
there, `angle_error_mat` and `angle_error_vec`, return exactly 0 for every rotation below 0.03 degrees in float32
|
|
47
|
+
and 180.0 for 179.99 degrees (kornia #5500); the rule cites it.
|
|
48
|
+
- Regression corpus case for kornia #5500: a 0.01 degree rotation scores 0.0 in both metrics on kornia main 050ac77f.
|
|
49
|
+
- The kornia #5500 corpus case records its fix, kornia #5505, merged 2026-10-06: `angle_error_mat` reads the sine from
|
|
50
|
+
the skew part of the relative rotation and `angle_error_vec` from the cross product of the scaled vectors, and both
|
|
51
|
+
return `atan2(sin, cos)`, so a 0.01 degree rotation scores 0.0099990 in float32. The case stays an expected failure
|
|
52
|
+
until a kornia release carries the fix.
|
|
53
|
+
- `ulpwise scan`: `logsumexp-by-hand` no longer fires on the stable form, where every `exp` argument has its
|
|
54
|
+
maximum subtracted first (`log(exp(x - x.max()).sum())`, or `s = x - m` with `m` bound to a `max`, `amax`,
|
|
55
|
+
`maximum` or `torch.max(...).values`). The lm-evaluation-harness scan listed the numerically stable `_log_softmax`
|
|
56
|
+
of `lm_eval/models/_onnx_base.py` next to a real `log(exp(a) + exp(b))`; it now reports only the real one.
|
|
57
|
+
|
|
3
58
|
## 0.3.2 (2026-10-06)
|
|
4
59
|
|
|
5
60
|
- Two regression corpus cases for pytorch #199850: `torch.erf` in bfloat16 and float16 on CPU returns 0 at and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ulpwise
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Framework :: Pytest
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -228,6 +228,9 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
|
|
|
228
228
|
| pytorch #198664 `erfcx` off by `x*x/2` ulps for negative `x` (`exp` at the rounded square) | rounding | open |
|
|
229
229
|
| pytorch #199850 `torch.erf` in bfloat16 and float16 on CPU returns 0 at and below 1.8e-7 and loses relative accuracy below 1e-3 (13404 bfloat16 ulps, 5 float16 ulps) | cancellation | open |
|
|
230
230
|
| pytorch #199867 `torch.special.logit` in float16 and bfloat16 on CPU rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, logit(0.499756) is -0.000488 for -0.000977 (512 float16 ulps, 64 bfloat16 ulps) | rounding | open |
|
|
231
|
+
| kornia #5505 `angle_error_mat` and `angle_error_vec` returned exactly 0 for every rotation below 0.03 degrees in float32 (`acos` next to 1, issue #5500) | cancellation | 2026-10-06 |
|
|
232
|
+
| peft #3769 `add_weighted_adapter` with `combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers | crash | open |
|
|
233
|
+
| peft #3830 OFT `module_dropout` was never applied, every training forward used all rotation blocks | no-op | open |
|
|
231
234
|
|
|
232
235
|
Cases marked `open` have an issue with the complete patch attached and no merged fix yet. Every case
|
|
233
236
|
is an expected failure until the installed release contains the fix (`fixed_in_release` in
|
|
@@ -246,22 +249,26 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
|
|
|
246
249
|
```
|
|
247
250
|
|
|
248
251
|
The scan is static and needs nothing installed: it parses each file with `ast`, walks every
|
|
249
|
-
function and matches
|
|
250
|
-
overflows, the usual replacement and, where one exists, the upstream bug it
|
|
252
|
+
function and matches fifteen patterns, each with a severity, the reason it loses digits,
|
|
253
|
+
overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
|
|
254
|
+
comes from.
|
|
251
255
|
|
|
252
256
|
| rule | severity | pattern |
|
|
253
257
|
|---|---|---|
|
|
254
258
|
| `exp-of-square` | high | `exp(x * x)`, `exp(x ** 2)`, `(-x.pow(2)).exp()`: the rounding error of the square is multiplied by `x * x / 2` ulps (pytorch #198664) |
|
|
255
259
|
| `sin-of-pi-times` | high | `sin(pi * x)`, `cos(pi * x)`: the product is rounded before the argument reduction (pytorch #198663) |
|
|
256
260
|
| `softplus-by-hand` | high | `log(1 + exp(x))` |
|
|
257
|
-
| `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))` |
|
|
258
|
-
| `hypot-by-hand` | high | `sqrt(a * a + b * b)` |
|
|
261
|
+
| `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))`; quiet when every `exp` argument has its maximum subtracted first, `log(exp(x - x.max()).sum())`, the stable form |
|
|
262
|
+
| `hypot-by-hand` | high | `sqrt(a * a + b * b)`; the advice no longer says `norm`, which squares first too and overflows at 1.8e19 in float32 (kornia #5505 review) |
|
|
259
263
|
| `sqrt-of-difference` | medium | `sqrt(a - b)` |
|
|
260
264
|
| `one-minus-cos` | medium | `1 - cos(x)` (kornia #4897) |
|
|
261
265
|
| `log1p-by-hand`, `expm1-by-hand` | medium | `log(1 + x)`, `exp(x) - 1` |
|
|
262
266
|
| `atan-of-quotient` | medium | `atan(y / x)` |
|
|
263
267
|
| `small-angle-division` | medium | `/ theta`, `/ theta ** 2`, `/ sin(theta)` in a function that takes `sin` or `cos` of `theta` and has no `where`, `clamp`, `eps` or series in sight (kornia #4838, #4897) |
|
|
264
|
-
| `acos-for-angle` |
|
|
268
|
+
| `acos-for-angle` | medium | `acos`, `asin` used to recover an angle: the angle comes back with an absolute error of `sqrt(eps)`, 0.02 degrees in float32 (kornia #5500) |
|
|
269
|
+
| `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
|
|
270
|
+
| `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
|
|
271
|
+
| `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
|
|
265
272
|
|
|
266
273
|
On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
|
|
267
274
|
`one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
|
|
@@ -270,6 +277,13 @@ and `Se3.exp` (also #4897) is under `one-minus-cos`. The scan puts `ellipse_to_l
|
|
|
270
277
|
on the list too, for a `sqrt` of a difference; the bug there was a different one, so that entry is
|
|
271
278
|
what the scan is: a reading list, not a verdict.
|
|
272
279
|
|
|
280
|
+
The two gradient rules are reading lists in the same sense. On kornia `main` at `d15741e2` they
|
|
281
|
+
report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where` guards, in
|
|
282
|
+
`PatchDominantGradientOrientation`, `compute_correspond_epilines`, `_crop_scale_translation` and
|
|
283
|
+
`_get_convex_edges`, of which the first two sit on a differentiable path and the last two draw
|
|
284
|
+
boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
|
|
285
|
+
inference code that is never differentiated, and nothing in torchvision.
|
|
286
|
+
|
|
273
287
|
`--run` adds the dynamic half. The module level functions with the most elementary math are
|
|
274
288
|
imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
|
|
275
289
|
required argument, in float32 and in float64, torch first and numpy second, and the float32 result
|
|
@@ -199,6 +199,9 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
|
|
|
199
199
|
| pytorch #198664 `erfcx` off by `x*x/2` ulps for negative `x` (`exp` at the rounded square) | rounding | open |
|
|
200
200
|
| pytorch #199850 `torch.erf` in bfloat16 and float16 on CPU returns 0 at and below 1.8e-7 and loses relative accuracy below 1e-3 (13404 bfloat16 ulps, 5 float16 ulps) | cancellation | open |
|
|
201
201
|
| pytorch #199867 `torch.special.logit` in float16 and bfloat16 on CPU rounds `1 - x` and `x / (1 - x)` to the input dtype before the log, logit(0.499756) is -0.000488 for -0.000977 (512 float16 ulps, 64 bfloat16 ulps) | rounding | open |
|
|
202
|
+
| kornia #5505 `angle_error_mat` and `angle_error_vec` returned exactly 0 for every rotation below 0.03 degrees in float32 (`acos` next to 1, issue #5500) | cancellation | 2026-10-06 |
|
|
203
|
+
| peft #3769 `add_weighted_adapter` with `combination_type='svd'` crashed for Conv1d and Conv3d LoRA layers | crash | open |
|
|
204
|
+
| peft #3830 OFT `module_dropout` was never applied, every training forward used all rotation blocks | no-op | open |
|
|
202
205
|
|
|
203
206
|
Cases marked `open` have an issue with the complete patch attached and no merged fix yet. Every case
|
|
204
207
|
is an expected failure until the installed release contains the fix (`fixed_in_release` in
|
|
@@ -217,22 +220,26 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
|
|
|
217
220
|
```
|
|
218
221
|
|
|
219
222
|
The scan is static and needs nothing installed: it parses each file with `ast`, walks every
|
|
220
|
-
function and matches
|
|
221
|
-
overflows, the usual replacement and, where one exists, the upstream bug it
|
|
223
|
+
function and matches fifteen patterns, each with a severity, the reason it loses digits,
|
|
224
|
+
overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
|
|
225
|
+
comes from.
|
|
222
226
|
|
|
223
227
|
| rule | severity | pattern |
|
|
224
228
|
|---|---|---|
|
|
225
229
|
| `exp-of-square` | high | `exp(x * x)`, `exp(x ** 2)`, `(-x.pow(2)).exp()`: the rounding error of the square is multiplied by `x * x / 2` ulps (pytorch #198664) |
|
|
226
230
|
| `sin-of-pi-times` | high | `sin(pi * x)`, `cos(pi * x)`: the product is rounded before the argument reduction (pytorch #198663) |
|
|
227
231
|
| `softplus-by-hand` | high | `log(1 + exp(x))` |
|
|
228
|
-
| `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))` |
|
|
229
|
-
| `hypot-by-hand` | high | `sqrt(a * a + b * b)` |
|
|
232
|
+
| `logsumexp-by-hand` | high | `log(exp(a) + exp(b))`, `log(sum(exp(x)))`, `log(exp(x).sum(-1))`; quiet when every `exp` argument has its maximum subtracted first, `log(exp(x - x.max()).sum())`, the stable form |
|
|
233
|
+
| `hypot-by-hand` | high | `sqrt(a * a + b * b)`; the advice no longer says `norm`, which squares first too and overflows at 1.8e19 in float32 (kornia #5505 review) |
|
|
230
234
|
| `sqrt-of-difference` | medium | `sqrt(a - b)` |
|
|
231
235
|
| `one-minus-cos` | medium | `1 - cos(x)` (kornia #4897) |
|
|
232
236
|
| `log1p-by-hand`, `expm1-by-hand` | medium | `log(1 + x)`, `exp(x) - 1` |
|
|
233
237
|
| `atan-of-quotient` | medium | `atan(y / x)` |
|
|
234
238
|
| `small-angle-division` | medium | `/ theta`, `/ theta ** 2`, `/ sin(theta)` in a function that takes `sin` or `cos` of `theta` and has no `where`, `clamp`, `eps` or series in sight (kornia #4838, #4897) |
|
|
235
|
-
| `acos-for-angle` |
|
|
239
|
+
| `acos-for-angle` | medium | `acos`, `asin` used to recover an angle: the angle comes back with an absolute error of `sqrt(eps)`, 0.02 degrees in float32 (kornia #5500) |
|
|
240
|
+
| `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
|
|
241
|
+
| `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
|
|
242
|
+
| `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
|
|
236
243
|
|
|
237
244
|
On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
|
|
238
245
|
`one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
|
|
@@ -241,6 +248,13 @@ and `Se3.exp` (also #4897) is under `one-minus-cos`. The scan puts `ellipse_to_l
|
|
|
241
248
|
on the list too, for a `sqrt` of a difference; the bug there was a different one, so that entry is
|
|
242
249
|
what the scan is: a reading list, not a verdict.
|
|
243
250
|
|
|
251
|
+
The two gradient rules are reading lists in the same sense. On kornia `main` at `d15741e2` they
|
|
252
|
+
report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where` guards, in
|
|
253
|
+
`PatchDominantGradientOrientation`, `compute_correspond_epilines`, `_crop_scale_translation` and
|
|
254
|
+
`_get_convex_edges`, of which the first two sit on a differentiable path and the last two draw
|
|
255
|
+
boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
|
|
256
|
+
inference code that is never differentiated, and nothing in torchvision.
|
|
257
|
+
|
|
244
258
|
`--run` adds the dynamic half. The module level functions with the most elementary math are
|
|
245
259
|
imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
|
|
246
260
|
required argument, in float32 and in float64, torch first and numpy second, and the float32 result
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ulpwise"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Numerical conformance testing for ML code: float edge values, exact rounding oracles, knife-edge finders and a pytest plugin"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT OR Apache-2.0" }
|
|
@@ -1103,6 +1103,70 @@
|
|
|
1103
1103
|
"max_ulp": 1
|
|
1104
1104
|
},
|
|
1105
1105
|
"before_fix": "[-0.00048828125, -0.01129150390625, -0.039337158203125, 0.0797119140625] on torch 2.14.0 (512, 72, 23 and 6.7 ulp)"
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
"id": "kornia-5500-angle-error-small-angle-acos",
|
|
1109
|
+
"repo": "kornia/kornia",
|
|
1110
|
+
"pr": 5505,
|
|
1111
|
+
"issue": 5500,
|
|
1112
|
+
"merged_at": "2026-10-06T17:27:13Z",
|
|
1113
|
+
"kind": "cancellation",
|
|
1114
|
+
"title": "angle_error_mat and angle_error_vec return exactly 0 for every rotation below 0.03 degrees in float32",
|
|
1115
|
+
"symptom": "Both metrics recover the angle as acos of a cosine, (tr(R1^T R2) - 1) / 2 and v1 . v2 / (|v1| |v2|). Next to 1 the float32 cosine has no digits left for a small angle, so a 0.01 degree rotation scores 0.0, a 0.05 degree one is 3 to 21 % off depending on the rotations and 179.99 degrees comes back as 180.0; the clamp(-1, 1).acos() also has a -inf / nan backward at exactly 0 and 180 degrees on torch 2.5.1. The patch reads sin(theta) from the skew part of the relative rotation, the cross product of the unit vectors for angle_error_vec, and returns atan2(sin, cos).",
|
|
1116
|
+
"requires": [
|
|
1117
|
+
"torch",
|
|
1118
|
+
"kornia"
|
|
1119
|
+
],
|
|
1120
|
+
"fixed_in_release": null,
|
|
1121
|
+
"repro": "import torch\nimport kornia\nR1 = kornia.geometry.axis_angle_to_rotation_matrix(torch.tensor([[0.3, -0.5, 0.8]], dtype=torch.float64))\nR2 = R1 @ kornia.geometry.axis_angle_to_rotation_matrix(torch.deg2rad(torch.tensor([[0.0, 0.0, 0.01]], dtype=torch.float64)))\nv1 = torch.tensor([[0.0, 0.6, 0.8]], dtype=torch.float64)\nv2 = (kornia.geometry.axis_angle_to_rotation_matrix(torch.deg2rad(torch.tensor([[0.01, 0.0, 0.0]], dtype=torch.float64))) @ v1[..., None])[..., 0]\nactual = [kornia.metrics.angle_error_mat(R1.float(), R2.float()).item(), kornia.metrics.angle_error_vec(v1.float(), v2.float()).item()]\nexpected = [0.01, 0.01]\n",
|
|
1122
|
+
"check": {
|
|
1123
|
+
"type": "allclose",
|
|
1124
|
+
"atol": 0.0,
|
|
1125
|
+
"rtol": 0.001
|
|
1126
|
+
},
|
|
1127
|
+
"before_fix": "[0.0, 0.0] on kornia main 050ac77f"
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
"id": "peft-3769-add-weighted-adapter-svd-conv1d-conv3d",
|
|
1131
|
+
"repo": "huggingface/peft",
|
|
1132
|
+
"pr": null,
|
|
1133
|
+
"issue": 3769,
|
|
1134
|
+
"merged_at": null,
|
|
1135
|
+
"kind": "crash",
|
|
1136
|
+
"title": "add_weighted_adapter with combination_type='svd' crashed for Conv1d and Conv3d LoRA layers",
|
|
1137
|
+
"symptom": "_svd_generalized_task_arithmetic_weighted_adapter checked isinstance(target, Conv2d) and flattened only that, so a Conv1d or Conv3d delta weight reached the SVD with its kernel dims in place and the matmul raised. The fix tests _ConvNd and always flattens from dim 1; PR 3903 is open with it.",
|
|
1138
|
+
"requires": [
|
|
1139
|
+
"torch",
|
|
1140
|
+
"peft"
|
|
1141
|
+
],
|
|
1142
|
+
"fixed_in_release": null,
|
|
1143
|
+
"repro": "import torch\nfrom peft import LoraConfig, get_peft_model\n\nclass Net(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.conv = torch.nn.Conv1d(4, 8, kernel_size=3)\n\n def forward(self, x):\n return self.conv(x)\n\ntorch.manual_seed(0)\nconfig = LoraConfig(target_modules=['conv'], r=2, init_lora_weights=False)\nmodel = get_peft_model(Net(), config, adapter_name='a')\nmodel.add_adapter('b', config)\nx = torch.randn(1, 4, 10)\nwith torch.no_grad():\n model.base_model.set_adapter(['a', 'b'])\n both = model(x)\n # svd_rank=4 holds the rank 2 + 2 sum of the two deltas exactly\n model.add_weighted_adapter(['a', 'b'], [1.0, 1.0], 'ab', combination_type='svd', svd_rank=4)\n model.set_adapter('ab')\n combined = model(x)\nactual = combined.flatten().tolist()\nexpected = both.flatten().tolist()\n",
|
|
1144
|
+
"check": {
|
|
1145
|
+
"type": "allclose",
|
|
1146
|
+
"atol": 1e-05,
|
|
1147
|
+
"rtol": 1e-05
|
|
1148
|
+
},
|
|
1149
|
+
"before_fix": "RuntimeError inside _svd_generalized_task_arithmetic_weighted_adapter on peft main 91ca7945: Expected size for first two dimensions of batch2 tensor to be: [8, 4] but got: [8, 3]"
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
"id": "peft-3830-oft-module-dropout-never-applied",
|
|
1153
|
+
"repo": "huggingface/peft",
|
|
1154
|
+
"pr": null,
|
|
1155
|
+
"issue": 3830,
|
|
1156
|
+
"merged_at": null,
|
|
1157
|
+
"kind": "no-op",
|
|
1158
|
+
"title": "OFT module_dropout was never applied, every training forward used all rotation blocks",
|
|
1159
|
+
"symptom": "OFTLayer built self.oft_dropout from module_dropout but OFTRotationModule.forward never called it, so module_dropout=0.5 behaved like 0 and ten training forwards of the same input were identical. The fix passes the dropout into the rotation module and applies it to the Cayley blocks; PR 3905 is open with it.",
|
|
1160
|
+
"requires": [
|
|
1161
|
+
"torch",
|
|
1162
|
+
"peft"
|
|
1163
|
+
],
|
|
1164
|
+
"fixed_in_release": null,
|
|
1165
|
+
"repro": "import torch\nfrom peft import OFTConfig, get_peft_model\n\nclass Net(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.lin = torch.nn.Linear(16, 12)\n\n def forward(self, x):\n return self.lin(x)\n\ntorch.manual_seed(0)\nconfig = OFTConfig(target_modules=['lin'], r=4, oft_block_size=0, module_dropout=0.5, init_weights=False)\nmodel = get_peft_model(Net(), config)\nmodel.train()\nx = torch.randn(8, 16)\nwith torch.no_grad():\n outputs = [model(x) for _ in range(10)]\n# module_dropout replaces half of the four rotation blocks by the identity in each training forward,\n# so ten forwards cannot all agree\nactual = [max((outputs[0] - out).abs().max().item() for out in outputs[1:])]\nexpected = [1.0] # any positive spread\n",
|
|
1166
|
+
"check": {
|
|
1167
|
+
"type": "sign"
|
|
1168
|
+
},
|
|
1169
|
+
"before_fix": "[0.0] on peft main 91ca7945; with the fix the ten forwards spread by 0.56"
|
|
1106
1170
|
}
|
|
1107
1171
|
]
|
|
1108
1172
|
}
|
|
@@ -65,15 +65,16 @@ RULES = {
|
|
|
65
65
|
),
|
|
66
66
|
"logsumexp-by-hand": (
|
|
67
67
|
"high",
|
|
68
|
-
"log of a sum of exps: any large term overflows the sum to inf",
|
|
68
|
+
"log of a sum of exps with no maximum subtracted first: any large term overflows the sum to inf",
|
|
69
69
|
"logsumexp, or subtract the maximum before exp",
|
|
70
70
|
"",
|
|
71
71
|
),
|
|
72
72
|
"hypot-by-hand": (
|
|
73
73
|
"high",
|
|
74
74
|
"sqrt(a * a + b * b): the squares overflow or underflow long before the result would",
|
|
75
|
-
"hypot(a, b) or norm"
|
|
76
|
-
"",
|
|
75
|
+
"hypot(a, b); a torch or numpy norm squares the entries first too and overflows at sqrt(max), 1.8e19 in float32, "
|
|
76
|
+
"so to normalise a vector divide by its largest absolute entry rather than by its norm",
|
|
77
|
+
"kornia #5505 review, angle_error_vec divided by the norm and returned 0 for float32 vectors longer than 1.8e19",
|
|
77
78
|
),
|
|
78
79
|
"sqrt-of-difference": (
|
|
79
80
|
"medium",
|
|
@@ -112,10 +113,28 @@ RULES = {
|
|
|
112
113
|
"kornia #4838 and #4897, So3.log and the Jacobians",
|
|
113
114
|
),
|
|
114
115
|
"acos-for-angle": (
|
|
115
|
-
"
|
|
116
|
-
"acos or asin to recover an angle: the
|
|
116
|
+
"medium",
|
|
117
|
+
"acos or asin to recover an angle: next to 1 the cosine has no digits left for a small angle, so the angle comes back with an absolute error of sqrt(eps), 0.02 degrees in float32, whatever its size, and the derivative is infinite at the ends",
|
|
117
118
|
"atan2 of the cross product norm and the dot product",
|
|
118
|
-
"",
|
|
119
|
+
"kornia #5500, angle_error_mat and angle_error_vec return 0 below 0.03 degrees in float32",
|
|
120
|
+
),
|
|
121
|
+
"eps-floor": (
|
|
122
|
+
"medium",
|
|
123
|
+
"x * (1 - eps) + eps: every value moves, 0 becomes eps and 1 stays 1, so the entries of a one-hot sum to 1 + (C - 1) eps and a perfect prediction scores a loss that grows with the image",
|
|
124
|
+
"keep the values exact and add eps only to the log argument or the denominator that needs it, or default eps to 0",
|
|
125
|
+
"kornia #5538 (found by the kornia conventions audit), one_hot floored its zeros with 1e-6 and a perfect prediction scored a macro Dice loss of 0.0234 on a 256 x 384 image",
|
|
126
|
+
),
|
|
127
|
+
"where-nan-gradient": (
|
|
128
|
+
"medium",
|
|
129
|
+
"where(d > eps, f(d), other) with f a division by d or a sqrt, log, acos or asin of it: where evaluates both branches and hands the discarded one a zero gradient, and the backward of a division or a sqrt at zero turns that zero into 0 / 0 = nan, which reaches the inputs at exactly the points the guard was written for; the forward value is right, only the gradient is lost, so this matters in a loss or a layer and not in a metric that is never differentiated; numpy has no autograd and only warns",
|
|
130
|
+
"substitute inside the branch that gets differentiated: safe = where(d > eps, d, ones_like(d)), then divide by safe or take the sqrt or log of safe, so that the discarded branch is finite too",
|
|
131
|
+
"kornia #5579 (found by the kornia conventions audit), the mutual information losses divided by the range of the signal inside where(diff > eps, ...) and a constant input or target got a nan gradient",
|
|
132
|
+
),
|
|
133
|
+
"clamp-at-singularity": (
|
|
134
|
+
"medium",
|
|
135
|
+
"clamp(x, min=0).sqrt(), sqrt(clamp(x, min=0)), clamp(c, -1, 1).acos(): the clamp bound is the point where the derivative of the next function is infinite, and on torch 2.5.1 and 2.9.1 clamp hands the incoming gradient through at its own bound, so the gradient there is inf or nan (torch 2.14 returns 0 at the bound, which hides it on the newer half of a supported range); a clamp to a value strictly inside the domain, such as min=1e-8, is a floor and is not reported",
|
|
136
|
+
"take the value from the real argument and the gradient from a safe one, f(where(at_bound, safe, x)) with the value fixed up by where, or atan2 instead of acos for an angle",
|
|
137
|
+
"kornia #4229 (found by the kornia conventions audit), _cdist's clamp(min=0.0).sqrt() gave nan gradients for identical float16 descriptors on torch 2.5.1; kornia #5500, clamp(-1, 1).acos() in angle_error_mat had a -inf backward at exactly 0 and 180 degrees on torch 2.5.1",
|
|
119
138
|
),
|
|
120
139
|
}
|
|
121
140
|
SEVERITY_ORDER = {"high": 0, "medium": 1, "info": 2}
|
|
@@ -227,7 +246,281 @@ def _angle_names(body: ast.AST) -> set:
|
|
|
227
246
|
return out
|
|
228
247
|
|
|
229
248
|
|
|
249
|
+
MAXIMUM = {"max", "amax", "maximum", "fmax"}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_maximum(node: ast.AST) -> Optional[ast.AST]:
|
|
253
|
+
"""The value whose maximum is taken in ``x.max(-1)``, ``np.max(x, axis=-1, keepdims=True)``, ``torch.amax(x)``,
|
|
254
|
+
``torch.maximum(a, b)`` and the ``.values`` or ``[0]`` of a ``torch.max`` result, else None."""
|
|
255
|
+
node = _strip(node)
|
|
256
|
+
if isinstance(node, ast.Subscript):
|
|
257
|
+
node = node.value
|
|
258
|
+
if isinstance(node, ast.Attribute) and node.attr == "values":
|
|
259
|
+
node = node.value
|
|
260
|
+
name, arg = _callee(node)
|
|
261
|
+
return arg if name in MAXIMUM else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _maxima_and_shifted(fn: ast.AST) -> Tuple[set, set]:
|
|
265
|
+
"""Names bound in ``fn`` to a maximum, and names bound to a value with a maximum subtracted from it."""
|
|
266
|
+
maxima: set = set()
|
|
267
|
+
shifted: set = set()
|
|
268
|
+
assignments = [
|
|
269
|
+
(node.targets if isinstance(node, ast.Assign) else [node.target], node.value)
|
|
270
|
+
for node in ast.walk(fn)
|
|
271
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None
|
|
272
|
+
]
|
|
273
|
+
for targets, value in assignments:
|
|
274
|
+
for target in targets:
|
|
275
|
+
if isinstance(target, (ast.Tuple, ast.List)) and target.elts:
|
|
276
|
+
target = target.elts[0] # ``m, _ = x.max(-1)``
|
|
277
|
+
if isinstance(target, ast.Name) and _is_maximum(value) is not None:
|
|
278
|
+
maxima.add(target.id)
|
|
279
|
+
for targets, value in assignments:
|
|
280
|
+
for target in targets:
|
|
281
|
+
if isinstance(target, ast.Name) and _is_shifted(value, maxima, set()):
|
|
282
|
+
shifted.add(target.id)
|
|
283
|
+
return maxima, shifted
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_shifted(node: ast.AST, maxima: set, shifted: set) -> bool:
|
|
287
|
+
"""``x - x.max(...)``, ``x - m`` with ``m`` bound to a maximum, or a name bound to such a difference: the
|
|
288
|
+
argument of ``exp`` in the stable form of logsumexp, which cannot overflow."""
|
|
289
|
+
node = _strip(node)
|
|
290
|
+
if isinstance(node, ast.Name):
|
|
291
|
+
return node.id in shifted
|
|
292
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Sub):
|
|
293
|
+
right = _strip(node.right)
|
|
294
|
+
return _is_maximum(right) is not None or (isinstance(right, ast.Name) and right.id in maxima)
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _every_exp_shifted(node: ast.AST, maxima: set, shifted: set) -> bool:
|
|
299
|
+
"""Every ``exp`` inside ``node`` is applied to a value with its maximum subtracted."""
|
|
300
|
+
args = []
|
|
301
|
+
for sub in ast.walk(node):
|
|
302
|
+
name, arg = _callee(sub)
|
|
303
|
+
if name in EXP and arg is not None:
|
|
304
|
+
args.append(arg)
|
|
305
|
+
return bool(args) and all(_is_shifted(arg, maxima, shifted) for arg in args)
|
|
306
|
+
|
|
307
|
+
|
|
230
308
|
_GUARD = re.compile(r"\bwhere\b|\bclamp|\bclip\b|\beps\b|\bfinfo\b|\btaylor\b|\bseries\b|small.angle|\bmasked", re.I)
|
|
309
|
+
_EPS_NAME = re.compile(r"^_?(eps|epsilon|smoothing|label_smoothing)$", re.I)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _eps_key(node: ast.AST) -> Optional[str]:
|
|
313
|
+
"""``'eps'`` for a name or attribute that looks like a smoothing constant (``eps``, ``self.eps``), else None."""
|
|
314
|
+
node = _strip(node)
|
|
315
|
+
if isinstance(node, ast.Name) and _EPS_NAME.match(node.id):
|
|
316
|
+
return node.id
|
|
317
|
+
if isinstance(node, ast.Attribute) and _EPS_NAME.match(node.attr):
|
|
318
|
+
return ast.unparse(node)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_eps_floor(node: ast.AST) -> bool:
|
|
323
|
+
"""``x * (1 - eps) + eps`` in either order: the eps smoothing floor that moves every value."""
|
|
324
|
+
if not (isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add)):
|
|
325
|
+
return False
|
|
326
|
+
for eps, other in ((node.right, node.left), (node.left, node.right)):
|
|
327
|
+
key = _eps_key(eps)
|
|
328
|
+
other = _strip(other)
|
|
329
|
+
if key is None or not (isinstance(other, ast.BinOp) and isinstance(other.op, ast.Mult)):
|
|
330
|
+
continue
|
|
331
|
+
for factor in (other.left, other.right):
|
|
332
|
+
factor = _strip(factor)
|
|
333
|
+
if (
|
|
334
|
+
isinstance(factor, ast.BinOp)
|
|
335
|
+
and isinstance(factor.op, ast.Sub)
|
|
336
|
+
and _is_const(factor.left, 1)
|
|
337
|
+
and _eps_key(factor.right) == key
|
|
338
|
+
):
|
|
339
|
+
return True
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
SINGULAR_AT_ZERO = {"sqrt", "rsqrt", "log", "log2", "log10"}
|
|
344
|
+
SINGULAR_AT_ONE = {"acos", "arccos", "asin", "arcsin", "atanh", "arctanh"}
|
|
345
|
+
CLAMP = {"clamp", "clip", "clamp_min", "clamp_max"}
|
|
346
|
+
NUMPY_MODULES = {"np", "numpy"}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _bare(node: ast.AST) -> Optional[ast.AST]:
|
|
350
|
+
"""The quantity a comparison tests, with unary minus, ``abs``, a square and a subscript such as
|
|
351
|
+
``mask[..., None]`` stripped: ``theta.abs() < eps`` and ``theta_sq > 0`` test ``theta`` and ``theta_sq``."""
|
|
352
|
+
node = _strip(node)
|
|
353
|
+
while True:
|
|
354
|
+
name, arg = _callee(node)
|
|
355
|
+
if name in {"abs", "absolute"} and arg is not None:
|
|
356
|
+
node = _strip(arg)
|
|
357
|
+
continue
|
|
358
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Pow):
|
|
359
|
+
node = _strip(node.left)
|
|
360
|
+
continue
|
|
361
|
+
break
|
|
362
|
+
if isinstance(node, ast.Constant):
|
|
363
|
+
return None
|
|
364
|
+
identifier = node.id if isinstance(node, ast.Name) else node.attr if isinstance(node, ast.Attribute) else ""
|
|
365
|
+
if _THRESHOLD.search(identifier):
|
|
366
|
+
return None # var > floor, r > radicand_floor: the floor is the threshold, not the quantity
|
|
367
|
+
return node
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
_THRESHOLD = re.compile(r"eps|tol|floor|thresh|tiny|bound|limit", re.I)
|
|
371
|
+
SAFE_MAKERS = {"where", "clamp", "clip", "clamp_min", "maximum"}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _made_safe(fn: ast.AST) -> dict:
|
|
375
|
+
"""Names re-bound in ``fn`` to a ``where``, ``clamp`` or ``maximum`` of themselves, with the line of the
|
|
376
|
+
re-binding: after ``w_sum = where(has_weight, w_sum, ones_like(w_sum))`` a division by ``w_sum`` is safe."""
|
|
377
|
+
out: dict = {}
|
|
378
|
+
for node in ast.walk(fn):
|
|
379
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
380
|
+
target = node.targets[0].id
|
|
381
|
+
value = _strip(node.value)
|
|
382
|
+
name, _ = _callee(value)
|
|
383
|
+
if name in SAFE_MAKERS and any(isinstance(sub, ast.Name) and sub.id == target for sub in ast.walk(value)):
|
|
384
|
+
out[target] = min(node.lineno, out.get(target, node.lineno))
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _comparisons(fn: ast.AST) -> dict:
|
|
389
|
+
"""Names bound in ``fn`` to a comparison, with the quantities that comparison tests: ``small = theta.abs() < 1e-4``
|
|
390
|
+
gives ``{"small": [theta]}``."""
|
|
391
|
+
out: dict = {}
|
|
392
|
+
for node in ast.walk(fn):
|
|
393
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None:
|
|
394
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
395
|
+
tested = _tested(node.value, {})
|
|
396
|
+
if tested:
|
|
397
|
+
for target in targets:
|
|
398
|
+
if isinstance(target, ast.Name):
|
|
399
|
+
out[target.id] = tested
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _tested(cond: ast.AST, comparisons: dict) -> List[ast.AST]:
|
|
404
|
+
"""The quantities compared in ``cond``: the two sides of every comparison in it, through ``~``, ``&``, ``|`` and a
|
|
405
|
+
subscript, and the comparisons behind the names bound in ``comparisons``."""
|
|
406
|
+
out: List[ast.AST] = []
|
|
407
|
+
for sub in ast.walk(cond):
|
|
408
|
+
if isinstance(sub, ast.Compare):
|
|
409
|
+
sides = [sub.left] + sub.comparators
|
|
410
|
+
if any(abs(_number(side) or 0) > 1 for side in sides):
|
|
411
|
+
continue # strength < 50 is a piecewise definition, not a guard at a singularity
|
|
412
|
+
for side in sides:
|
|
413
|
+
quantity = _bare(side)
|
|
414
|
+
if quantity is not None:
|
|
415
|
+
out.append(quantity)
|
|
416
|
+
elif isinstance(sub, ast.Name) and sub.id in comparisons:
|
|
417
|
+
out.extend(comparisons[sub.id])
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _singular_use(branch: ast.AST, tested: Sequence[ast.AST]) -> bool:
|
|
422
|
+
"""``branch`` divides by one of ``tested``, raises it to a negative or fractional power, or takes the sqrt, log,
|
|
423
|
+
acos or asin of it, so that its derivative is infinite where the comparison fails."""
|
|
424
|
+
for sub in ast.walk(branch):
|
|
425
|
+
if isinstance(sub, ast.BinOp) and isinstance(sub.op, ast.Div):
|
|
426
|
+
den = _strip(sub.right)
|
|
427
|
+
if isinstance(den, ast.BinOp) and isinstance(den.op, ast.Pow):
|
|
428
|
+
den = _strip(den.left)
|
|
429
|
+
if any(_same(den, t) for t in tested):
|
|
430
|
+
return True
|
|
431
|
+
if isinstance(sub, ast.BinOp) and isinstance(sub.op, ast.Pow):
|
|
432
|
+
exponent = sub.right
|
|
433
|
+
negative = isinstance(exponent, ast.UnaryOp) and isinstance(exponent.op, ast.USub)
|
|
434
|
+
exponent = _strip(exponent)
|
|
435
|
+
if isinstance(exponent, ast.Constant) and isinstance(exponent.value, (int, float)):
|
|
436
|
+
if negative or exponent.value != int(exponent.value):
|
|
437
|
+
if any(_same(_strip(sub.left), t) for t in tested):
|
|
438
|
+
return True
|
|
439
|
+
name, arg = _callee(sub)
|
|
440
|
+
if name in SINGULAR_AT_ZERO | SINGULAR_AT_ONE and arg is not None:
|
|
441
|
+
if any(_same(_strip(arg), t) for t in tested):
|
|
442
|
+
return True
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _where_parts(node: ast.AST) -> Optional[Tuple[ast.AST, List[ast.AST]]]:
|
|
447
|
+
"""(condition, branches) of ``where(cond, a, b)``, ``torch.where(cond, a, b)`` and the method form
|
|
448
|
+
``a.where(cond, b)``; None for anything else, including ``numpy.where``, which has no autograd."""
|
|
449
|
+
name, _ = _callee(node)
|
|
450
|
+
if name != "where" or not isinstance(node, ast.Call):
|
|
451
|
+
return None
|
|
452
|
+
fn = node.func
|
|
453
|
+
if isinstance(fn, ast.Attribute):
|
|
454
|
+
module = fn.value
|
|
455
|
+
if isinstance(module, ast.Name) and module.id in NUMPY_MODULES:
|
|
456
|
+
return None
|
|
457
|
+
if len(node.args) == 2: # a.where(cond, b)
|
|
458
|
+
return node.args[0], [module, node.args[1]]
|
|
459
|
+
if len(node.args) >= 3:
|
|
460
|
+
return node.args[0], [node.args[1], node.args[2]]
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _number(node: ast.AST) -> Optional[float]:
|
|
465
|
+
negative = isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub)
|
|
466
|
+
node = _strip(node)
|
|
467
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
468
|
+
return -node.value if negative else node.value
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
MODULES = {"torch", "np", "numpy", "jnp", "jax", "tf", "F"}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _clamp_bounds(node: ast.AST) -> Optional[Tuple[Optional[float], Optional[float]]]:
|
|
476
|
+
"""(lower, upper) of a ``clamp`` / ``clip`` call whose bounds are literal numbers; a bound given by a name is None,
|
|
477
|
+
and the result is None when ``node`` is not a clamp."""
|
|
478
|
+
if not isinstance(node, ast.Call):
|
|
479
|
+
return None
|
|
480
|
+
name, _ = _callee(node)
|
|
481
|
+
if name not in CLAMP:
|
|
482
|
+
return None
|
|
483
|
+
positional = list(node.args)
|
|
484
|
+
fn = node.func
|
|
485
|
+
module_form = isinstance(fn, ast.Name) or (
|
|
486
|
+
isinstance(fn, ast.Attribute)
|
|
487
|
+
and (isinstance(fn.value, ast.Attribute) or (isinstance(fn.value, ast.Name) and fn.value.id in MODULES))
|
|
488
|
+
)
|
|
489
|
+
if module_form and positional:
|
|
490
|
+
positional = positional[1:] # clamp(x, lo, hi), torch.clamp(x, lo, hi): the first argument is the value
|
|
491
|
+
lo = hi = None
|
|
492
|
+
if name == "clamp_max":
|
|
493
|
+
hi = _number(positional[0]) if positional else None
|
|
494
|
+
else:
|
|
495
|
+
lo = _number(positional[0]) if positional else None
|
|
496
|
+
hi = _number(positional[1]) if len(positional) > 1 else None
|
|
497
|
+
for kw in node.keywords:
|
|
498
|
+
if kw.arg in {"min", "a_min"}:
|
|
499
|
+
lo = _number(kw.value)
|
|
500
|
+
elif kw.arg in {"max", "a_max"}:
|
|
501
|
+
hi = _number(kw.value)
|
|
502
|
+
return lo, hi
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _numpy_call(node: ast.AST) -> bool:
|
|
506
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
507
|
+
return False
|
|
508
|
+
return isinstance(node.func.value, ast.Name) and node.func.value.id in NUMPY_MODULES
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _clamp_at_singularity(name: str, node: ast.AST, arg: ast.AST) -> bool:
|
|
512
|
+
"""``f(clamp(x, ...))`` where a clamp bound is the point at which ``f`` has an infinite derivative; quiet for
|
|
513
|
+
numpy, which has no autograd."""
|
|
514
|
+
arg = _strip(arg)
|
|
515
|
+
bounds = _clamp_bounds(arg)
|
|
516
|
+
if bounds is None or _numpy_call(node) or _numpy_call(arg):
|
|
517
|
+
return False
|
|
518
|
+
lo, hi = bounds
|
|
519
|
+
if name in SINGULAR_AT_ZERO:
|
|
520
|
+
return lo == 0
|
|
521
|
+
if name in SINGULAR_AT_ONE:
|
|
522
|
+
return lo == -1 or hi == 1
|
|
523
|
+
return False
|
|
231
524
|
|
|
232
525
|
|
|
233
526
|
def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines: Sequence[str]) -> Tuple[List[Finding], HotSpot]:
|
|
@@ -236,6 +529,9 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
236
529
|
divisions = 0
|
|
237
530
|
names: List[str] = []
|
|
238
531
|
angles = _angle_names(fn)
|
|
532
|
+
maxima, shifted = _maxima_and_shifted(fn)
|
|
533
|
+
comparisons = _comparisons(fn)
|
|
534
|
+
made_safe = _made_safe(fn)
|
|
239
535
|
fn_source = "\n".join(source_lines[fn.lineno - 1 : getattr(fn, "end_lineno", fn.lineno)])
|
|
240
536
|
guarded = bool(_GUARD.search(fn_source))
|
|
241
537
|
|
|
@@ -243,7 +539,7 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
243
539
|
line = getattr(node, "lineno", fn.lineno)
|
|
244
540
|
findings.append(Finding(rule, path, line, qualname, source_lines[line - 1].strip()[:160]))
|
|
245
541
|
|
|
246
|
-
for node in
|
|
542
|
+
for node in _own_nodes(fn):
|
|
247
543
|
name, arg = _callee(node)
|
|
248
544
|
if name in ELEMENTARY:
|
|
249
545
|
calls += 1
|
|
@@ -256,6 +552,16 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
256
552
|
den_name = den.id if isinstance(den, ast.Name) else None
|
|
257
553
|
if not guarded and angles and (den_name in angles or _contains_call(den, SIN)):
|
|
258
554
|
add("small-angle-division", node)
|
|
555
|
+
if _is_eps_floor(node):
|
|
556
|
+
add("eps-floor", node)
|
|
557
|
+
parts = _where_parts(node)
|
|
558
|
+
if parts is not None:
|
|
559
|
+
tested = [
|
|
560
|
+
t for t in _tested(parts[0], comparisons)
|
|
561
|
+
if not (isinstance(t, ast.Name) and made_safe.get(t.id, node.lineno) < node.lineno)
|
|
562
|
+
]
|
|
563
|
+
if tested and any(_singular_use(branch, tested) for branch in parts[1]):
|
|
564
|
+
add("where-nan-gradient", node)
|
|
259
565
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Sub):
|
|
260
566
|
left, right = _strip(node.left), _strip(node.right)
|
|
261
567
|
rn, _ = _callee(right)
|
|
@@ -267,6 +573,8 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
267
573
|
if name is None or arg is None:
|
|
268
574
|
continue
|
|
269
575
|
inner = _strip(arg)
|
|
576
|
+
if name in SINGULAR_AT_ZERO | SINGULAR_AT_ONE and _clamp_at_singularity(name, node, inner):
|
|
577
|
+
add("clamp-at-singularity", node)
|
|
270
578
|
if name in EXP and _is_square(inner):
|
|
271
579
|
add("exp-of-square", node)
|
|
272
580
|
elif name in SIN:
|
|
@@ -280,13 +588,15 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
280
588
|
if (_is_const(a, 1) and bn in EXP) or (_is_const(b, 1) and an in EXP):
|
|
281
589
|
add("softplus-by-hand", node)
|
|
282
590
|
elif an in EXP and bn in EXP:
|
|
283
|
-
|
|
591
|
+
if not _every_exp_shifted(inner, maxima, shifted):
|
|
592
|
+
add("logsumexp-by-hand", node)
|
|
284
593
|
elif _is_const(a, 1) or _is_const(b, 1):
|
|
285
594
|
add("log1p-by-hand", node)
|
|
286
595
|
else:
|
|
287
596
|
sn, sarg = _callee(inner)
|
|
288
597
|
if sn == "sum" and sarg is not None and _contains_call(sarg, EXP):
|
|
289
|
-
|
|
598
|
+
if not _every_exp_shifted(sarg, maxima, shifted):
|
|
599
|
+
add("logsumexp-by-hand", node)
|
|
290
600
|
elif name in SQRT:
|
|
291
601
|
if isinstance(inner, ast.BinOp) and isinstance(inner.op, ast.Add) and _is_square(inner.left) and _is_square(inner.right):
|
|
292
602
|
add("hypot-by-hand", node)
|
|
@@ -302,6 +612,17 @@ def _findings_for_function(fn: ast.AST, qualname: str, path: str, source_lines:
|
|
|
302
612
|
return findings, HotSpot(path, fn.lineno, qualname, calls, divisions, sorted(set(names)))
|
|
303
613
|
|
|
304
614
|
|
|
615
|
+
def _own_nodes(fn: ast.AST) -> Iterable[ast.AST]:
|
|
616
|
+
"""The nodes of ``fn`` without those of a nested function or class, which ``_functions`` lists on their own."""
|
|
617
|
+
stack = [fn]
|
|
618
|
+
while stack:
|
|
619
|
+
node = stack.pop()
|
|
620
|
+
yield node
|
|
621
|
+
for child in ast.iter_child_nodes(node):
|
|
622
|
+
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
623
|
+
stack.append(child)
|
|
624
|
+
|
|
625
|
+
|
|
305
626
|
def _functions(tree: ast.Module) -> Iterable[Tuple[ast.AST, str]]:
|
|
306
627
|
def walk(node: ast.AST, prefix: str) -> Iterable[Tuple[ast.AST, str]]:
|
|
307
628
|
for child in ast.iter_child_nodes(node):
|
|
@@ -12,6 +12,7 @@ from ulpwise import scan
|
|
|
12
12
|
SNIPPET = textwrap.dedent(
|
|
13
13
|
'''
|
|
14
14
|
import math
|
|
15
|
+
import numpy as np
|
|
15
16
|
import torch
|
|
16
17
|
|
|
17
18
|
|
|
@@ -41,6 +42,21 @@ SNIPPET = textwrap.dedent(
|
|
|
41
42
|
return be.log(be.exp(x).sum((0, 1)))
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def lse_shifted(x):
|
|
46
|
+
m = x.max(-1, keepdim=True).values
|
|
47
|
+
s = x - m
|
|
48
|
+
return torch.log(torch.exp(s).sum(-1)) + m.squeeze(-1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def lse_shifted_inline(a, b):
|
|
52
|
+
m = torch.maximum(a, b)
|
|
53
|
+
return torch.log(torch.exp(a - m) + torch.exp(b - m)) + m
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def lse_half_shifted(a, b):
|
|
57
|
+
return torch.log(torch.exp(a - a.max()) + torch.exp(b))
|
|
58
|
+
|
|
59
|
+
|
|
44
60
|
def norm2(a, b):
|
|
45
61
|
return torch.sqrt(a ** 2 + b ** 2)
|
|
46
62
|
|
|
@@ -54,11 +70,93 @@ SNIPPET = textwrap.dedent(
|
|
|
54
70
|
|
|
55
71
|
|
|
56
72
|
def so3_jacobian_guarded(theta, w):
|
|
73
|
+
# quiet for small-angle-division, but the discarded branch still differentiates sin(theta) / theta at 0
|
|
57
74
|
small = theta.abs() < 1e-4
|
|
58
75
|
s = torch.where(small, 1 - theta ** 2 / 6, torch.sin(theta) / theta)
|
|
59
76
|
return w / s
|
|
60
77
|
|
|
61
78
|
|
|
79
|
+
def so3_jacobian_safe(theta, w):
|
|
80
|
+
small = theta.abs() < 1e-4
|
|
81
|
+
safe_theta = torch.where(small, torch.ones_like(theta), theta)
|
|
82
|
+
s = torch.where(small, 1 - theta ** 2 / 6, torch.sin(safe_theta) / safe_theta)
|
|
83
|
+
return w / s
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_signal(data, eps):
|
|
87
|
+
min_val, _ = data.min(dim=-1)
|
|
88
|
+
diff = (data.max(dim=-1)[0] - min_val).unsqueeze(-1)
|
|
89
|
+
return torch.where(diff > eps, (data - min_val.unsqueeze(-1)) / diff, 0.0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sqrt_where(theta_sq):
|
|
93
|
+
nonzero = theta_sq > 0
|
|
94
|
+
return torch.where(nonzero[..., None], theta_sq.sqrt(), 0.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def where_unrelated(x, y):
|
|
98
|
+
return torch.where(x > 0, x / y, 0.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def where_numpy(x, d):
|
|
102
|
+
return np.where(d > 0, x / d, 0.0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cdist_tail(dm):
|
|
106
|
+
return dm.clamp(min=0.0).sqrt()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def angle_from_trace(w):
|
|
110
|
+
return torch.acos(torch.clamp(w, -1.0, 1.0))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def floored_sqrt(x):
|
|
114
|
+
return torch.sqrt(x.clamp(min=1e-8))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def np_arc(dot):
|
|
118
|
+
return np.arccos(np.clip(dot, -1, 1))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def piecewise_scale(strength):
|
|
122
|
+
return torch.where(strength < 50, 5000.0 / strength, 200.0 - 2.0 * strength)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def floored_std(var):
|
|
126
|
+
floor = torch.full_like(var, 1e-10)
|
|
127
|
+
above = var > floor
|
|
128
|
+
safe_var = torch.where(above, var, torch.ones_like(var))
|
|
129
|
+
return torch.where(above, safe_var.sqrt(), floor.sqrt())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def rebound_mean(w, x):
|
|
133
|
+
w_sum = w.sum(-1)
|
|
134
|
+
has_weight = w_sum != 0
|
|
135
|
+
w_sum = torch.where(has_weight, w_sum, torch.ones_like(w_sum))
|
|
136
|
+
return torch.where(has_weight, (w * x).sum(-1) / w_sum, x.mean(-1))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def outer(x):
|
|
140
|
+
def inner(y):
|
|
141
|
+
return torch.exp(y * y)
|
|
142
|
+
|
|
143
|
+
return inner(x)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def one_hot_floor(labels, eps):
|
|
147
|
+
t = torch.nn.functional.one_hot(labels, 3).float()
|
|
148
|
+
return t * (1.0 - eps) + eps
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Smoother:
|
|
152
|
+
def forward(self, t):
|
|
153
|
+
return self.eps + (1 - self.eps) * t
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def eps_guard(p, q, eps):
|
|
157
|
+
return (p + eps) / (q + eps) * (1 - eps)
|
|
158
|
+
|
|
159
|
+
|
|
62
160
|
def misc(x, y):
|
|
63
161
|
a = torch.log(1 + x)
|
|
64
162
|
b = torch.exp(x) - 1
|
|
@@ -73,15 +171,26 @@ SNIPPET = textwrap.dedent(
|
|
|
73
171
|
EXPECTED = {
|
|
74
172
|
("exp-of-square", "erfcx_neg"),
|
|
75
173
|
("exp-of-square", "misc"),
|
|
174
|
+
("exp-of-square", "outer.inner"),
|
|
76
175
|
("sin-of-pi-times", "trigamma_reflect"),
|
|
77
176
|
("softplus-by-hand", "softplus"),
|
|
78
177
|
("logsumexp-by-hand", "lse"),
|
|
79
178
|
("logsumexp-by-hand", "lse_dim"),
|
|
80
179
|
("logsumexp-by-hand", "lse_backend"),
|
|
180
|
+
("logsumexp-by-hand", "lse_half_shifted"),
|
|
81
181
|
("hypot-by-hand", "norm2"),
|
|
82
182
|
("acos-for-angle", "angle_between"),
|
|
183
|
+
("eps-floor", "one_hot_floor"),
|
|
184
|
+
("eps-floor", "Smoother.forward"),
|
|
83
185
|
("one-minus-cos", "so3_jacobian"),
|
|
84
186
|
("small-angle-division", "so3_jacobian"),
|
|
187
|
+
("where-nan-gradient", "so3_jacobian_guarded"),
|
|
188
|
+
("where-nan-gradient", "normalize_signal"),
|
|
189
|
+
("where-nan-gradient", "sqrt_where"),
|
|
190
|
+
("clamp-at-singularity", "cdist_tail"),
|
|
191
|
+
("clamp-at-singularity", "angle_from_trace"),
|
|
192
|
+
("acos-for-angle", "angle_from_trace"),
|
|
193
|
+
("acos-for-angle", "np_arc"),
|
|
85
194
|
("log1p-by-hand", "misc"),
|
|
86
195
|
("expm1-by-hand", "misc"),
|
|
87
196
|
("atan-of-quotient", "misc"),
|
|
@@ -93,8 +202,14 @@ def test_every_rule_fires_once_on_its_snippet_and_the_guard_silences_it():
|
|
|
93
202
|
findings, spots = scan.scan_source(SNIPPET, "demo.py")
|
|
94
203
|
assert {(f.rule, f.function) for f in findings} == EXPECTED
|
|
95
204
|
assert {rule for rule, _ in EXPECTED} == set(scan.RULES) # every rule has a snippet
|
|
96
|
-
assert sum(f.rule == "exp-of-square" for f in findings) ==
|
|
97
|
-
|
|
205
|
+
assert sum(f.rule == "exp-of-square" for f in findings) == 4 # x * x, x.pow(2), square(x), once in outer.inner
|
|
206
|
+
quiet = {
|
|
207
|
+
"so3_jacobian_safe", "where_unrelated", "where_numpy", "floored_sqrt", "piecewise_scale", "floored_std",
|
|
208
|
+
"rebound_mean", "outer", "lse_shifted", "lse_shifted_inline", "eps_guard",
|
|
209
|
+
}
|
|
210
|
+
assert not [f for f in findings if f.function in quiet]
|
|
211
|
+
assert [f.rule for f in findings if f.function == "so3_jacobian_guarded"] == ["where-nan-gradient"]
|
|
212
|
+
assert [f.rule for f in findings if f.function == "np_arc"] == ["acos-for-angle"] # numpy has no autograd
|
|
98
213
|
assert all(f.snippet and f.line > 0 and f.path == "demo.py" for f in findings)
|
|
99
214
|
busiest = max(spots, key=lambda s: s.calls)
|
|
100
215
|
assert busiest.function == "misc" and busiest.calls == 7 and "exp" in busiest.names
|
|
@@ -125,7 +240,7 @@ def test_scan_tree_skips_tests_and_reports(tmp_path):
|
|
|
125
240
|
with pytest.raises(ValueError):
|
|
126
241
|
scan.checkout("not a repository at all")
|
|
127
242
|
text = scan.render(findings, spots, "pkg", None, nfiles)
|
|
128
|
-
assert text.startswith("ulpwise scan of pkg: 2 files") and "exp-of-square [high]
|
|
243
|
+
assert text.startswith("ulpwise scan of pkg: 2 files") and "exp-of-square [high] x4" in text
|
|
129
244
|
md = scan.render(findings, spots, "pkg", "abc123", nfiles, top=3, markdown=True)
|
|
130
245
|
assert md.startswith("# ulpwise scan of pkg @ abc123") and "| calls |" in md and md.count("\n| ") == 4
|
|
131
246
|
|
|
@@ -139,7 +254,7 @@ def test_scan_cli(tmp_path, capsys):
|
|
|
139
254
|
assert "findings" in capsys.readouterr().out and report.read_text().startswith("# ulpwise scan of")
|
|
140
255
|
assert main(["scan", str(tmp_path), "--rules", "acos-for-angle", "--fail-on", "high"]) == 0
|
|
141
256
|
out = capsys.readouterr().out
|
|
142
|
-
assert "acos-for-angle [
|
|
257
|
+
assert "acos-for-angle [medium] x3" in out and "exp-of-square" not in out
|
|
143
258
|
assert main(["scan", str(tmp_path), "--rules", "no-such-rule"]) == 2
|
|
144
259
|
assert main(["scan", str(tmp_path / "missing")]) == 2
|
|
145
260
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|