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