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.
Files changed (38) hide show
  1. {ulpwise-0.3.2 → ulpwise-0.3.3}/CHANGELOG.md +55 -0
  2. {ulpwise-0.3.2 → ulpwise-0.3.3}/Cargo.lock +1 -1
  3. {ulpwise-0.3.2 → ulpwise-0.3.3}/Cargo.toml +1 -1
  4. {ulpwise-0.3.2 → ulpwise-0.3.3}/PKG-INFO +20 -6
  5. {ulpwise-0.3.2 → ulpwise-0.3.3}/README.md +19 -5
  6. {ulpwise-0.3.2 → ulpwise-0.3.3}/pyproject.toml +1 -1
  7. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/__init__.py +1 -1
  8. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/corpus/cases.json +64 -0
  9. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/scan.py +330 -9
  10. {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_scan.py +119 -4
  11. {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/dependabot.yml +0 -0
  12. {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/workflows/ci.yml +0 -0
  13. {ulpwise-0.3.2 → ulpwise-0.3.3}/.github/workflows/release.yml +0 -0
  14. {ulpwise-0.3.2 → ulpwise-0.3.3}/.gitignore +0 -0
  15. {ulpwise-0.3.2 → ulpwise-0.3.3}/CONTRIBUTING.md +0 -0
  16. {ulpwise-0.3.2 → ulpwise-0.3.3}/LICENSE-APACHE +0 -0
  17. {ulpwise-0.3.2 → ulpwise-0.3.3}/LICENSE-MIT +0 -0
  18. {ulpwise-0.3.2 → ulpwise-0.3.3}/examples/sqrt_knife_values.py +0 -0
  19. {ulpwise-0.3.2 → ulpwise-0.3.3}/examples/torch_sqrt_conformance.py +0 -0
  20. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/__main__.py +0 -0
  21. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/corpus/__init__.py +0 -0
  22. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/plugin.py +0 -0
  23. {ulpwise-0.3.2 → ulpwise-0.3.3}/python/ulpwise/survey.py +0 -0
  24. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/edge.rs +0 -0
  25. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/exact.rs +0 -0
  26. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/knife.rs +0 -0
  27. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/lib.rs +0 -0
  28. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/py.rs +0 -0
  29. {ulpwise-0.3.2 → ulpwise-0.3.3}/src/ulp.rs +0 -0
  30. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/README.md +0 -0
  31. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.csv +0 -0
  32. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-09/results.md +0 -0
  33. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/README.md +0 -0
  34. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/results.csv +0 -0
  35. {ulpwise-0.3.2 → ulpwise-0.3.3}/studies/accuracy-survey-2026-10-half/results.md +0 -0
  36. {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_core.py +0 -0
  37. {ulpwise-0.3.2 → ulpwise-0.3.3}/tests/test_corpus.py +0 -0
  38. {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
@@ -120,7 +120,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
120
120
 
121
121
  [[package]]
122
122
  name = "ulpwise"
123
- version = "0.3.2"
123
+ version = "0.3.3"
124
124
  dependencies = [
125
125
  "pyo3",
126
126
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ulpwise"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  edition = "2021"
5
5
  rust-version = "1.86"
6
6
  license = "MIT OR Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ulpwise
3
- Version: 0.3.2
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 twelve patterns, each with a severity, the reason it loses digits or
250
- overflows, the usual replacement and, where one exists, the upstream bug it comes from.
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` | info | `acos`, `asin` used to recover an 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 twelve patterns, each with a severity, the reason it loses digits or
221
- overflows, the usual replacement and, where one exists, the upstream bug it comes from.
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` | info | `acos`, `asin` used to recover an 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.2"
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" }
@@ -30,7 +30,7 @@ from ._core import special as _special_core
30
30
  from ._core import ulp_distance as _ulp_distance_core
31
31
  from ._core import ulp_distances as _ulp_distances_core
32
32
 
33
- __version__ = "0.3.2"
33
+ __version__ = "0.3.3"
34
34
 
35
35
  __all__ = [
36
36
  "UNARY_OPS",
@@ -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
- "info",
116
- "acos or asin to recover an angle: the derivative is infinite at the ends, so the angle near 0 or pi loses half its digits",
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 ast.walk(fn):
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
- add("logsumexp-by-hand", node)
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
- add("logsumexp-by-hand", node)
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) == 3 # x * x, x.pow(2), square(x)
97
- assert not [f for f in findings if f.function == "so3_jacobian_guarded"]
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] x3" in text
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 [info] x1" in out and "exp-of-square" not in out
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