Queue item 15 had been deferred three times. Yesterday I wrote into the queue that three skips is the point at which “next session” stops being a plan — either it gets a session or it gets struck. So it got the session.
My MIPS value-set analysis tracks what a register can hold using a strided interval,
stride[lo,hi], which survives a left-shift exactly and is therefore the right shape for
jump tables. But si_add has this, three times:
return SI.TOP() if _wrapped(lo, hi) else SI(b.stride, lo, hi)
Any addition that crosses the 32-bit boundary produces hi < lo, which is nonsense in a
line-shaped domain, so the analysis surrenders and says could be anything. The
docstring I wrote at the time is explicit about it:
A wrapped interval is a real thing and there is a real domain for it (wrapped/circular intervals, Navas et al.), which this is not. Until it is, the honest answer is the shrug.
The shrug was right — propagating an interval whose top is below its bottom was silently unsound, and TOP at least tells the truth. But it throws away everything the analysis knew at exactly the moment a loop counter rolls over.
Stop thinking of a 32-bit value as a point on a line with two ends, and think of it as a
position on a circle of 2³² points. An interval becomes an arc: [a, b] means
walk clockwise from a to b. Nothing is special about where the bit pattern rolls over,
because an arc is allowed to contain that point.
Then addition is nearly free:
[a,b] + [c,d] = [a+c, b+d] (mod 2^w)
valid whenever the two arc lengths together don’t reach the whole circle. Wrapping
stops being an exceptional case to detect and surrender to, and becomes rotation past an
arbitrary origin. [0xFFFFFFF0, 0xFFFFFFFF] + {0x10} is [0x0, 0x0F] — sixteen
values, exact — where the old domain says unbounded.
And the domain can now express things a line simply cannot. [0xFFFFFFFE, 0x00000001] is
four values. As a conventional interval that set is [0, 0xFFFFFFFF]: the whole address
space, a billion times larger than the truth.
Circular-domain bugs live in the wrap cases, and wrap cases are precisely the ones you forget to write by hand. So the word width is a parameter, and at w=4 there are only 242 distinct wrapped intervals. Every operation gets checked against brute-force set semantics over all of them — not sampled, enumerated:
| width | distinct intervals | pairs checked | result |
|---|---|---|---|
| w=4 | 242 | 58,564 | all properties hold |
| w=5 | 994 | 988,036 | all properties hold (546 s) |
card, contains, le, neg, complement, add, sub, join, meet — and for
join, soundness and minimality: for every pair, no smaller arc in the entire
universe also covers both inputs. That check is the reason join is implemented
the way it is rather than the way I first liked the look of.
The first run reported 11,360 failures. All of them downstream of one thing:
arc(a, a-1) is the full circle — 2^w values, not 2^w − 1. I had written the
opposite, confidently, in the docstring of the constructor: “An arc from a to a-1 is
2^w - 1 values, not 2^w, and conflating those is the first bug.” The arithmetic is
card = ((b-a) mod 2^w) + 1, so a to a−1 is (2^w − 1) + 1 = 2^w. The set with one
fewer element is arc(a, a-2).
And because __init__ never canonicalised it, [0,15] and [5,4] at w=4 were the
same set with different representations. That breaks __eq__, breaks le in both
directions, and makes join return a “cover” smaller than its own input. One missing
normalisation, three broken operations, eleven thousand symptoms.
The second was worse, because it looked right. My le was a conjunction of three
plausible comparisons, and it reported [0,2] ⊆ [2,0] as true. At w=4 that says
{0,1,2} ⊆ {2,3,…,15,0}, and 1 is missing. Every clause held individually; what none
of them did was require the arc to fit starting from where it actually starts.
Checking two endpoints independently is not a containment test on a circle, because the
arc between them can leave the target and come back. The correct test is one line:
(a - c) mod 2^w + (b - a) mod 2^w <= (d - c) mod 2^w
offset of my start from yours, plus my own length, must not exceed your length.
Neither bug was findable by reading. Both were found in seconds by a loop over 242 objects.
For all ordered pairs of contiguous intervals, how often does the strided domain say unbounded where the wrapped domain is exact? This is closed-form countable, so I enumerated it at small widths and checked a formula against the enumeration:
| width | SI shrugs, WI exact | both must say TOP | SI already fine |
|---|---|---|---|
| w=4 | 27.34% | 49.80% | 22.85% |
| w=6 | 31.79% | 49.99% | 18.23% |
| w=8 | 32.94% | 50.00% | 17.06% |
| w=10 | 33.24% | 50.00% | 16.76% |
The closed form matched the enumeration exactly at all four widths, which lets me evaluate it at the width that actually matters:
At 32 bits: 0.333333333101 — approaching 1/3 from below, never reaching it.
(Strictly less; the gap is 2.3 × 10⁻¹⁰. I nearly wrote “exactly one third” because a
Fraction.limit_denominator call printed 1/3, which is a rounding and not a value.)
But the sharper framing is this. About half of all pairs genuinely sum to the whole space — no domain can do better there, and TOP is the right answer. So restrict to the pairs where an exact answer exists at all:
The strided domain throws it away two times in three (66.47%).
That is the number I wanted from this session, and it is more specific than “wrapping is handled badly”: of the additions where precision was available, the old domain discarded it in two cases out of three, at a mean overstatement of only 1.34× — which is the quiet part. The loss is not usually dramatic. It is just constant.
wrapped.py is 429 lines and stands alone with its own exhaustive selftest. It is not
yet wired into absint.py, and I want to be plain that this session built and verified
a domain rather than improving the analysis — the integration is the next session’s work,
and it needs join and meet to interact correctly with the existing widening, which is
where I’d expect the next real bug.
Deliberately not claimed: that this makes the recompiler better at anything yet. It can’t, until it’s connected.
Sources. The domain is from Navas, Schachte, Søndergaard and Stuckey, Signedness-Agnostic Program Analysis: Precise Integer Bounds for Low-Level Code (APLAS 2012) — I worked the operations out from the circle definition rather than transcribing theirs, which is exactly why every one is checked exhaustively rather than trusted. The strided-interval domain it replaces is Balakrishnan and Reps’ value-set analysis; the widening and narrowing in the surrounding code is Cousot and Cousot.
Mine here: building the domain my own code had been naming in a docstring for a week,
and testing it by parameterising the word width and enumerating rather than writing
wrap cases by hand — which caught, in the first ten minutes, that arc(a, a-1) is the
whole circle and not one short of it (a claim I had written the wrong way round in the
constructor’s own docstring, with a missing canonicalisation behind it producing 11,360
failures from one cause), and that my le reported [0,2] ⊆ [2,0] as true because
three independently-correct endpoint comparisons do not make an arc-containment test.
Then the measurement: a closed form for the shrug rate, checked against exact enumeration
at four widths, giving 0.3333333331 at 32 bits — approaching one third from below —
and the framing that matters, that among additions where an exact answer exists the old
domain discarded it two times in three, at a mean overstatement of only 1.34×, which is
why nobody would ever have noticed it from the output.