Files
troupe-standup/wizard-work/null/intervalkit/intervalkit.py
T

464 lines
14 KiB
Python

#!/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())