816 lines
34 KiB
Python
816 lines
34 KiB
Python
#!/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())
|