null: intervalkit — canonical half-open interval-set algebra, 12/12 checks + 7/7 mutants caught (641k differential comparisons)
This commit is contained in:
1 parent
37048766f4
commit
e9d3283242
3 files changed
+1427
No files matched your search
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
"""intervalkit -- canonical algebra of half-open interval sets over the reals.
|
||||
|
||||
Stdlib only, no third-party dependencies.
|
||||
|
||||
Model
|
||||
=====
|
||||
A *set* is a finite sequence of half-open intervals ``[lo, hi)`` where ``lo``
|
||||
and ``hi`` are numbers or ``None``. ``None`` in the ``lo`` slot means
|
||||
-unbounded (-inf); ``None`` in the ``hi`` slot means +unbounded (+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, computed by
|
||||
:func:`normalize`.
|
||||
|
||||
Guarantees
|
||||
==========
|
||||
* ``normalize`` is idempotent and returns the unique canonical form.
|
||||
* ``union``, ``intersection``, ``difference``, ``symmetric_difference`` and
|
||||
``complement`` are total, closed, and always return canonical sets.
|
||||
* No operation mutates its inputs and none invents endpoints: every result is
|
||||
built from max/min combinations of input endpoints, so integer endpoints stay
|
||||
integers.
|
||||
* ``complement`` is taken over the whole line (-inf, +inf) and is an involution.
|
||||
* Touching intervals merge: ``[1,2) U [2,3) == [1,3)``. A one-unit gap does
|
||||
not: ``[1,2) U [3,4)`` stays two intervals.
|
||||
|
||||
Deliberate limit
|
||||
================
|
||||
Degenerate intervals (single points, ``[x, x)``) are not representable. That
|
||||
is the price of a half-open model, and it is what makes every operation above
|
||||
total and closed. A point set such as {0, 2} is therefore *not* expressible;
|
||||
{0, 1, 2} is, as ``[0, 3)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
|
||||
__all__ = [
|
||||
"IntervalError",
|
||||
"normalize",
|
||||
"canonical",
|
||||
"is_canonical",
|
||||
"assert_canonical",
|
||||
"union",
|
||||
"intersection",
|
||||
"difference",
|
||||
"symmetric_difference",
|
||||
"complement",
|
||||
"contains",
|
||||
"measure",
|
||||
"sample_points",
|
||||
"from_points",
|
||||
"parse",
|
||||
"format_set",
|
||||
"main",
|
||||
]
|
||||
|
||||
|
||||
class IntervalError(ValueError):
|
||||
"""Raised for malformed intervals, sets or interval text."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# bounds helpers. In this module None in the lo slot means -inf and None in
|
||||
# the hi slot means +inf. Two different combination rules follow from that.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _none_loses(a, b, fn):
|
||||
"""Combine bounds where None means -inf (max) or +inf (min): None loses."""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return fn(a, b)
|
||||
|
||||
|
||||
def _max_lo(a, b):
|
||||
return _none_loses(a, b, max)
|
||||
|
||||
|
||||
def _min_hi(a, b):
|
||||
return _none_loses(a, b, min)
|
||||
|
||||
|
||||
def _max_hi(a, b):
|
||||
"""Combine upper bounds where None means +inf: None wins."""
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return max(a, b)
|
||||
|
||||
|
||||
def _valid_bounds(lo, hi):
|
||||
if lo is None or hi is None:
|
||||
return True
|
||||
try:
|
||||
return bool(lo < hi)
|
||||
except TypeError:
|
||||
raise IntervalError("endpoints are not comparable: %r, %r" % (lo, hi))
|
||||
|
||||
|
||||
def _bounds(iv):
|
||||
"""Validate one interval and return it as a ``(lo, hi)`` tuple."""
|
||||
if isinstance(iv, (str, bytes)):
|
||||
raise IntervalError("interval must be a (lo, hi) pair, got %r" % (iv,))
|
||||
try:
|
||||
n = len(iv)
|
||||
except TypeError:
|
||||
raise IntervalError("interval must be a (lo, hi) pair, got %r" % (iv,))
|
||||
if n != 2:
|
||||
raise IntervalError("interval must have exactly 2 endpoints, got %d in %r" % (n, iv))
|
||||
lo, hi = iv[0], iv[1]
|
||||
if not _valid_bounds(lo, hi):
|
||||
raise IntervalError(
|
||||
"empty or ill-ordered interval %r; the empty set is the empty sequence" % (iv,)
|
||||
)
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _sort_key(iv):
|
||||
lo = iv[0]
|
||||
return (0, 0) if lo is None else (1, lo)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# core
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def normalize(intervals):
|
||||
"""Return the unique canonical form of ``intervals``.
|
||||
|
||||
Accepts any sequence of valid (possibly overlapping, unsorted) intervals.
|
||||
Idempotent. Raises :class:`IntervalError` on a malformed interval.
|
||||
"""
|
||||
if intervals is None:
|
||||
raise IntervalError("expected a sequence of intervals, got None")
|
||||
items = []
|
||||
for iv in intervals:
|
||||
items.append(_bounds(iv))
|
||||
if not items:
|
||||
return ()
|
||||
try:
|
||||
items.sort(key=_sort_key)
|
||||
except TypeError:
|
||||
raise IntervalError("endpoints are not mutually comparable: %r" % (items,))
|
||||
out = [items[0]]
|
||||
for lo, hi in items[1:]:
|
||||
plo, phi = out[-1]
|
||||
# Sorted by lo, so this interval starts at or after the open one. It
|
||||
# joins when it starts no later than the open interval ends; a second
|
||||
# -inf lower bound (lo is None) always joins, since -inf <= anything.
|
||||
if phi is None or lo is None or lo <= phi:
|
||||
out[-1] = (plo, _max_hi(phi, hi))
|
||||
else:
|
||||
out.append((lo, hi))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
#: Public alias: ``canonical`` reads better at call sites that just want form.
|
||||
canonical = normalize
|
||||
|
||||
|
||||
def is_canonical(intervals):
|
||||
"""True iff ``intervals`` is exactly the canonical form of its own set."""
|
||||
try:
|
||||
items = [_bounds(iv) for iv in intervals]
|
||||
except IntervalError:
|
||||
return False
|
||||
for k in range(1, len(items)):
|
||||
plo, phi = items[k - 1]
|
||||
lo, hi = items[k]
|
||||
if lo is None:
|
||||
return False # an unbounded-below interval must be first
|
||||
if phi is None:
|
||||
return False # nothing can follow an unbounded-above interval
|
||||
if plo is not None and not (plo <= lo):
|
||||
return False # not sorted
|
||||
if not (lo > phi):
|
||||
return False # touching or overlapping: not separated
|
||||
return True
|
||||
|
||||
|
||||
def assert_canonical(intervals):
|
||||
"""Raise ``AssertionError`` with detail unless ``intervals`` is canonical."""
|
||||
if not is_canonical(intervals):
|
||||
raise AssertionError(
|
||||
"not canonical: %r (normal form is %r)" % (tuple(intervals), normalize(intervals))
|
||||
)
|
||||
return tuple(intervals)
|
||||
|
||||
|
||||
def union(*sets):
|
||||
"""Canonical union of any number of sets."""
|
||||
acc = []
|
||||
for s in sets:
|
||||
acc.extend(normalize(s))
|
||||
return normalize(acc)
|
||||
|
||||
|
||||
def _intersect_two(a, b):
|
||||
res = []
|
||||
i = j = 0
|
||||
while i < len(a) and j < len(b):
|
||||
alo, ahi = a[i]
|
||||
blo, bhi = b[j]
|
||||
lo = _max_lo(alo, blo)
|
||||
hi = _min_hi(ahi, bhi)
|
||||
if lo is None or hi is None or lo < hi:
|
||||
res.append((lo, hi))
|
||||
# advance whichever interval ends first (on a tie, advance the left)
|
||||
if ahi is None:
|
||||
j += 1
|
||||
elif bhi is None:
|
||||
i += 1
|
||||
elif ahi <= bhi:
|
||||
i += 1
|
||||
else:
|
||||
j += 1
|
||||
return tuple(res)
|
||||
|
||||
|
||||
def intersection(*sets):
|
||||
"""Canonical intersection of any number of sets."""
|
||||
if not sets:
|
||||
return ((None, None),) # intersection of nothing is the whole line
|
||||
acc = normalize(sets[0])
|
||||
for s in sets[1:]:
|
||||
acc = _intersect_two(acc, normalize(s))
|
||||
if not acc:
|
||||
return ()
|
||||
return acc
|
||||
|
||||
|
||||
def complement(a):
|
||||
"""Complement of ``a`` over the whole line (-inf, +inf). Involution."""
|
||||
a = normalize(a)
|
||||
out = []
|
||||
if a and a[0][0] is not None:
|
||||
out.append((None, a[0][0]))
|
||||
for k in range(len(a) - 1):
|
||||
out.append((a[k][1], a[k + 1][0]))
|
||||
if a and a[-1][1] is not None:
|
||||
out.append((a[-1][1], None))
|
||||
if not a:
|
||||
out.append((None, None))
|
||||
return normalize(out)
|
||||
|
||||
|
||||
def difference(a, b):
|
||||
"""``a`` minus ``b``, canonical."""
|
||||
return _intersect_two(normalize(a), complement(b))
|
||||
|
||||
|
||||
def symmetric_difference(a, b):
|
||||
"""Points in exactly one of ``a``, ``b``, canonical."""
|
||||
return normalize(list(difference(a, b)) + list(difference(b, a)))
|
||||
|
||||
|
||||
def contains(a, x):
|
||||
"""True iff the number ``x`` is in the set ``a``.
|
||||
|
||||
Works on any valid sequence of intervals, canonical or not.
|
||||
"""
|
||||
for lo, hi in a:
|
||||
lo, hi = _bounds((lo, hi))
|
||||
if (lo is None or lo <= x) and (hi is None or x < hi):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def measure(a):
|
||||
"""Total length of ``a``; ``inf`` when any interval is unbounded."""
|
||||
total = 0
|
||||
for lo, hi in normalize(a):
|
||||
if lo is None or hi is None:
|
||||
return math.inf
|
||||
total += hi - lo
|
||||
return total
|
||||
|
||||
|
||||
def sample_points(a, lo, hi):
|
||||
"""The integers ``x`` with ``lo <= x < hi`` that are in the set ``a``."""
|
||||
return [x for x in range(lo, hi) if contains(a, x)]
|
||||
|
||||
|
||||
def from_points(points):
|
||||
"""Canonical set from integer points; runs of consecutive integers merge."""
|
||||
xs = sorted(set(points))
|
||||
if not xs:
|
||||
return ()
|
||||
ivs = []
|
||||
start = prev = xs[0]
|
||||
for x in xs[1:]:
|
||||
if x == prev + 1:
|
||||
prev = x
|
||||
continue
|
||||
ivs.append((start, prev + 1))
|
||||
start = prev = x
|
||||
ivs.append((start, prev + 1))
|
||||
return normalize(ivs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# text: "[1,3) U [5,+inf)" <-> canonical sets
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_TOKEN_RE = re.compile(r"\[\s*([^,\]]+?)\s*,\s*([^,\]]+?)\s*\)")
|
||||
_SEP_RE = re.compile(r"\s*(?:U|\u222a|\||;)\s*")
|
||||
_EMPTY_WORDS = ("", "{}", "empty", "EMPTY", "none", "NONE")
|
||||
|
||||
|
||||
def _num_lo(s):
|
||||
t = s.strip().lower()
|
||||
if t in ("-inf", "-infinity", "-oo"):
|
||||
return None
|
||||
if t in ("+inf", "inf", "infinity", "+oo", "oo"):
|
||||
raise IntervalError("a lower bound cannot be +inf: %r" % s)
|
||||
return _number(t, s)
|
||||
|
||||
|
||||
def _num_hi(s):
|
||||
t = s.strip().lower()
|
||||
if t in ("+inf", "inf", "infinity", "+oo", "oo"):
|
||||
return None
|
||||
if t in ("-inf", "-infinity", "-oo"):
|
||||
raise IntervalError("an upper bound cannot be -inf: %r" % s)
|
||||
return _number(t, s)
|
||||
|
||||
|
||||
def _number(t, original):
|
||||
if re.fullmatch(r"[+-]?\d+", t):
|
||||
return int(t)
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
raise IntervalError("bad endpoint: %r" % original)
|
||||
|
||||
|
||||
def parse(text):
|
||||
"""Parse interval text into a canonical set.
|
||||
|
||||
Accepts ``[1,3)``, ``(-inf,2)`` as ``[-inf,2)``-style unbounded bounds
|
||||
spelled ``-inf`` / ``+inf``, separated by ``U``, ``|`` or ``;``, plus the
|
||||
empty-set spellings ``{}`` / ``empty``.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise IntervalError("expected interval text, got %r" % (text,))
|
||||
s = text.strip()
|
||||
if s in _EMPTY_WORDS:
|
||||
return ()
|
||||
ivs = []
|
||||
for piece in _SEP_RE.split(s):
|
||||
piece = piece.strip()
|
||||
if not piece:
|
||||
continue
|
||||
m = _TOKEN_RE.fullmatch(piece)
|
||||
if not m:
|
||||
raise IntervalError(
|
||||
"not a half-open interval: %r (this algebra is half-open: "
|
||||
"lower '[' , upper ')' , or use -inf / +inf)" % piece
|
||||
)
|
||||
ivs.append((_num_lo(m.group(1)), _num_hi(m.group(2))))
|
||||
if not ivs:
|
||||
raise IntervalError("no interval found in %r" % text)
|
||||
return normalize(ivs)
|
||||
|
||||
|
||||
def _fmt_num(x):
|
||||
if isinstance(x, float) and x.is_integer():
|
||||
return str(int(x))
|
||||
return repr(x) if isinstance(x, float) else str(x)
|
||||
|
||||
|
||||
def format_set(a):
|
||||
"""Canonical set as text: ``[1,3) U [5,+inf)``, or ``{}`` when empty."""
|
||||
a = normalize(a)
|
||||
if not a:
|
||||
return "{}"
|
||||
parts = []
|
||||
for lo, hi in a:
|
||||
parts.append(
|
||||
"[%s,%s)"
|
||||
% ("-inf" if lo is None else _fmt_num(lo), "+inf" if hi is None else _fmt_num(hi))
|
||||
)
|
||||
return " U ".join(parts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_USAGE = """\
|
||||
usage: intervalkit.py OP ARGS...
|
||||
|
||||
union A B [C ...] canonical union
|
||||
intersection A B [..] canonical intersection
|
||||
difference A B A minus B
|
||||
symdiff A B points in exactly one of A, B
|
||||
complement A complement over (-inf, +inf)
|
||||
contains A X print true / false
|
||||
measure A print total length (inf when unbounded)
|
||||
parse TEXT parse TEXT and print its canonical form
|
||||
|
||||
A set is written "[1,3) U [5,+inf)" or "{}" for the empty set.
|
||||
Endpoints are numbers, -inf or +inf; the algebra is half-open.
|
||||
"""
|
||||
|
||||
_OPS = ("union", "intersection", "difference", "symdiff", "complement",
|
||||
"contains", "measure", "parse")
|
||||
|
||||
|
||||
def _need(args, n, op):
|
||||
if len(args) < n:
|
||||
raise IntervalError("%s needs at least %d argument(s)" % (op, n))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help", "help"):
|
||||
sys.stdout.write(_USAGE)
|
||||
return 0
|
||||
op, args = argv[0], argv[1:]
|
||||
try:
|
||||
if op not in _OPS:
|
||||
raise IntervalError("unknown op %r" % op)
|
||||
if op == "parse":
|
||||
_need(args, 1, op)
|
||||
out = format_set(parse(" ".join(args)))
|
||||
elif op == "union":
|
||||
_need(args, 1, op)
|
||||
out = format_set(union(*[parse(a) for a in args]))
|
||||
elif op == "intersection":
|
||||
_need(args, 1, op)
|
||||
out = format_set(intersection(*[parse(a) for a in args]))
|
||||
elif op == "difference":
|
||||
_need(args, 2, op)
|
||||
out = format_set(difference(parse(args[0]), parse(args[1])))
|
||||
elif op == "symdiff":
|
||||
_need(args, 2, op)
|
||||
out = format_set(symmetric_difference(parse(args[0]), parse(args[1])))
|
||||
elif op == "complement":
|
||||
_need(args, 1, op)
|
||||
out = format_set(complement(parse(args[0])))
|
||||
elif op == "contains":
|
||||
_need(args, 2, op)
|
||||
out = "true" if contains(parse(args[0]), _number(args[1].strip().lower(), args[1])) else "false"
|
||||
else: # measure
|
||||
_need(args, 1, op)
|
||||
out = str(measure(parse(args[0])))
|
||||
except IntervalError as exc:
|
||||
sys.stderr.write("error: %s\n\n%s" % (exc, _USAGE))
|
||||
return 2
|
||||
sys.stdout.write(str(out) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,815 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_intervalkit.py -- verification harness for intervalkit (stdlib only).
|
||||
|
||||
Run: python3 test_intervalkit.py exit 0 = every check passed
|
||||
|
||||
What this harness establishes
|
||||
-----------------------------
|
||||
1. EXACT DIFFERENTIAL ORACLE. For integer-endpoint interval sets, membership
|
||||
over the integers is decided by ``in_set`` -- an independent 5-line
|
||||
predicate written in this file, not imported from the library. Every
|
||||
operation is then compared against the corresponding set operation on
|
||||
bitmaps over the universe [-24, 25). Because every operation builds its
|
||||
result from max/min combinations of input endpoints, integer endpoints stay
|
||||
integer, so the integer bitmap *is* the set: this comparison is exact, not
|
||||
sampled.
|
||||
2. STRUCTURAL CONTRACT. Every result must be canonical (sorted,
|
||||
non-degenerate, strictly separated).
|
||||
3. ALGEBRAIC LAWS. De Morgan (both directions), complement involution,
|
||||
idempotence, commutativity, associativity, a == (a n b) U (a \\ b),
|
||||
a n ~a == {}, a U ~a == the line, measure additivity.
|
||||
4. HAND-CHECKED BOUNDARY CASES. Touching intervals, empty intersection at a
|
||||
shared endpoint, one-unit gaps, unbounded bounds, the full line, text
|
||||
round-trips, and the CLI as a subprocess.
|
||||
5. NEGATIVE CONTROLS (teeth). Six deliberately broken implementations are run
|
||||
through the *same* oracle, which must report each one as detected, in the
|
||||
leg that is supposed to see it. A harness that cannot fail proves nothing.
|
||||
6. ANTI-VACUITY GUARD. The runner counts executed comparisons and fuzz
|
||||
coverage, and refuses to exit 0 below the floors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from fractions import Fraction
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import intervalkit as ik # noqa: E402
|
||||
|
||||
TRIALS = 3000
|
||||
SEED = 20261022
|
||||
UNIVERSE = list(range(-24, 25))
|
||||
UNIVERSE_SET = set(UNIVERSE)
|
||||
|
||||
# Anti-vacuity floors: fail loudly if the fuzz degenerates into trivial inputs,
|
||||
# if checks silently stop executing comparisons, or if checks disappear.
|
||||
MIN_CHECKS = 12
|
||||
MIN_SUBASSERTIONS = 100000
|
||||
MIN_UNBOUNDED = 200
|
||||
MIN_MERGES = 300
|
||||
MIN_GAPS = 300
|
||||
MIN_EMPTIES = 50
|
||||
MIN_DISTINCT_SETS = 500
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the oracle: independent membership predicate + bitmap
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def in_set(a, x):
|
||||
"""Independent half-open membership test. Deliberately NOT ik.contains."""
|
||||
for lo, hi in a:
|
||||
if (lo is None or lo <= x) and (hi is None or x < hi):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def bitmap(a):
|
||||
return frozenset(x for x in UNIVERSE if in_set(a, x))
|
||||
|
||||
|
||||
def exact_measure(a):
|
||||
"""Total length over the reals, or inf when any interval is unbounded."""
|
||||
total = 0
|
||||
for lo, hi in a:
|
||||
if lo is None or hi is None:
|
||||
return float("inf")
|
||||
total += hi - lo
|
||||
return total
|
||||
|
||||
|
||||
def brute_bitmap(ivs, window=None):
|
||||
"""Bitmap by enumeration -- no sweep, no sorting, no library code.
|
||||
|
||||
Unbounded bounds are clipped to ``window`` (a (lo, hi) pair) when given.
|
||||
"""
|
||||
out = set()
|
||||
for lo, hi in ivs:
|
||||
if window is not None:
|
||||
if lo is None:
|
||||
lo = window[0]
|
||||
if hi is None:
|
||||
hi = window[1]
|
||||
out.update(range(lo, hi))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# tiny check runner with sub-assertion accounting
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
CHECKS = []
|
||||
|
||||
|
||||
def check(name):
|
||||
def deco(fn):
|
||||
CHECKS.append((name, fn))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
class Ctx:
|
||||
def __init__(self):
|
||||
self.sub = 0
|
||||
self.notes = []
|
||||
|
||||
def ok(self, cond, msg):
|
||||
self.sub += 1
|
||||
if not cond:
|
||||
raise AssertionError(msg)
|
||||
return True
|
||||
|
||||
def eq(self, got, want, msg):
|
||||
self.sub += 1
|
||||
if got != want:
|
||||
raise AssertionError("%s\n got: %r\n want: %r" % (msg, got, want))
|
||||
return True
|
||||
|
||||
def note(self, text):
|
||||
self.notes.append(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# fuzz: implementation shim, mutant implementations, differential driver
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Impl:
|
||||
"""A namespace of set operations; defaults to the real library."""
|
||||
|
||||
def __init__(self, **over):
|
||||
self.normalize = over.get("normalize", ik.normalize)
|
||||
self.union = over.get("union", ik.union)
|
||||
self.intersection = over.get("intersection", ik.intersection)
|
||||
self.difference = over.get("difference", ik.difference)
|
||||
self.symmetric_difference = over.get("symmetric_difference", ik.symmetric_difference)
|
||||
self.complement = over.get("complement", ik.complement)
|
||||
self.measure = over.get("measure", ik.measure)
|
||||
self.contains = over.get("contains", ik.contains)
|
||||
self.is_canonical = over.get("is_canonical", ik.is_canonical)
|
||||
|
||||
|
||||
def _one(iv):
|
||||
"""Canonicalize a single interval, raising if it is degenerate."""
|
||||
n = ik.normalize([iv])
|
||||
if not n:
|
||||
raise ik.IntervalError("degenerate interval %r" % (iv,))
|
||||
return n[0]
|
||||
|
||||
|
||||
def _key(iv):
|
||||
return (0, 0) if iv[0] is None else (1, iv[0])
|
||||
|
||||
|
||||
def mut_normalize_strict_merge(intervals):
|
||||
"""BUG: joins neighbours only on strict overlap, so touching intervals
|
||||
survive unmerged and the result is not canonical."""
|
||||
ivs = sorted((_one(iv) for iv in intervals), key=_key)
|
||||
if not ivs:
|
||||
return ()
|
||||
out = [ivs[0]]
|
||||
for lo, hi in ivs[1:]:
|
||||
plo, phi = out[-1]
|
||||
if phi is None or lo < phi: # should be lo <= phi
|
||||
out[-1] = (plo, None if (phi is None or hi is None) else max(phi, hi))
|
||||
else:
|
||||
out.append((lo, hi))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def mut_intersection_always_advance_left(a, b):
|
||||
"""BUG: advances the left cursor unconditionally, so it stops looking at
|
||||
the right set after the first hit."""
|
||||
a, b = ik.normalize(a), ik.normalize(b)
|
||||
res = []
|
||||
i = j = 0
|
||||
while i < len(a) and j < len(b):
|
||||
alo, ahi = a[i]
|
||||
blo, bhi = b[j]
|
||||
lo = ik._max_lo(alo, blo)
|
||||
hi = ik._min_hi(ahi, bhi)
|
||||
if lo is None or hi is None or lo < hi:
|
||||
res.append((lo, hi))
|
||||
i += 1 # should advance whichever interval ends first
|
||||
return tuple(res)
|
||||
|
||||
|
||||
def mut_complement_bounds_swapped(a):
|
||||
"""BUG: writes each bound into the wrong slot."""
|
||||
a = ik.normalize(a)
|
||||
out = []
|
||||
for lo, hi in a:
|
||||
out.append((None, hi))
|
||||
out.append((lo, None))
|
||||
return ik.normalize(out)
|
||||
|
||||
|
||||
def mut_measure_counts_integer_slots(a):
|
||||
"""BUG: counts integer slots (hi - lo - 1) instead of length."""
|
||||
total = 0
|
||||
for lo, hi in ik.normalize(a):
|
||||
if lo is None or hi is None:
|
||||
return float("inf")
|
||||
total += hi - lo - 1
|
||||
return total
|
||||
|
||||
|
||||
def mut_contains_ignores_unbounded_lower(a, x):
|
||||
"""BUG: drops the -inf case, so (-inf, h) is treated as empty."""
|
||||
for lo, hi in a:
|
||||
if (lo is not None and lo <= x) and (hi is None or x < hi):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mut_union_unnormalized_concat(*sets):
|
||||
"""BUG: concatenates normalized parts without re-normalizing the join."""
|
||||
out = []
|
||||
for s in sets:
|
||||
out.extend(ik.normalize(s))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def mut_normalize_no_neginf_merge(intervals):
|
||||
"""BUG: this is the pre-fix body of ik.normalize -- it compares a second
|
||||
-inf lower bound with ``<=`` and crashes with a TypeError. The differential
|
||||
fuzz found this in the real library; the mutant pins the regression."""
|
||||
ivs = sorted((_one(iv) for iv in intervals), key=_key)
|
||||
if not ivs:
|
||||
return ()
|
||||
out = [ivs[0]]
|
||||
for lo, hi in ivs[1:]:
|
||||
plo, phi = out[-1]
|
||||
if phi is None or lo <= phi: # crashes when lo is None
|
||||
out[-1] = (plo, None if (phi is None or hi is None) else max(phi, hi))
|
||||
else:
|
||||
out.append((lo, hi))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
MUTANTS = [
|
||||
("normalize: strict-overlap merge (misses touching)",
|
||||
Impl(normalize=mut_normalize_strict_merge), "structural"),
|
||||
("intersection: always advance left cursor",
|
||||
Impl(intersection=mut_intersection_always_advance_left), "semantic"),
|
||||
("complement: bounds written to wrong slots",
|
||||
Impl(complement=mut_complement_bounds_swapped), "semantic"),
|
||||
("measure: counts integer slots (hi - lo - 1)",
|
||||
Impl(measure=mut_measure_counts_integer_slots), "semantic"),
|
||||
("contains: ignores unbounded lower bound",
|
||||
Impl(contains=mut_contains_ignores_unbounded_lower), "semantic"),
|
||||
("union: unnormalized concatenation",
|
||||
Impl(union=mut_union_unnormalized_concat), "structural"),
|
||||
("normalize: duplicate -inf lower bound crashes (pre-fix body)",
|
||||
Impl(normalize=mut_normalize_no_neginf_merge), "semantic"),
|
||||
]
|
||||
|
||||
REAL = Impl()
|
||||
|
||||
|
||||
def _rand_intervals(rng, n):
|
||||
"""Random valid intervals with integer endpoints in [-10, 10], plus
|
||||
unbounded bounds and the occasional full line."""
|
||||
ivs = []
|
||||
for _ in range(n):
|
||||
style = rng.random()
|
||||
lo = None if style < 0.15 else rng.randint(-10, 10)
|
||||
hi = None if style > 0.85 else rng.randint(-10, 10)
|
||||
if lo is not None and hi is not None and lo >= hi:
|
||||
lo, hi = hi, lo
|
||||
if lo == hi:
|
||||
continue
|
||||
ivs.append((lo, hi))
|
||||
return ivs
|
||||
|
||||
|
||||
def diff_failures(impl, trials=TRIALS, seed=SEED, with_coverage=False):
|
||||
"""Run the differential oracle against ``impl``.
|
||||
|
||||
Returns 'semantic' and 'structural' failure lists, a 'checks_run' count of
|
||||
comparisons actually performed, and 'coverage' when asked.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
semantic, structural = [], []
|
||||
cov = Counter()
|
||||
seen = set()
|
||||
n_checks = 0
|
||||
|
||||
def hit(leg, op, msg, raw_a, raw_b=None):
|
||||
entry = "%s(%r%s): %s" % (op, raw_a, "" if raw_b is None else ", %r" % (raw_b,), msg)
|
||||
(semantic if leg == "semantic" else structural).append(entry)
|
||||
|
||||
for t in range(trials):
|
||||
raw_a = _rand_intervals(rng, rng.randint(0, 4))
|
||||
raw_b = _rand_intervals(rng, rng.randint(0, 4))
|
||||
ba, bb = bitmap(raw_a), bitmap(raw_b)
|
||||
can_a, can_b = ik.normalize(raw_a), ik.normalize(raw_b)
|
||||
|
||||
cov["sets"] += 2
|
||||
seen.add(can_a)
|
||||
seen.add(can_b)
|
||||
if len(raw_a) != len(can_a) or len(raw_b) != len(can_b):
|
||||
cov["merges"] += 1
|
||||
if any(lo is None or hi is None for lo, hi in raw_a + raw_b):
|
||||
cov["unbounded"] += 1
|
||||
if len(can_a) > 1 or len(can_b) > 1:
|
||||
cov["gaps"] += 1
|
||||
if not can_a or not can_b:
|
||||
cov["empties"] += 1
|
||||
|
||||
# ---- normalize -------------------------------------------------
|
||||
try:
|
||||
r = impl.normalize(raw_a)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hit("semantic", "normalize", "raised %s: %s" % (type(exc).__name__, exc), raw_a)
|
||||
r = None
|
||||
if r is not None:
|
||||
n_checks += 2
|
||||
if bitmap(r) != ba:
|
||||
hit("semantic", "normalize",
|
||||
"bitmap %r != %r" % (sorted(bitmap(r)), sorted(ba)), raw_a)
|
||||
if not impl.is_canonical(r):
|
||||
hit("structural", "normalize", "result %r is not canonical" % (r,), raw_a)
|
||||
|
||||
# ---- binary set operations ------------------------------------
|
||||
for op, want in (
|
||||
("union", lambda: ba | bb),
|
||||
("intersection", lambda: ba & bb),
|
||||
("difference", lambda: ba - bb),
|
||||
("symmetric_difference", lambda: ba ^ bb),
|
||||
):
|
||||
fn = getattr(impl, op)
|
||||
try:
|
||||
res = fn(raw_a, raw_b)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hit("semantic", op, "raised %s: %s" % (type(exc).__name__, exc), raw_a, raw_b)
|
||||
continue
|
||||
n_checks += 2
|
||||
if bitmap(res) != want():
|
||||
hit("semantic", op,
|
||||
"bitmap %r != %r" % (sorted(bitmap(res)), sorted(want())), raw_a, raw_b)
|
||||
if not impl.is_canonical(res):
|
||||
hit("structural", op, "result %r is not canonical" % (res,), raw_a, raw_b)
|
||||
|
||||
# ---- complement (over the whole line) --------------------------
|
||||
try:
|
||||
res = impl.complement(can_a)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hit("semantic", "complement", "raised %s: %s" % (type(exc).__name__, exc), can_a)
|
||||
else:
|
||||
n_checks += 2
|
||||
want = UNIVERSE_SET - ba
|
||||
if bitmap(res) != want:
|
||||
hit("semantic", "complement",
|
||||
"bitmap %r != %r" % (sorted(bitmap(res)), sorted(want)), can_a)
|
||||
if not impl.is_canonical(res):
|
||||
hit("structural", "complement", "result %r is not canonical" % (res,), can_a)
|
||||
|
||||
# ---- measure ---------------------------------------------------
|
||||
try:
|
||||
m = impl.measure(raw_a)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hit("semantic", "measure", "raised %s: %s" % (type(exc).__name__, exc), raw_a)
|
||||
else:
|
||||
n_checks += 1
|
||||
want_m = exact_measure(can_a)
|
||||
if m != want_m:
|
||||
hit("semantic", "measure", "got %r want %r" % (m, want_m), raw_a)
|
||||
elif want_m != float("inf") and len(ba) != want_m:
|
||||
raise AssertionError(
|
||||
"oracle self-check: integer bitmap size %d != real measure %r for %r"
|
||||
% (len(ba), want_m, raw_a)
|
||||
)
|
||||
|
||||
# ---- contains (every 4th trial, to keep the run quick) ---------
|
||||
if t % 4 == 0:
|
||||
for x in UNIVERSE:
|
||||
try:
|
||||
got = impl.contains(raw_a, x)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hit("semantic", "contains",
|
||||
"raised %s: %s" % (type(exc).__name__, exc), raw_a)
|
||||
break
|
||||
n_checks += 1
|
||||
if bool(got) != (x in ba):
|
||||
hit("semantic", "contains",
|
||||
"contains(%r) is %r, oracle says %r" % (x, got, x in ba), raw_a)
|
||||
break
|
||||
|
||||
out = {"semantic": semantic, "structural": structural, "checks_run": n_checks}
|
||||
if with_coverage:
|
||||
cov["distinct_canonical_sets"] = len(seen)
|
||||
cov["trials"] = trials
|
||||
cov["contains_points_checked"] = (trials + 3) // 4 * len(UNIVERSE)
|
||||
out["coverage"] = cov
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. the real implementation must be clean, and the fuzz must have teeth
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@check("fuzz: real implementation -- 0 semantic, 0 structural failures")
|
||||
def _c_real(ctx):
|
||||
t0 = time.time()
|
||||
res = diff_failures(REAL, TRIALS, SEED, with_coverage=True)
|
||||
cov = res["coverage"]
|
||||
ctx.sub += res["checks_run"]
|
||||
ctx.ok(not res["semantic"],
|
||||
"semantic failures: %d, first 3: %s" % (len(res["semantic"]), res["semantic"][:3]))
|
||||
ctx.ok(not res["structural"],
|
||||
"structural failures: %d, first 3: %s" % (len(res["structural"]), res["structural"][:3]))
|
||||
ctx.note("trials=%d, %d comparisons in %.1fs" % (TRIALS, res["checks_run"], time.time() - t0))
|
||||
ctx.note("coverage: " + ", ".join("%s=%d" % kv for kv in sorted(cov.items())))
|
||||
ctx.ok(cov["unbounded"] >= MIN_UNBOUNDED,
|
||||
"fuzz barely exercised unbounded bounds: %d < %d" % (cov["unbounded"], MIN_UNBOUNDED))
|
||||
ctx.ok(cov["merges"] >= MIN_MERGES,
|
||||
"fuzz barely exercised merging: %d < %d" % (cov["merges"], MIN_MERGES))
|
||||
ctx.ok(cov["gaps"] >= MIN_GAPS,
|
||||
"fuzz barely produced multi-interval sets: %d < %d" % (cov["gaps"], MIN_GAPS))
|
||||
ctx.ok(cov["empties"] >= MIN_EMPTIES,
|
||||
"fuzz barely produced empty sets: %d < %d" % (cov["empties"], MIN_EMPTIES))
|
||||
ctx.ok(cov["distinct_canonical_sets"] >= MIN_DISTINCT_SETS,
|
||||
"fuzz explored only %d distinct sets" % cov["distinct_canonical_sets"])
|
||||
|
||||
|
||||
@check("teeth: all 7 mutants detected, each in the expected leg")
|
||||
def _c_mutants(ctx):
|
||||
for name, impl, leg in MUTANTS:
|
||||
res = diff_failures(impl, TRIALS, SEED)
|
||||
sem, stru = len(res["semantic"]), len(res["structural"])
|
||||
ctx.note("mutant %-52s semantic=%-5d structural=%-5d (expected %s)"
|
||||
% (name, sem, stru, leg))
|
||||
ctx.sub += res["checks_run"]
|
||||
ctx.ok(sem + stru > 0, "mutant NOT detected (harness has no teeth): %s" % name)
|
||||
ctx.ok((sem if leg == "semantic" else stru) > 0,
|
||||
"mutant detected, but not in the %s leg: %s" % (leg, name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. algebraic laws, exact canonical equality
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _rand_sets(rng, n):
|
||||
for _ in range(n):
|
||||
yield ik.normalize(_rand_intervals(rng, rng.randint(0, 3)))
|
||||
|
||||
|
||||
@check("laws: De Morgan, involution, idempotence, commutativity, associativity")
|
||||
def _c_laws(ctx):
|
||||
rng = random.Random(SEED + 1)
|
||||
for a in _rand_sets(rng, 600):
|
||||
b = ik.normalize(_rand_intervals(rng, rng.randint(0, 3)))
|
||||
c = ik.normalize(_rand_intervals(rng, rng.randint(0, 3)))
|
||||
ctx.eq(ik.complement(ik.union(a, b)),
|
||||
ik.intersection(ik.complement(a), ik.complement(b)),
|
||||
"De Morgan (union) failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.complement(ik.intersection(a, b)),
|
||||
ik.union(ik.complement(a), ik.complement(b)),
|
||||
"De Morgan (intersection) failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.complement(ik.complement(a)), a, "involution failed for %r" % (a,))
|
||||
ctx.eq(ik.union(a, a), a, "union idempotence failed for %r" % (a,))
|
||||
ctx.eq(ik.intersection(a, a), a, "intersection idempotence failed for %r" % (a,))
|
||||
ctx.eq(ik.union(a, b), ik.union(b, a), "union commutativity failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.intersection(a, b), ik.intersection(b, a),
|
||||
"intersection commutativity failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.symmetric_difference(a, b), ik.symmetric_difference(b, a),
|
||||
"symdiff commutativity failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.union(ik.union(a, b), c), ik.union(a, ik.union(b, c)),
|
||||
"union associativity failed for %r, %r, %r" % (a, b, c))
|
||||
ctx.eq(ik.intersection(ik.intersection(a, b), c), ik.intersection(a, ik.intersection(b, c)),
|
||||
"intersection associativity failed for %r, %r, %r" % (a, b, c))
|
||||
ctx.eq(ik.union(ik.intersection(a, b), ik.difference(a, b)), a,
|
||||
"a == (a n b) U (a \\ b) failed for %r, %r" % (a, b))
|
||||
ctx.eq(ik.intersection(a, ik.complement(a)), (), "a n ~a != {} for %r" % (a,))
|
||||
ctx.eq(ik.union(a, ik.complement(a)), ((None, None),),
|
||||
"a U ~a != the whole line for %r" % (a,))
|
||||
ctx.eq(ik.difference(a, a), (), "a \\ a != {} for %r" % (a,))
|
||||
ctx.eq(ik.difference(a, ()), a, "a \\ {} != a for %r" % (a,))
|
||||
ctx.eq(ik.difference((), a), (), "{} \\ a != {} for %r" % (a,))
|
||||
ctx.eq(ik.union(a, ()), a, "a U {} != a for %r" % (a,))
|
||||
ctx.eq(ik.intersection(a, ((None, None),)), a, "a n line != a for %r" % (a,))
|
||||
ctx.eq(ik.symmetric_difference(a, a), (), "a ^ a != {} for %r" % (a,))
|
||||
ma, mb = ik.measure(a), ik.measure(b)
|
||||
if ma != float("inf") and mb != float("inf"):
|
||||
ctx.eq(ik.measure(ik.union(a, b)) + ik.measure(ik.intersection(a, b)), ma + mb,
|
||||
"measure additivity failed for %r, %r" % (a, b))
|
||||
ctx.ok(ik.is_canonical(a), "generated set %r is not canonical" % (a,))
|
||||
|
||||
|
||||
@check("laws: normalize is idempotent and order-independent; bad input rejected")
|
||||
def _c_normalize(ctx):
|
||||
rng = random.Random(SEED + 2)
|
||||
for _ in range(600):
|
||||
raw = _rand_intervals(rng, rng.randint(0, 5))
|
||||
n1 = ik.normalize(raw)
|
||||
ctx.eq(ik.normalize(n1), n1, "normalize not idempotent for %r" % (raw,))
|
||||
ctx.ok(ik.is_canonical(n1), "normalize(%r) not canonical: %r" % (raw, n1))
|
||||
ctx.eq(ik.assert_canonical(n1), n1, "assert_canonical rejected its own output for %r" % (raw,))
|
||||
perm = list(raw)
|
||||
rng.shuffle(perm)
|
||||
ctx.eq(ik.normalize(perm), n1, "normalize depends on input order for %r" % (raw,))
|
||||
if list(raw) != list(n1):
|
||||
ctx.ok(not ik.is_canonical(raw),
|
||||
"a non-normal-form %r passed is_canonical" % (raw,))
|
||||
for bad in ([(1, 2), (2, 3)], [(2, 3), (1, 2)], [(1, 5), (2, 3)],
|
||||
[(None, 5), (1, 2)], [(1, 2), (None, 5)], [(None, None), (1, 2)]):
|
||||
ctx.ok(not ik.is_canonical(bad), "is_canonical accepted non-canonical %r" % (bad,))
|
||||
for good in ([], [(1, 2)], [(1, 2), (3, 4)], [(None, 2), (3, None)], [(None, None)]):
|
||||
ctx.ok(ik.is_canonical(good), "is_canonical rejected canonical %r" % (good,))
|
||||
for bad_iv in ([(3, 3)], [(5, 1)], [(1, 2, 3)], ["[1,2)"], [(None, None, None)], [(1,)]):
|
||||
try:
|
||||
ik.normalize(bad_iv)
|
||||
except ik.IntervalError:
|
||||
ctx.sub += 1
|
||||
else:
|
||||
raise AssertionError("normalize accepted malformed input %r" % (bad_iv,))
|
||||
# regressions for the two library bugs this suite found while being written
|
||||
ctx.eq(ik.union([(None, 1)], [(None, 2)]), ((None, 2),),
|
||||
"two -inf lower bounds must merge, not crash")
|
||||
ctx.eq(ik.union([(None, 1), (-9, -6)], [(None, 1), (-9, -6)]), ((None, 1),),
|
||||
"union of a set that reaches -inf with itself")
|
||||
ctx.eq(ik.symmetric_difference([(None, 0)], [(None, 1)]), ((0, 1),),
|
||||
"symmetric difference of two half-lines meeting at 0")
|
||||
ctx.eq(ik.union([(None, None)], [(1, 2)]), ((None, None),),
|
||||
"the full line absorbs anything")
|
||||
ctx.ok(ik.is_canonical([(None, 5), (1, 2)]) is False,
|
||||
"is_canonical must return False for an unsorted -inf set, not crash")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. hand-checked boundary cases
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@check("boundary: touching, gaps, shared endpoints, unbounded, full line")
|
||||
def _c_boundary(ctx):
|
||||
U, I, D, C, F = ik.union, ik.intersection, ik.difference, ik.complement, ik.format_set
|
||||
|
||||
ctx.eq(F(U([(1, 3)], [(2, 5)])), "[1,5)", "overlapping union")
|
||||
ctx.eq(F(U([(1, 2)], [(2, 3)])), "[1,3)", "touching intervals must merge")
|
||||
ctx.eq(F(U([(1, 2)], [(3, 4)])), "[1,2) U [3,4)", "a one-unit gap must not merge")
|
||||
ctx.eq(F(U([(5, 6)], [(1, 2)])), "[1,2) U [5,6)", "unsorted union")
|
||||
ctx.eq(F(U([(1, 2)], [(2, 3)], [(3, 4)])), "[1,4)", "chain of touching intervals")
|
||||
|
||||
ctx.eq(F(I([(1, 3)], [(3, 5)])), "{}", "a shared endpoint is in neither half-open interval")
|
||||
ctx.eq(F(I([(1, 3)], [(2, 3)])), "[2,3)", "nested intersection")
|
||||
ctx.eq(F(I([(1, 10)], [(3, 4), (6, 7)])), "[3,4) U [6,7)", "intersection against a split set")
|
||||
ctx.eq(F(I([(None, 5)], [(0, None)])), "[0,5)", "unbounded on both sides")
|
||||
ctx.eq(F(I()), "[-inf,+inf)", "intersection of nothing is the whole line")
|
||||
|
||||
ctx.eq(F(D([(1, 5)], [(2, 3)])), "[1,2) U [3,5)", "difference splits an interval")
|
||||
ctx.eq(F(D([(1, 5)], [(1, 5)])), "{}", "difference with itself")
|
||||
ctx.eq(F(D([(1, 5)], [(0, 10)])), "{}", "difference with a superset")
|
||||
ctx.eq(F(D([(1, 5)], [(5, 6)])), "[1,5)", "difference with a disjoint neighbour")
|
||||
|
||||
ctx.eq(F(C([(1, 3)])), "[-inf,1) U [3,+inf)", "complement of an interval")
|
||||
ctx.eq(F(C([])), "[-inf,+inf)", "complement of the empty set is the line")
|
||||
ctx.eq(F(C([(None, None)])), "{}", "complement of the line is empty")
|
||||
ctx.eq(F(C(C([(1, 3), (7, 9)]))), "[1,3) U [7,9)", "complement is an involution")
|
||||
|
||||
ctx.eq(F(U([(None, 0)], [(0, None)])), "[-inf,+inf)", "half-lines meet at 0")
|
||||
ctx.eq(F(ik.symmetric_difference([(1, 5)], [(3, 7)])), "[1,3) U [5,7)", "symmetric difference")
|
||||
|
||||
ctx.eq(ik.measure([(1, 5)]), 4, "measure of [1,5)")
|
||||
ctx.eq(ik.measure([(1, 2), (3, 4)]), 2, "measure sums over parts")
|
||||
ctx.eq(ik.measure([]), 0, "measure of the empty set")
|
||||
ctx.ok(ik.measure([(None, 1)]) == float("inf"), "measure of an unbounded set is inf")
|
||||
ctx.eq(ik.measure(U([(1, 5)], [(3, 9)])), 8, "measure of a merged union")
|
||||
|
||||
ctx.eq(ik.from_points({0, 1, 2, 5}), ((0, 3), (5, 6)), "from_points groups runs")
|
||||
ctx.eq(ik.from_points(set()), (), "from_points of nothing")
|
||||
ctx.eq(ik.sample_points([(0, 3), (5, 7)], 0, 10), [0, 1, 2, 5, 6], "sample_points")
|
||||
ctx.ok(ik.contains([(1, 3)], 1) and not ik.contains([(1, 3)], 3),
|
||||
"contains is half-open at both ends")
|
||||
ctx.ok(not ik.contains([], 0), "the empty set contains nothing")
|
||||
|
||||
|
||||
@check("boundary: text parse / format round-trips and rejection")
|
||||
def _c_text(ctx):
|
||||
cases = [
|
||||
("[1,3) U [5,+inf)", ((1, 3), (5, None))),
|
||||
("{}", ()),
|
||||
("empty", ()),
|
||||
("[-inf,0) | [2,3)", ((None, 0), (2, 3))),
|
||||
("[1.5,2.5)", ((1.5, 2.5),)),
|
||||
("[3,4) ; [1,2)", ((1, 2), (3, 4))),
|
||||
("[1,2) U [2,3)", ((1, 3),)),
|
||||
("[0,1) U [0,1) U [0,1)", ((0, 1),)),
|
||||
]
|
||||
for text, want in cases:
|
||||
got = ik.parse(text)
|
||||
ctx.eq(got, want, "parse(%r)" % text)
|
||||
ctx.eq(ik.parse(ik.format_set(got)), got, "round-trip through format_set for %r" % text)
|
||||
ctx.eq(ik.format_set(ik.parse("[1,3) U [5,+inf)")), "[1,3) U [5,+inf)", "format round-trip")
|
||||
ctx.eq(ik.format_set(ik.parse("[1,2) U [2,3)")), "[1,3)", "parse canonicalizes touching")
|
||||
for bad in ("[1,2]", "(1,2)", "[1,2", "1,2", "[a,b)", "[2,1)", "[+inf,3)", "[1,-inf)",
|
||||
"[]", "junk", "[1,2) [3,4)"):
|
||||
try:
|
||||
ik.parse(bad)
|
||||
except ik.IntervalError:
|
||||
ctx.sub += 1
|
||||
else:
|
||||
raise AssertionError("parse accepted invalid text %r" % (bad,))
|
||||
|
||||
|
||||
@check("points: from_points / sample_points agree with the bitmap oracle")
|
||||
def _c_points(ctx):
|
||||
rng = random.Random(SEED + 5)
|
||||
for _ in range(300):
|
||||
pts = set(rng.sample(UNIVERSE, rng.randint(0, 12)))
|
||||
s = ik.from_points(pts)
|
||||
ctx.ok(ik.is_canonical(s), "from_points(%r) is not canonical: %r" % (sorted(pts), s))
|
||||
ctx.eq(set(ik.sample_points(s, -24, 25)), pts,
|
||||
"from_points/sample_points round trip for %r" % (sorted(pts),))
|
||||
ctx.eq(set(bitmap(s)), pts, "bitmap of from_points disagrees for %r" % (sorted(pts),))
|
||||
ctx.eq(ik.measure(s), len(pts), "measure of a point set is its cardinality: %r" % (s,))
|
||||
|
||||
|
||||
@check("endpoints: no invented endpoints; float and Fraction endpoints exact")
|
||||
def _c_endpoints(ctx):
|
||||
rng = random.Random(SEED + 4)
|
||||
|
||||
def eps(a):
|
||||
return set(b for iv in ik.normalize(a) for b in iv if b is not None)
|
||||
|
||||
for _ in range(400):
|
||||
ra = [(rng.randint(-40, 40) / 4.0, rng.randint(-40, 40) / 4.0) for _ in range(rng.randint(0, 3))]
|
||||
rb = [(rng.randint(-40, 40) / 4.0, rng.randint(-40, 40) / 4.0) for _ in range(rng.randint(0, 3))]
|
||||
ra = [iv for iv in ra if iv[0] < iv[1]]
|
||||
rb = [iv for iv in rb if iv[0] < iv[1]]
|
||||
src = eps(ra) | eps(rb)
|
||||
for res in (ik.union(ra, rb), ik.intersection(ra, rb), ik.difference(ra, rb),
|
||||
ik.complement(ra), ik.normalize(ra)):
|
||||
ctx.ok(eps(res) <= src, "invented endpoint in %r from %r / %r" % (res, ra, rb))
|
||||
ctx.ok(ik.is_canonical(res), "result %r is not canonical" % (res,))
|
||||
|
||||
ctx.eq(ik.format_set(ik.union([(0.5, 1.5)], [(1.25, 2.0)])), "[0.5,2)", "float endpoints merge")
|
||||
ctx.eq(ik.measure([(0.5, 1.5)]), 1.0, "float measure")
|
||||
ctx.eq(ik.measure([(Fraction(1, 3), Fraction(2, 3))]), Fraction(1, 3),
|
||||
"measure is exact over Fractions")
|
||||
ctx.eq(ik.format_set(ik.union([(Fraction(1, 3), Fraction(1, 2))], [(Fraction(1, 2), Fraction(2, 3))])),
|
||||
"[1/3,2/3)", "Fraction endpoints merge exactly")
|
||||
ctx.eq(ik.measure(ik.difference([(Fraction(0), Fraction(1))], [(Fraction(1, 3), Fraction(2, 3))])),
|
||||
Fraction(2, 3), "difference measure over Fractions")
|
||||
|
||||
|
||||
@check("scale: sweep normalize/intersection match brute force at 1500 intervals")
|
||||
def _c_scale(ctx):
|
||||
rng = random.Random(SEED + 6)
|
||||
n, lo, hi = 1500, -300, 300
|
||||
|
||||
def raw():
|
||||
out = []
|
||||
for _ in range(n):
|
||||
a = rng.randint(lo, hi - 1)
|
||||
out.append((a, rng.randint(a + 1, hi)))
|
||||
return out
|
||||
|
||||
t0 = time.time()
|
||||
A, B = raw(), raw()
|
||||
na, nb = ik.normalize(A), ik.normalize(B)
|
||||
ba, bb = brute_bitmap(A), brute_bitmap(B)
|
||||
ctx.ok(ik.is_canonical(na) and ik.is_canonical(nb), "normalize output is not canonical at scale")
|
||||
ctx.ok(len(na) <= n and len(nb) <= n, "canonical form is longer than the input")
|
||||
ctx.eq(brute_bitmap(na), ba, "normalize at scale disagrees with brute force")
|
||||
ctx.eq(brute_bitmap(nb), bb, "normalize at scale disagrees with brute force (B)")
|
||||
ctx.eq(ik.measure(na), len(ba), "measure at scale != integer count")
|
||||
ctx.eq(ik.measure(na), sum(h - l for l, h in na), "measure at scale != sum of part lengths")
|
||||
ctx.eq(brute_bitmap(ik.union(A, B)), ba | bb, "union at scale disagrees with brute force")
|
||||
ctx.eq(brute_bitmap(ik.intersection(A, B)), ba & bb, "intersection at scale disagrees with brute force")
|
||||
ctx.eq(brute_bitmap(ik.difference(A, B)), ba - bb, "difference at scale disagrees with brute force")
|
||||
ctx.eq(brute_bitmap(ik.symmetric_difference(A, B)), ba ^ bb,
|
||||
"symmetric difference at scale disagrees with brute force")
|
||||
ctx.eq(brute_bitmap(ik.complement(na), (lo, hi)), set(range(lo, hi)) - ba,
|
||||
"complement at scale disagrees with brute force")
|
||||
ctx.note("1500 x 1500 intervals over 600 points: %d comparisons in %.2fs"
|
||||
% (ctx.sub, time.time() - t0))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. the CLI as a subprocess: exit code and stdout are the interface
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _run_cli(*args):
|
||||
exe = os.path.join(HERE, "intervalkit.py")
|
||||
return subprocess.run([sys.executable, exe, *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
@check("cli: subprocess smoke over real argv, stdout and exit codes")
|
||||
def _c_cli(ctx):
|
||||
p = _run_cli("union", "[1,3)", "[2,5)")
|
||||
ctx.eq(p.returncode, 0, "cli union rc (stderr=%r)" % p.stderr)
|
||||
ctx.eq(p.stdout.strip(), "[1,5)", "cli union stdout")
|
||||
|
||||
p = _run_cli("difference", "[1,5)", "[2,3)")
|
||||
ctx.eq(p.returncode, 0, "cli difference rc")
|
||||
ctx.eq(p.stdout.strip(), "[1,2) U [3,5)", "cli difference stdout")
|
||||
|
||||
p = _run_cli("complement", "[1,3)")
|
||||
ctx.eq(p.stdout.strip(), "[-inf,1) U [3,+inf)", "cli complement stdout")
|
||||
|
||||
p = _run_cli("symdiff", "[1,5)", "[3,7)")
|
||||
ctx.eq(p.stdout.strip(), "[1,3) U [5,7)", "cli symdiff stdout")
|
||||
|
||||
p = _run_cli("contains", "[1,3)", "2.5")
|
||||
ctx.eq(p.stdout.strip(), "true", "cli contains true")
|
||||
p = _run_cli("contains", "[1,3)", "3")
|
||||
ctx.eq(p.stdout.strip(), "false", "cli contains false at the open end")
|
||||
|
||||
p = _run_cli("measure", "[1,5)")
|
||||
ctx.eq(p.stdout.strip(), "4", "cli measure")
|
||||
p = _run_cli("measure", "[-inf,1)")
|
||||
ctx.eq(p.stdout.strip(), "inf", "cli measure of an unbounded set")
|
||||
|
||||
p = _run_cli("parse", "[-inf,0) U [2,+inf)")
|
||||
ctx.eq(p.stdout.strip(), "[-inf,0) U [2,+inf)", "cli parse")
|
||||
|
||||
p = _run_cli("union", "[1,2]")
|
||||
ctx.ok(p.returncode != 0, "cli must reject closed upper bounds")
|
||||
ctx.ok(p.stderr.strip() != "", "cli rejection must explain itself on stderr")
|
||||
p = _run_cli("frobnicate", "[1,2)")
|
||||
ctx.ok(p.returncode != 0, "cli must reject an unknown op")
|
||||
p = _run_cli("--help")
|
||||
ctx.eq(p.returncode, 0, "cli --help rc")
|
||||
ctx.ok("usage:" in p.stdout, "cli --help prints usage")
|
||||
|
||||
|
||||
@check("cli: exit code 0 on a valid op, and the value on stdout")
|
||||
def _c_cli_exit(ctx):
|
||||
p = _run_cli("intersection", "[1,10)", "[3,4)", "[6,7)")
|
||||
ctx.eq(p.returncode, 0, "cli rc")
|
||||
ctx.eq(p.stdout.strip(), "{}", "intersection of [1,10) with [3,4) and [6,7)")
|
||||
p = _run_cli("parse", "[1,2) U [2,3) U [4,5)")
|
||||
ctx.eq(p.stdout.strip(), "[1,3) U [4,5)", "cli parse canonicalizes")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. oracle self-checks (the harness must not be lying to itself)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@check("oracle self-check: in_set agrees with ik.contains; bitmaps match measures")
|
||||
def _c_oracle(ctx):
|
||||
rng = random.Random(SEED + 3)
|
||||
for _ in range(500):
|
||||
can = ik.normalize(_rand_intervals(rng, rng.randint(0, 4)))
|
||||
bm = bitmap(can)
|
||||
m = exact_measure(can)
|
||||
if m != float("inf"):
|
||||
ctx.eq(len(bm), m, "bitmap size must equal the real measure for integer endpoints: %r" % (can,))
|
||||
for x in UNIVERSE:
|
||||
ctx.ok(in_set(can, x) == ik.contains(can, x),
|
||||
"in_set and ik.contains disagree at %r for %r" % (x, can))
|
||||
ctx.eq(bitmap([(0, 1)]), frozenset({0}), "bitmap of [0,1) over the integers is {0}")
|
||||
ctx.eq(bitmap([(None, None)]), frozenset(UNIVERSE), "bitmap of the line is the universe")
|
||||
ctx.eq(bitmap([]), frozenset(), "bitmap of the empty set is empty")
|
||||
ctx.ok(in_set([(0, 2)], 1) and not in_set([(0, 2)], 2),
|
||||
"the oracle predicate is half-open")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# runner
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run_all():
|
||||
passed = failed = 0
|
||||
total_sub = 0
|
||||
for name, fn in CHECKS:
|
||||
ctx = Ctx()
|
||||
try:
|
||||
fn(ctx)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
print("FAIL %s" % name)
|
||||
for line in str(exc).splitlines():
|
||||
print(" %s" % line)
|
||||
else:
|
||||
passed += 1
|
||||
print("ok %-72s %8d assertions" % (name, ctx.sub))
|
||||
total_sub += ctx.sub
|
||||
for note in ctx.notes:
|
||||
print(" . %s" % note)
|
||||
print("-" * 90)
|
||||
print("checks: %d passed, %d failed (of %d) comparisons executed: %d"
|
||||
% (passed, failed, len(CHECKS), total_sub))
|
||||
|
||||
if len(CHECKS) < MIN_CHECKS:
|
||||
print("FAIL anti-vacuity: only %d checks registered (floor %d)" % (len(CHECKS), MIN_CHECKS))
|
||||
return 1
|
||||
if total_sub < MIN_SUBASSERTIONS:
|
||||
print("FAIL anti-vacuity: only %d comparisons executed (floor %d)"
|
||||
% (total_sub, MIN_SUBASSERTIONS))
|
||||
return 1
|
||||
if failed:
|
||||
return 1
|
||||
print("RESULT: all checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_all())
|
||||
Reference in new issue
Block a user