Files

150 lines
7.4 KiB
Markdown

# intervalkit — canonical half-open interval-set algebra
Stdlib only, no dependencies. Two files: the library and its harness.
intervalkit.py the library + a small CLI
test_intervalkit.py the verification harness (run it: exit 0 = correct)
## The model
A *set* is a finite sequence of half-open intervals `[lo, hi)`. `lo` and `hi`
are numbers or `None`; `None` on the left is `-inf`, on the right is `+inf`.
The empty set is the empty sequence.
A sequence is **canonical** iff it is sorted by `lo`, every interval is
non-degenerate (`lo < hi`), and consecutive intervals are strictly separated
(`next.lo > prev.hi`). Every set has exactly one canonical form.
Touching intervals merge (`[1,2) U [2,3) == [1,3)`); a one-unit gap does not
(`[1,2) U [3,4)` stays two intervals).
## API
normalize(a) unique canonical form; idempotent, order-blind
union(*sets) canonical union
intersection(*sets) canonical intersection
difference(a, b) a minus b
symmetric_difference(a, b) points in exactly one
complement(a) over the whole line (-inf, +inf); an involution
contains(a, x) half-open membership
measure(a) total length; inf when unbounded
from_points(points) integer runs -> canonical set
sample_points(a, lo, hi) integers in the window that are in the set
is_canonical(a) structural predicate
assert_canonical(a) raises AssertionError with detail
parse(text) / format_set(a) "[1,3) U [5,+inf)" <-> canonical set
main(argv) CLI entry point
CLI:
$ python3 intervalkit.py union "[1,3)" "[2,5)"
[1,5)
$ python3 intervalkit.py complement "[1,3)"
[-inf,1) U [3,+inf)
$ python3 intervalkit.py contains "[1,3)" 3
false
$ python3 intervalkit.py measure "[-inf,1)"
inf
## Guarantees (and what is deliberately excluded)
* `normalize` is idempotent and order-independent; it is the unique normal form.
* `union`, `intersection`, `difference`, `symmetric_difference` and `complement`
are total, closed, and always return canonical sets.
* No operation mutates its input, and none invents an endpoint: every result is
built from max/min combinations of input endpoints. Integer endpoints stay
integer, and `Fraction` endpoints stay exact (`measure` of `[1/3, 2/3)` is
exactly `1/3`).
* **Degenerate intervals (single points) are not representable.** `[x, x)` is
rejected. That is the price of a half-open model, and it is what makes every
operation above total and closed. `{0, 2}` is therefore not expressible;
`{0, 1, 2}` is, as `[0, 3)`. Malformed input raises `IntervalError` — it is
never silently coerced.
## Verification
python3 test_intervalkit.py # exit code IS the verdict: 0 = correct
Last run: **12/12 checks passed, 641,123 comparisons executed, ~13 s.**
### Why the differential test is exact, not sampled
Membership over the integers is decided by `in_set` — an independent five-line
predicate written in the harness, not imported from the library. Operations are
compared against set operations on bitmaps over the universe `[-24, 25)`.
This is exact because the algebra is endpoint-based: results are max/min
combinations of input endpoints, so integer endpoints never become
non-integers, and a half-open interval with integer endpoints covers exactly
its integer points. The integer bitmap therefore *is* the set, with no sampling
error and no tolerance.
The real-line claim (dense, unbounded domain) rests on the same endpoint
arithmetic plus the density argument, not on a separate test. The library is
declared over the reals; the oracle pins the integer-endpoint case.
### What the harness checks
| # | check | volume |
|---|-------|--------|
| 1 | fuzz: real implementation, 0 semantic / 0 structural failures | 3000 trials, 75,750 comparisons |
| 2 | teeth: 7/7 mutants detected, each in the expected leg | 7 x 3000 trials |
| 3 | laws: De Morgan (both ways), involution, idempotence, commutativity, associativity, `a == (a n b) U (a \ b)`, `a n ~a == {}`, `a U ~a == line`, measure additivity | 600 random triples |
| 4 | laws: `normalize` idempotent + order-independent; malformed input rejected; regression cases | 600 random sets |
| 5 | boundary: touching, gaps, shared endpoints, unbounded bounds, full line, measure, points | 30 hand-checked cases |
| 6 | boundary: text parse/format round-trips, 11 rejection cases | 29 cases |
| 7 | points: `from_points`/`sample_points` vs the bitmap oracle | 300 point sets |
| 8 | endpoints: no invented endpoints; float and `Fraction` exactness | 400 random pairs |
| 9 | scale: 1500 x 1500 intervals vs brute-force enumeration | 600-point universe |
| 10 | CLI: subprocess smoke over real argv, stdout, exit codes, rejection | 16 cases |
| 11 | CLI: exit code 0 and the value on stdout | 3 cases |
| 12 | oracle self-check: `in_set` vs `ik.contains`; bitmap size vs real measure | 500 sets x 49 points |
Fuzz coverage floors (anti-vacuity): 2049 trials with unbounded bounds, 2288
with a merge, 830 with multi-interval results, 1113 with an empty set, 1052
distinct canonical sets.
### The teeth
A harness that cannot fail proves nothing. Seven deliberately broken
implementations are pushed through the *same* oracle; all seven must be caught,
and in the leg that is supposed to see them (semantic = bitmap mismatch or
crash, structural = result not canonical):
| mutant | semantic | structural | expected |
|--------|---------:|-----------:|----------|
| `normalize`: strict-overlap merge (misses touching) | 145 | 118 | structural |
| `intersection`: always advance the left cursor | 270 | 0 | semantic |
| `complement`: bounds written to wrong slots | 3000 | 0 | semantic |
| `measure`: counts integer slots (`hi - lo - 1`) | 1032 | 0 | semantic |
| `contains`: ignores the unbounded lower bound | 197 | 0 | semantic |
| `union`: unnormalized concatenation | 0 | 1835 | structural |
| `normalize`: pre-fix body, duplicate `-inf` lower bound crashes | 145 | 0 | semantic |
The last mutant is the pre-fix body of `ik.normalize`; the fuzz found that crash
in the real library. Two bugs were found this way while the suite was being
written, and both are now pinned by named regression cases in check 4:
1. `normalize` compared a second `-inf` lower bound with `<=` and raised
`TypeError`. Reachable from `union`/`symmetric_difference`, which concatenate
normal forms and re-normalize. Fix: treat `lo is None` as "always joins".
2. `is_canonical([(None, 5), (1, 2)])` raised `TypeError` instead of returning
`False`. Fix: only compare `plo <= lo` when `plo` is not `None`.
### Anti-vacuity guard
The runner counts executed comparisons and refuses to exit 0 if fewer than
12 checks are registered or fewer than 100,000 comparisons were actually
executed. A test run that silently stops asserting cannot report success.
## Limits, stated plainly
* The exact differential oracle is over integer endpoints on a bounded
universe. Unbounded and float/`Fraction` behaviour is covered by hand-checked
cases, algebraic laws and endpoint-preservation properties, not by the bitmap
oracle.
* `contains` and `measure` are linear scans; `measure` normalizes first so it
cannot double-count overlapping parts. There is no interval tree here — this
is an algebra, not an index.
* Degenerate (single-point) intervals are out of scope by design, as above.