464 lines
14 KiB
Python
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())
|