From 0108b97698bb54a2e0ca77d4e3b2014162136241 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:04:24 -0600 Subject: [PATCH] initial commit --- .gitignore | 10 ++++ .python-version | 1 + README.md | 0 pyproject.toml | 17 +++++++ src/daglib/__init__.py | 2 + src/daglib/dag.py | 80 ++++++++++++++++++++++++++++++ src/daglib/set.py | 109 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/daglib/__init__.py create mode 100644 src/daglib/dag.py create mode 100644 src/daglib/set.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd3d55c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "daglib" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "John Lancaster", email = "32917998+jsl12@users.noreply.github.com" } +] +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +daglib = "daglib:main" + +[build-system] +requires = ["uv_build>=0.10.2,<0.11.0"] +build-backend = "uv_build" diff --git a/src/daglib/__init__.py b/src/daglib/__init__.py new file mode 100644 index 0000000..d9d69f5 --- /dev/null +++ b/src/daglib/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from daglib!") diff --git a/src/daglib/dag.py b/src/daglib/dag.py new file mode 100644 index 0000000..6c97d5d --- /dev/null +++ b/src/daglib/dag.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable, Iterator, MutableMapping, MutableSet +from typing import DefaultDict, Generic, Set, TypeVar + +from .set import _AdjView + +T = TypeVar("T") + + +class DagAdj(Generic[T], MutableMapping[T, MutableSet[T]]): + """ + DAG adjacency map: + - g[u] -> mutable set-like view of successors of u + - g.reverse[v] -> predecessors of v + + Wrapper approach: adjacency sets are not exposed directly; callers mutate + a guarded view that keeps reverse in sync. + """ + + def __init__(self) -> None: + self._succ: DefaultDict[T, Set[T]] = defaultdict(set) + self.reverse: DefaultDict[T, Set[T]] = defaultdict(set) + + # --- MutableMapping interface --- + + def __getitem__(self, u: T) -> MutableSet[T]: + # defaultdict semantics: touch key + _ = self._succ[u] + return _AdjView(self, u) + + def __setitem__(self, u: T, vs: Iterable[T]) -> None: + view = _AdjView(self, u) + view.clear() + view |= vs # uses our in-place operator + + def __delitem__(self, u: T) -> None: + self.discard_node(u) + + def __iter__(self) -> Iterator[T]: + return iter(self._succ) + + def __len__(self) -> int: + return len(self._succ) + + def __repr__(self) -> str: + return f"DagAdj({dict(self._succ)!r})" + + # --- core edge/node operations (single source of truth) --- + + def add_edge(self, u: T, v: T) -> None: + if u == v: + raise ValueError("Self-loops are not allowed in a DAG") + + if v not in self._succ[u]: + self._succ[u].add(v) + self.reverse[v].add(u) + + # Touch keys so nodes appear if accessed later (optional but nice) + _ = self._succ[v] + _ = self.reverse[u] + + def remove_edge(self, u: T, v: T, *, missing_ok: bool = True) -> None: + if v in self._succ.get(u, ()): + self._succ[u].remove(v) + self.reverse[v].discard(u) + elif not missing_ok: + raise KeyError((u, v)) + + def discard_node(self, u: T) -> None: + # remove outgoing + for v in list(self._succ.get(u, ())): + self.remove_edge(u, v, missing_ok=True) + # remove incoming + for p in list(self.reverse.get(u, ())): + self.remove_edge(p, u, missing_ok=True) + + self._succ.pop(u, None) + self.reverse.pop(u, None) diff --git a/src/daglib/set.py b/src/daglib/set.py new file mode 100644 index 0000000..1fc09b0 --- /dev/null +++ b/src/daglib/set.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Iterable, Iterator, MutableSet, Set +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from .dag import DagAdj + + +T = TypeVar("T") + + +class _AdjView(MutableSet[T]): + """ + A mutable set-like view onto DagAdj._succ[u], with reverse-index maintenance. + + Supports all set update operators: + - |= (union_update) + - &= (intersection_update) + - -= (difference_update) + - ^= (symmetric_difference_update) + Also supports += as an alias for update for ergonomic batching. + """ + + __slots__ = ("_g", "_u") + + def __init__(self, g: DagAdj[T], u: T) -> None: + self._g = g + self._u = u + + def _raw(self) -> Set[T]: + return self._g._succ[self._u] + + # --- required MutableSet methods --- + + def __contains__(self, x: object) -> bool: + return x in self._raw() + + def __iter__(self) -> Iterator[T]: + return iter(self._raw()) + + def __len__(self) -> int: + return len(self._raw()) + + def add(self, v: T) -> None: + self._g.add_edge(self._u, v) + + def discard(self, v: T) -> None: + self._g.remove_edge(self._u, v, missing_ok=True) + + # --- convenience / correctness helpers --- + + def remove(self, v: T) -> None: + self._g.remove_edge(self._u, v, missing_ok=False) + + def clear(self) -> None: + for v in list(self._raw()): + self._g.remove_edge(self._u, v, missing_ok=True) + + def update(self, it: Iterable[T]) -> None: + for v in it: + self._g.add_edge(self._u, v) + + # --- in-place operator support --- + + def __ior__(self, other: Iterable[T]) -> _AdjView[T]: + # a |= b => add everything in other + self.update(other) + return self + + def __iand__(self, other: Iterable[T]) -> _AdjView[T]: + # a &= b => keep only those also in other + other_set = set(other) + for v in list(self._raw()): + if v not in other_set: + self._g.remove_edge(self._u, v, missing_ok=True) + return self + + def __isub__(self, other: Iterable[T]) -> _AdjView[T]: + # a -= b => remove those in other + for v in other: + self._g.remove_edge(self._u, v, missing_ok=True) + return self + + def __ixor__(self, other: Iterable[T]) -> _AdjView[T]: + # a ^= b => symmetric difference update + other_set = set(other) + raw = self._raw() + to_remove = raw & other_set + to_add = other_set - raw + + for v in to_remove: + self._g.remove_edge(self._u, v, missing_ok=True) + for v in to_add: + self._g.add_edge(self._u, v) + return self + + def __iadd__(self, other: Iterable[T]) -> _AdjView[T]: + """ + Built-in set doesn't define +=, but many folks expect it. + Treat it as 'update' (extend). + """ + self.update(other) + return self + + # --- nice repr for debugging --- + + def __repr__(self) -> str: + return repr(self._raw())