Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Changelog

This project follows a lightweight "keep a log" style.


## 0.1.1 - Refactoring

- **Deterministic iteration**
- All public iteration APIs (`nodes`, `edges`, `neighbors`, `successors`, `predecessors`) now yield results in a deterministic order.
- Ordering is lexicographic when objects are mutually comparable; otherwise a stable fallback order is used.

- **Read-only attribute views**
- `get_node_data()` and `get_edge_data()` now return **read-only live views** (mapping proxies) instead of mutable dicts.
- Use `set_node_attrs()` / `set_edge_attrs()` to update attributes (these bump `data_version`).

- **Edge keys**
- Auto-generated edge keys are now deterministic and local to a `(u, v)` pair (mirrors NetworkX `new_edge_key`).
- Explicit keys must be Python integers (bool is rejected).

- **New APIs**
- `edges(..., tvec=True)` can include the structural translation vector in iteration records.
- `in_neighbors(...)` and `in_neighbors_inst(...)` provide deterministic access to incoming periodic edges.
- `PeriodicGraph.check_invariants()` validates undirected pairing invariants.

- **Lattice/SNF**
- Removed the SymPy dependency by implementing exact inversion of unimodular matrices.

- **Version semantics**
- Pure data-only `data_version` semantics: `data_version` increments only on user-attribute updates that do not change structure (e.g., `set_node_attrs`, `set_edge_attrs`, or `add_node` on an existing node).
- Creating new nodes/edges with attributes does not increment `data_version` (structural change only).

- **Docs clarifications**
- Clarified that component extraction and weak-neighbor helpers rely on deterministic (stable-sorted) iteration, not insertion order.
- Documented an edge-iteration gotcha for self-loop periodic edges: use `tvec=True` with `keys=True` to disambiguate paired realizations.
- Corrected `SNFDecomposition.diag` documentation (returned length is `rank`).

- **Performance**
- Reduced redundant generator collection for undirected components by deduplicating paired directed realizations (no semantic change).

## 0.1.0 — Initial release

- First public release of **pbcgraph**, a lightweight Python library for periodic graphs built on top of **NetworkX**.
- Provides periodic graph containers with **integer translation vectors** on directed edges to represent connectivity between periodic images.
- Supports **directed/undirected** and **simple/multi** variants (NetworkX `DiGraph`/`MultiDiGraph`-style API).
- Includes core algorithms for **connected components**, **quotient shortest paths**, and basic periodic graph traversal utilities.
- Implements **periodic component** analysis (computing translation subgroup invariants via **Smith normal form**-based lattice reduction).
- Ships with initial tests and basic documentation.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ G = PeriodicGraph(dim=2)

# Undirected edges are stored internally as two directed realizations
# with tvec and -tvec.

# Self-loop periodic edges are supported (quotient bond to a periodic image):
G1 = PeriodicGraph(dim=1)
G1.add_edge('A', 'A', tvec=(1,))

G.add_edge('A', 'B', tvec=(0, 0))
G.add_edge('B', 'C', tvec=(0, 0))
G.add_edge('C', 'A', tvec=(1, 0)) # closes a periodic cycle (rank-1 along x)
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
---8<-- "CHANGELOG.md"
27 changes: 20 additions & 7 deletions docs/general/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ This gives you a precise infinite graph semantics without materializing the infi
- `u -> v` with translation `tvec`
- `v -> u` with translation `-tvec`

Both realizations share a single live attribute mapping.
Both realizations share the same underlying user-attributes dict.

Note that `get_edge_data()` returns a **read-only live view** of that mapping
(so updating attributes must be done via `set_edge_attrs()`).

### Important invariant (PeriodicGraph)

Expand All @@ -47,12 +50,22 @@ pbcgraph distinguishes two container families:
Here, the translation vector is treated as part of the edge identity.
- `PeriodicMultiDiGraph` / `PeriodicMultiGraph`: allow multiple edges per `(u, v, tvec)`.

In all cases, edges are addressed by `(u, v, key)`. Edge keys are intended to be integers.
If you explicitly provide a non-integer hashable key, it will be forwarded to the underlying NetworkX graph,
but pbcgraph only guarantees deterministic key generation and ordering for integer keys.
In all cases, edges are addressed by `(u, v, key)`.
Edge keys must be Python integers (bool is rejected).

- If you do not provide a key, pbcgraph generates a deterministic fresh integer key.
- A `key` is shared between the two realizations of an undirected edge in `PeriodicGraph` / `PeriodicMultiGraph`.
- A *base key* is shared between the two realizations of an undirected edge in `PeriodicGraph` / `PeriodicMultiGraph`.



#### Internal representation for self-loops

A crystallographically common pattern is a *quotient self-loop* with non-zero translation (\`u == v\`, \`tvec != 0\`), representing a bond to a periodic image.

NetworkX identifies edges by \`(u, v, key)\`, so the two directed realizations of such an undirected edge would collide if they shared the same key.

To support this, pbcgraph stores undirected realizations using private internal keys derived from the base key (a pair \`_UKey(base, +1)\` / \`_UKey(base, -1)\`). The public API always exposes only the base key (an int).


## Attributes and version counters

Expand All @@ -61,8 +74,8 @@ pbcgraph tracks two counters:
- `structural_version`: increments on node/edge add/remove.
- `data_version`: increments on attribute updates *performed via pbcgraph APIs*.

`get_node_data()` / `get_edge_data()` return the underlying mutable mapping.
Direct mutation of that mapping is allowed but may not update `data_version` (treat as undefined behavior).
`get_node_data()` / `get_edge_data()` return a read-only live view of the underlying mapping.
Direct mutation is not allowed; use `set_node_attrs()` / `set_edge_attrs()`.

## Components, rank, and torsion

Expand Down
5 changes: 4 additions & 1 deletion docs/general/graph_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ Typical examples:
Notes:

- `PeriodicGraph` is implemented as two directed realizations (`u -> v` with `tvec` and `v -> u` with `-tvec`)
that share one live attributes mapping.
that share the same underlying user-attributes dict. The public API returns
read-only live views of that mapping (update via `set_edge_attrs()`).
- Edge identity includes the translation vector: two edges between the same pair of nodes are allowed
if their `tvec` differ.
- Self-loop periodic edges are supported: a quotient edge with `u == v` and `tvec != 0` represents a bond to a periodic image. Internally this uses private keys derived from the base key, but the public API still exposes only integer base keys.
- When iterating edges with `keys=True`, pass `tvec=True` for self-loop periodic edges to distinguish the paired realizations; otherwise `edges(keys=True)` can yield duplicate `(u, u, key)` records.

## `PeriodicMultiGraph`

Expand Down
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ markdown_extensions:
# Math rendering (MathJax)
- pymdownx.arithmatex:
generic: true
- pymdownx.snippets:
check_paths: true

extra_javascript:
- javascripts/mathjax.js
Expand Down Expand Up @@ -65,3 +67,4 @@ nav:
- Algorithms: api/algorithms.md
- Types: api/types.md
- Exceptions: api/exceptions.md
- Changelog: changelog.md
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ classifiers = [
dependencies = [
"networkx>=3.2",
"numpy>=1.23",
"sympy>=1.12",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion src/pbcgraph/__about__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = '0.1.0'
__version__ = '0.1.1'

__all__ = [
'__version__',
Expand Down
9 changes: 9 additions & 0 deletions src/pbcgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@
G.add_node('A')
G.add_edge('A', 'A', tvec=(1,)) # A(i) -> A(i+1)
# For an undirected representation, use PeriodicGraph

# A crystallographically common pattern is a quotient self-loop with
# non-zero translation (bond to a periodic image):

from pbcgraph import PeriodicGraph

G = PeriodicGraph(dim=1)
G.add_edge('A', 'A', (1,), kind='bond')

# (adds both directions).

Algorithms
Expand Down
5 changes: 3 additions & 2 deletions src/pbcgraph/alg/_neighbors.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ def weak_neighbors(G: _SupportsPredSucc, u: NodeId) -> List[NodeId]:
The weak neighborhood treats the directed quotient graph as undirected.
Order is deterministic:

1) successors in insertion order,
2) then predecessors in insertion order (excluding nodes already yielded).
1) successors in deterministic order as provided by `G.successors(u)`,
2) then predecessors in deterministic order as provided by
`G.predecessors(u)` (excluding nodes already yielded).

Args:
G: A graph providing `successors(u)` and `predecessors(u)`.
Expand Down
7 changes: 5 additions & 2 deletions src/pbcgraph/alg/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ def components(G: PeriodicDiGraphLike) -> List[PeriodicComponent]:
G: A periodic graph container (structural protocol).

Returns:
List of PeriodicComponent objects, ordered by first-seen node in
insertion order.
List of PeriodicComponent objects in deterministic order.

The order follows the graph's deterministic node iteration
(`G.nodes(...)`), so the first component is the one containing the
smallest node under the container's stable ordering.
"""
visited: Set[NodeId] = set()
out: List[PeriodicComponent] = []
Expand Down
67 changes: 49 additions & 18 deletions src/pbcgraph/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)

from pbcgraph.core.exceptions import StaleComponentError
from pbcgraph.core.ordering import fallback_key
from pbcgraph.core.types import (
NodeId,
NodeInst,
Expand Down Expand Up @@ -266,9 +267,6 @@ def _compute_potentials(self) -> Dict[NodeId, TVec]:
pot: Dict[NodeId, TVec] = {self.root: zero_tvec(dim)}
q = deque([self.root])

# Access internal MultiDiGraph for incoming-edge data.
gnx = getattr(self.graph, '_g')

while q:
u = q.popleft()
pu = pot[u]
Expand All @@ -283,20 +281,15 @@ def _compute_potentials(self) -> Dict[NodeId, TVec]:
q.append(v)

# Incoming edges next (weak traversal).
pred_adj = gnx.pred[u]
for v in pred_adj:
for v, t_in, _k in self.graph.in_neighbors(
u, keys=True, data=False
):
if v not in self.nodes:
continue
kd = pred_adj[v]
for k in kd:
if v in pot:
break
ed = kd[k]
t_in = tuple(ed['_tvec'])
pot[v] = add_tvec(pu, neg_tvec(t_in))
q.append(v)
# If v was added via some incoming edge,
# stop scanning keys for v.
if v in pot:
continue
pot[v] = sub_tvec(pu, t_in)
q.append(v)
if len(pot) != len(self.nodes):
# This should never happen if component extraction is correct.
missing = [u for u in self.nodes if u not in pot]
Expand All @@ -308,11 +301,49 @@ def _compute_potentials(self) -> Dict[NodeId, TVec]:

def _compute_generators(self, pot: Dict[NodeId, TVec]) -> List[TVec]:
gens: List[TVec] = []
for u, v, k in self.graph.edges(keys=True, data=False):

if not self.graph.is_undirected:
for u, v, t, _k in self.graph.edges(
keys=True, data=False, tvec=True
):
if u not in self.nodes or v not in self.nodes:
continue
g = sub_tvec(add_tvec(pot[u], t), pot[v])
if _tvec_is_zero(g):
continue
gens.append(g)
return gens

def _node_leq(a: NodeId, b: NodeId) -> bool:
try:
return a <= b # type: ignore[operator]
except TypeError:
return fallback_key(a) <= fallback_key(b)

seen = set()
for u, v, t, k in self.graph.edges(keys=True, data=False, tvec=True):
if u not in self.nodes or v not in self.nodes:
continue
t = self.graph.edge_tvec(u, v, k)
g = sub_tvec(add_tvec(pot[u], t), pot[v])

tv = tuple(int(x) for x in t)
if u == v:
tv_abs = min(tv, neg_tvec(tv))
ident = (u, u, tv_abs, int(k))
if ident in seen:
continue
seen.add(ident)
g = tv_abs
else:
if _node_leq(u, v):
a, b, tv_use = u, v, tv
else:
a, b, tv_use = v, u, neg_tvec(tv)
ident = (a, b, tv_use, int(k))
if ident in seen:
continue
seen.add(ident)
g = sub_tvec(add_tvec(pot[a], tv_use), pot[b])

if _tvec_is_zero(g):
continue
gens.append(g)
Expand Down
85 changes: 85 additions & 0 deletions src/pbcgraph/core/ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Deterministic ordering helpers.

NetworkX iteration order is insertion-based and can vary with construction
sequence. pbcgraph enforces deterministic ordering at the container level.

Strategy
--------
For a collection of objects we first try native sorting (``sorted(items)``).
If objects are not mutually comparable (``TypeError``), we fall back to a
stable composite key based on type identity and ``repr``.

The fallback is deterministic provided that:

- the type's module and qualname are stable (normally true), and
- ``repr(obj)`` is stable across processes and does not embed memory
addresses.

For best cross-process determinism, prefer primitive node ids (``int``,
``str``, tuples of primitives).
"""

from __future__ import annotations

from typing import (
Any, Iterable, List, Sequence, Tuple, TypeVar,
)


T = TypeVar('T')


def fallback_key(x: Any) -> Tuple[str, str, str]:
"""Return a stable fallback key for objects that are not comparable."""
tp = type(x)
return (tp.__module__, tp.__qualname__, repr(x))


def stable_sorted(items: Iterable[T]) -> List[T]:
"""Return a deterministically sorted list.

This tries native ordering first and falls back to a composite key.
"""
seq = list(items)
try:
return sorted(seq)
except TypeError:
return sorted(seq, key=fallback_key) # type: ignore[arg-type]


def stable_unique_sorted(items: Iterable[T]) -> List[T]:
"""Return unique items in deterministic order."""
return stable_sorted(set(items))


def stable_tvec(tvec: Sequence[Any]) -> Tuple[int, ...]:
"""Canonicalize a translation vector to a tuple of Python ints."""
return tuple(int(x) for x in tvec)


def try_sort_edges(
records: List[Tuple[Any, Any, Tuple[int, ...], int, Any]],
) -> None:
"""In-place deterministic sort for edge records.

Records are ``(u, v, tvec, key, payload)``.
"""
try:
records.sort(key=lambda r: (r[0], r[1], r[2], r[3]))
except TypeError:
records.sort(
key=lambda r: (fallback_key(r[0]), fallback_key(r[1]), r[2], r[3])
)


def try_sort_neighbor_edges(
records: List[Tuple[Any, Tuple[int, ...], int, Any]],
) -> None:
"""In-place deterministic sort for neighbor-edge records.

Records are ``(v, tvec, key, payload)``.
"""
try:
records.sort(key=lambda r: (r[0], r[1], r[2]))
except TypeError:
records.sort(key=lambda r: (fallback_key(r[0]), r[1], r[2]))
Loading