digraphx 0.5__tar.gz → 0.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {digraphx-0.5 → digraphx-0.7}/.github/workflows/multi-platforms.yml +1 -1
- {digraphx-0.5 → digraphx-0.7}/.github/workflows/python-app.yml +3 -3
- digraphx-0.7/CHANGELOG.md +70 -0
- {digraphx-0.5/src/digraphx.egg-info → digraphx-0.7}/PKG-INFO +1 -1
- digraphx-0.7/benchmarks/test_benchmarks.py +131 -0
- {digraphx-0.5 → digraphx-0.7}/docs/requirements.txt +1 -0
- {digraphx-0.5 → digraphx-0.7}/experimental/spare_tsv.py +1 -3
- {digraphx-0.5 → digraphx-0.7}/mypy.ini +3 -5
- digraphx-0.7/src/digraphx/__init__.py +36 -0
- digraphx-0.7/src/digraphx/_cycle_base.py +253 -0
- digraphx-0.7/src/digraphx/_parametric_base.py +98 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/csr_digraph.py +5 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/mcf.py +8 -26
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/min_parametric_q.py +14 -46
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/neg_cycle.py +27 -77
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/neg_cycle_q.py +43 -102
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/parametric.py +3 -36
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/tiny_digraph.py +34 -25
- {digraphx-0.5 → digraphx-0.7/src/digraphx.egg-info}/PKG-INFO +1 -1
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/SOURCES.txt +4 -1
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/scm_file_list.json +68 -65
- digraphx-0.7/src/digraphx.egg-info/scm_version.json +8 -0
- digraphx-0.7/tests/test_coverage_extras.py +546 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_min_cycle_ratio_extra.py +15 -13
- {digraphx-0.5 → digraphx-0.7}/tests/test_min_parametric_q_extra.py +20 -12
- {digraphx-0.5 → digraphx-0.7}/tests/test_neg_cycle.py +29 -0
- digraphx-0.7/tunable_parameter.md +153 -0
- digraphx-0.5/.github/workflows/ci.bak +0 -128
- digraphx-0.5/CHANGELOG.md +0 -29
- digraphx-0.5/src/digraphx/__init__.py +0 -14
- digraphx-0.5/src/digraphx.egg-info/scm_version.json +0 -8
- digraphx-0.5/tests/test_coverage_extras.py +0 -51
- {digraphx-0.5 → digraphx-0.7}/.coveragerc +0 -0
- {digraphx-0.5 → digraphx-0.7}/.github/workflows/jekyll-gh-pages.yml +0 -0
- {digraphx-0.5 → digraphx-0.7}/.github/workflows/python-publish.yml +0 -0
- {digraphx-0.5 → digraphx-0.7}/.gitignore +0 -0
- {digraphx-0.5 → digraphx-0.7}/.isort.cfg +0 -0
- {digraphx-0.5 → digraphx-0.7}/.pre-commit-config.yaml +0 -0
- {digraphx-0.5 → digraphx-0.7}/.readthedocs.yml +0 -0
- {digraphx-0.5 → digraphx-0.7}/.vscode/settings.json +0 -0
- {digraphx-0.5 → digraphx-0.7}/AGENTS.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/AUTHORS.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/CONTRIBUTING.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/GEMINI.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/LICENSE.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/README.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/coverage.json +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/Makefile +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/_static/.gitignore +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/authors.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/changelog.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/conf.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/contributing.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/examples/plot_cycle_detection.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/examples/plot_tiny_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/figures_demo.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/index.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/license.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/docs/readme.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/environment.yml +0 -0
- {digraphx-0.5 → digraphx-0.7}/experimental/__init__.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experimental/test_spare_tsv.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/experi-descent-new.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/experi-descent-old.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/experi-descent.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/experi.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/plot_node_colormap.ipynb +0 -0
- {digraphx-0.5 → digraphx-0.7}/experiments/plot_node_colormap.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/note.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/pyproject.toml +0 -0
- {digraphx-0.5 → digraphx-0.7}/requirements/README.md +0 -0
- {digraphx-0.5 → digraphx-0.7}/requirements/default.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/requirements/doc.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/requirements/test.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/requirements.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/setup.cfg +0 -0
- {digraphx-0.5 → digraphx-0.7}/setup.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/min_cycle_ratio.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx/py.typed +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/dependency_links.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/not-zip-safe +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/requires.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/src/digraphx.egg-info/top_level.txt +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/__init__.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/conftest.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_csr_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_cycle_ratio.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_delay_padding_example_howard.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_edge_cases.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_howard_cycle_cancellation.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_mcf.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_min_cycle_ratio_property.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_min_parametric_q.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_neg_cycle_q.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_neg_cycle_q_simple_timing.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_neg_cycle_simple_timing.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_parametric.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_stress.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_timing_example.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_timing_example_howard.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_tiny_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tests/test_tiny_digraph_property.py +0 -0
- {digraphx-0.5 → digraphx-0.7}/tox.ini +0 -0
|
@@ -14,9 +14,9 @@ jobs:
|
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
|
|
16
16
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
18
|
- name: Set up Python 3.10
|
|
19
|
-
uses: actions/setup-python@
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
20
|
with:
|
|
21
21
|
python-version: "3.10"
|
|
22
22
|
- name: Install dependencies
|
|
@@ -37,7 +37,7 @@ jobs:
|
|
|
37
37
|
- name: Codecov
|
|
38
38
|
# You may pin to the exact commit or the version.
|
|
39
39
|
# uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192
|
|
40
|
-
uses: codecov/codecov-action@
|
|
40
|
+
uses: codecov/codecov-action@v4
|
|
41
41
|
with:
|
|
42
42
|
# Repository upload token - get it from codecov.io. Required only for private repositories
|
|
43
43
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Version 0.7 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Optional `max_iter` cap for Howard's method**: `howard` / `howard_pred` / `howard_succ` accept an optional `max_iter` (default `None` = unbounded); a set cap raises `RuntimeError` instead of looping forever on degenerate graphs. (#8669cdd)
|
|
7
|
+
|
|
8
|
+
### Performance
|
|
9
|
+
- **Fast-path `TinyDiGraph.add_edge`**: Lazy-slot resolution avoids unnecessary work on edge insertion. (#a7ed9c3)
|
|
10
|
+
- **Pre-weighted edge list reuse**: Howard relaxation reuses the pre-weighted edge list instead of recomputing it. (#da93374)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
- **`cheat_adjlist_outer_dict` doctest**: Aligned the doctest with the lazy-sentinel behavior. (#20e7c11)
|
|
14
|
+
|
|
15
|
+
### Testing & Code Quality
|
|
16
|
+
- **Runtime benchmarks**: Added benchmarks for the core graph algorithms. (#0c7329a)
|
|
17
|
+
- **Neg-cycle tests**: Added tests covering the `max_iter` cap. (#8669cdd)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- **Tunable parameter reference**: Added `tunable_parameter.md`. (#9e0639b)
|
|
21
|
+
|
|
22
|
+
## Version 0.6 (2026-09-04)
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
- **Shared cycle/parametric bases**: Extracted `_cycle_base.py` (`find_cycle`, `relax_pred`, `relax_succ`, `cycle_list`, `is_negative`, `howard_search`) and `_parametric_base.py` (`_run_loop`) shared by the max/min parametric solvers. `NegCycleFinder`/`NegCycleFinderQ` now delegate to the shared skeleton, `MinParametricAPI` subclasses `ParametricAPI` (was a duplicate interface), and `mcf.py` reuses `_residual_edge`. Public API unchanged. (#1c60bde)
|
|
26
|
+
- **Public re-exports in `__init__.py`**: Fixes README quick-start imports such as `NegCycleFinder`, `TinyDiGraph`, `MinCycleRatioSolver`. (#1c60bde)
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
- **mypy type errors**: Resolved None guards, test generics and networkx config issues. (#13dc943)
|
|
30
|
+
- **RTD doc build**: Added matplotlib and numpy to `docs/requirements.txt`. (#1f49779)
|
|
31
|
+
|
|
32
|
+
### Testing & Code Quality
|
|
33
|
+
- **Coverage raised 85%→99%**: Added coverage extras for MCF solver, TinyDiGraph and solvers. (#2cabc4e)
|
|
34
|
+
- **Style pass**: Reformatted list comprehensions and type annotations. (#2973013)
|
|
35
|
+
|
|
36
|
+
### Code Cleanup
|
|
37
|
+
- **Removed AI slop**: Stripped boilerplate from docstrings and comments. (#be4456a)
|
|
38
|
+
- **Import tidy-up**: Dropped unused `NegCycleFinderQ` import in `min_parametric_q`, split multi-name `_cycle_base` imports, stripped stray blank lines in `tiny_digraph`. (#5e37f7c)
|
|
39
|
+
|
|
40
|
+
### Build & CI
|
|
41
|
+
- **Removed stale `.bak` workflow**: Deleted `ci.bak`. (#5f50fe3)
|
|
42
|
+
- **Updated GitHub Actions**: checkout→v4, setup-python→v5, codecov-action→v4 to fix Codecov tokenless upload failure and Node 20 deprecation. (#5334192)
|
|
43
|
+
|
|
44
|
+
## Version 0.5 (2026-07-16)
|
|
45
|
+
|
|
46
|
+
### Features
|
|
47
|
+
- **MCF solver**: Added min-cost flow cycle-cancellation solver (`mcf.py`) and `NegCycleFinderQ` with `VertexFilter` for vertex-disjoint MCF constraint. (#582b1eb, #e6e248f)
|
|
48
|
+
- **CSRDiGraph**: Memory-efficient CSR-backed directed graph with `_CSRNeighbors` Mapping view — compact integer arrays instead of dicts. (#12465a3)
|
|
49
|
+
- **Bellman-Ford cycle detection**: Replaced Howard's algorithm with Bellman-Ford for negative cycle detection; fixed parallel edge bug. (#04da700)
|
|
50
|
+
- **spareTSV integration**: Added spare TSV network flow utilities and unified variable naming to `utx`/`vtx` (C++ convention). (#762008a)
|
|
51
|
+
- **Moved spare_tsv to experimental/**: Relocated matplotlib-dependent spare_tsv module out of main package to avoid CI failures when matplotlib is not installed. (#7367e6c)
|
|
52
|
+
|
|
53
|
+
### Performance
|
|
54
|
+
- **Lazy TinyDiGraph/CSRDiGraph**: Avoid pre-allocating 2N dicts per `init_nodes()` call (~464 MB saved for 1M nodes). (#12465a3)
|
|
55
|
+
- **Incremental residual updates**: Update only edges affected by cycle cancellation instead of rebuilding full residual each iteration. (#12465a3)
|
|
56
|
+
|
|
57
|
+
### Documentation
|
|
58
|
+
- **plot_directive and svgbob**: Enabled matplotlib plot_directive for auto-generated figures. Added TinyDiGraph and cycle detection example plots. Added svgbob diagram to TinyDiGraph class docstring. (#b64d424)
|
|
59
|
+
|
|
60
|
+
### Testing & Code Quality
|
|
61
|
+
- **Coverage raised 70%→95%**: Added extensive test coverage including MCF solver, CSRDiGraph, edge cases, and extra min-cycle-ratio tests. (#80da2a2, #9030d8a)
|
|
62
|
+
|
|
63
|
+
### Code Cleanup
|
|
64
|
+
- **Removed PyScaffold boilerplate**: Deleted `skeleton.py` and `test_skeleton.py`. (#9bdbdb1)
|
|
65
|
+
- **Dropped Python < 3.9 compat**: Removed `importlib-metadata` conditional dependency. (#9bdbdb1)
|
|
66
|
+
- **Config cleanup**: Removed dead entry points, unused mypy ignores, stale `IFLOW.md` and duplicate `LICENSE`. (#9bdbdb1)
|
|
67
|
+
|
|
68
|
+
### Build & CI
|
|
69
|
+
- **CI repair**: Fixed broken entry_points and remaining skeleton imports. (#91fd90e)
|
|
70
|
+
- **flake8 cleanup**: Removed unused imports and fixed formatting warnings. (#91d7141)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Runtime benchmarks for digraphx core algorithms.
|
|
2
|
+
|
|
3
|
+
Run explicitly (they are outside the default ``testpaths`` so ``pytest`` alone
|
|
4
|
+
skips them):
|
|
5
|
+
|
|
6
|
+
pytest benchmarks/ --benchmark-only
|
|
7
|
+
|
|
8
|
+
Uses fixed seeds so numbers are comparable across runs and across machines.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
|
|
14
|
+
import networkx as nx
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from digraphx.min_cycle_ratio import MinCycleRatioSolver, set_default
|
|
18
|
+
from digraphx.neg_cycle import NegCycleFinder
|
|
19
|
+
from digraphx.tiny_digraph import DiGraphAdapter, TinyDiGraph
|
|
20
|
+
|
|
21
|
+
SEED = 1234
|
|
22
|
+
SIZES = [200, 1000]
|
|
23
|
+
MCR_SIZES = [50, 100]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def weighted_graph(n: int, deg: int, seed: int = SEED) -> dict:
|
|
27
|
+
rng = random.Random(seed)
|
|
28
|
+
g: dict = {i: {} for i in range(n)}
|
|
29
|
+
for i in range(n):
|
|
30
|
+
for _ in range(deg):
|
|
31
|
+
j = rng.randrange(n)
|
|
32
|
+
if j != i:
|
|
33
|
+
g[i][j] = rng.randint(1, 9)
|
|
34
|
+
for i in range(n - 1):
|
|
35
|
+
g[i][i + 1] = 1
|
|
36
|
+
g[n - 1][0] = -n
|
|
37
|
+
return g
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ratio_graph(n: int, deg: int, seed: int = SEED) -> DiGraphAdapter:
|
|
41
|
+
rng = random.Random(seed)
|
|
42
|
+
g = DiGraphAdapter()
|
|
43
|
+
g.add_nodes_from(range(n))
|
|
44
|
+
for i in range(n):
|
|
45
|
+
for _ in range(deg):
|
|
46
|
+
j = rng.randrange(n)
|
|
47
|
+
if j != i:
|
|
48
|
+
g.add_edge(i, j, cost=rng.randint(-5, 9), time=rng.randint(1, 5))
|
|
49
|
+
return g
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("n", SIZES)
|
|
53
|
+
def test_bench_tiny_build(benchmark, n: int) -> None:
|
|
54
|
+
deg = 5
|
|
55
|
+
|
|
56
|
+
def build() -> TinyDiGraph:
|
|
57
|
+
g = TinyDiGraph()
|
|
58
|
+
g.init_nodes(n)
|
|
59
|
+
for i in range(n):
|
|
60
|
+
for j in range(1, deg + 1):
|
|
61
|
+
g.add_edge(i, (i + j) % n, weight=1)
|
|
62
|
+
return g
|
|
63
|
+
|
|
64
|
+
benchmark(build)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("n", SIZES)
|
|
68
|
+
def test_bench_adapter_build(benchmark, n: int) -> None:
|
|
69
|
+
deg = 5
|
|
70
|
+
|
|
71
|
+
def build() -> DiGraphAdapter:
|
|
72
|
+
g = DiGraphAdapter()
|
|
73
|
+
g.add_nodes_from(range(n))
|
|
74
|
+
for i in range(n):
|
|
75
|
+
for j in range(1, deg + 1):
|
|
76
|
+
g.add_edge(i, (i + j) % n, weight=1)
|
|
77
|
+
return g
|
|
78
|
+
|
|
79
|
+
benchmark(build)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("n", SIZES)
|
|
83
|
+
def test_bench_howard_negative_cycle(benchmark, n: int) -> None:
|
|
84
|
+
g = weighted_graph(n, 6)
|
|
85
|
+
finder = NegCycleFinder(g)
|
|
86
|
+
|
|
87
|
+
def run() -> None:
|
|
88
|
+
dist = {v: 0 for v in g}
|
|
89
|
+
list(finder.howard(dist, lambda e: e))
|
|
90
|
+
|
|
91
|
+
benchmark(run)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("n", SIZES)
|
|
95
|
+
def test_bench_networkx_negative_edge_cycle(benchmark, n: int) -> None:
|
|
96
|
+
g = weighted_graph(n, 6)
|
|
97
|
+
h = nx.DiGraph()
|
|
98
|
+
for u, nb in g.items():
|
|
99
|
+
h.add_node(u)
|
|
100
|
+
for v, w in nb.items():
|
|
101
|
+
h.add_edge(u, v, weight=w)
|
|
102
|
+
|
|
103
|
+
benchmark(lambda: nx.negative_edge_cycle(h, weight="weight"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("n", MCR_SIZES)
|
|
107
|
+
def test_bench_mcr_fraction(benchmark, n: int) -> None:
|
|
108
|
+
g = ratio_graph(n, 5)
|
|
109
|
+
set_default(g, "cost", 0)
|
|
110
|
+
set_default(g, "time", 1)
|
|
111
|
+
solver = MinCycleRatioSolver(g)
|
|
112
|
+
|
|
113
|
+
def run():
|
|
114
|
+
dist = {v: Fraction(0) for v in g}
|
|
115
|
+
return solver.run(dist, Fraction(10))
|
|
116
|
+
|
|
117
|
+
benchmark(run)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize("n", MCR_SIZES)
|
|
121
|
+
def test_bench_mcr_float(benchmark, n: int) -> None:
|
|
122
|
+
g = ratio_graph(n, 5)
|
|
123
|
+
set_default(g, "cost", 0)
|
|
124
|
+
set_default(g, "time", 1)
|
|
125
|
+
solver = MinCycleRatioSolver(g)
|
|
126
|
+
|
|
127
|
+
def run():
|
|
128
|
+
dist = {v: 0.0 for v in g}
|
|
129
|
+
return solver.run(dist, 10.0)
|
|
130
|
+
|
|
131
|
+
benchmark(run)
|
|
@@ -97,9 +97,7 @@ def showPaths(gra, pos, N, edgeProbs=1.0, path=None, visibleNodes=None, guards=N
|
|
|
97
97
|
# only display edges between non-dummy nodes
|
|
98
98
|
edge_list = list(gra.edges())
|
|
99
99
|
visibleEdges = [
|
|
100
|
-
e
|
|
101
|
-
for e in edge_list
|
|
102
|
-
if e[0] in visibleNodes and e[1] in visibleNodes
|
|
100
|
+
e for e in edge_list if e[0] in visibleNodes and e[1] in visibleNodes
|
|
103
101
|
]
|
|
104
102
|
|
|
105
103
|
if isinstance(edgeProbs, float):
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
[mypy]
|
|
2
2
|
python_version = 3.12
|
|
3
3
|
warn_unused_configs = True
|
|
4
|
+
disable_error_code = import-untyped
|
|
4
5
|
|
|
5
6
|
[mypy-setuptools.*]
|
|
6
7
|
ignore_missing_imports = True
|
|
7
8
|
|
|
9
|
+
[mypy-matplotlib.*]
|
|
8
10
|
ignore_missing_imports = True
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
ignore_missing_imports = True
|
|
13
|
-
|
|
14
|
-
[mypy-matplotlib.*]
|
|
12
|
+
[mypy-networkx.*]
|
|
15
13
|
ignore_missing_imports = True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if sys.version_info[:2] >= (3, 8):
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version # pragma: no cover
|
|
5
|
+
else:
|
|
6
|
+
from importlib_metadata import PackageNotFoundError, version # pragma: no cover
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
dist_name = __name__
|
|
10
|
+
__version__ = version(dist_name)
|
|
11
|
+
except PackageNotFoundError: # pragma: no cover
|
|
12
|
+
__version__ = "unknown"
|
|
13
|
+
finally:
|
|
14
|
+
del version, PackageNotFoundError
|
|
15
|
+
|
|
16
|
+
# Public API re-exports (matching the README quick-start examples)
|
|
17
|
+
from .csr_digraph import CSRDiGraph # noqa: E402
|
|
18
|
+
from .mcf import cycle_canceling_mcf # noqa: E402
|
|
19
|
+
from .min_cycle_ratio import MinCycleRatioSolver # noqa: E402
|
|
20
|
+
from .min_parametric_q import MinParametricSolver # noqa: E402
|
|
21
|
+
from .neg_cycle import NegCycleFinder # noqa: E402
|
|
22
|
+
from .neg_cycle_q import NegCycleFinderQ # noqa: E402
|
|
23
|
+
from .parametric import MaxParametricSolver # noqa: E402
|
|
24
|
+
from .tiny_digraph import DiGraphAdapter, TinyDiGraph # noqa: E402
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CSRDiGraph",
|
|
28
|
+
"DiGraphAdapter",
|
|
29
|
+
"MaxParametricSolver",
|
|
30
|
+
"MinCycleRatioSolver",
|
|
31
|
+
"MinParametricSolver",
|
|
32
|
+
"NegCycleFinder",
|
|
33
|
+
"NegCycleFinderQ",
|
|
34
|
+
"TinyDiGraph",
|
|
35
|
+
"cycle_canceling_mcf",
|
|
36
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Shared implementation cores for the negative-cycle finders.
|
|
2
|
+
|
|
3
|
+
This module centralises the graph-traversal helpers and the Howard
|
|
4
|
+
policy-iteration skeleton (Template Method) used by :class:`NegCycleFinder`
|
|
5
|
+
(neg_cycle.py) and :class:`NegCycleFinderQ` (neg_cycle_q.py). The relaxation
|
|
6
|
+
direction and the optional negativity check are injected as strategies.
|
|
7
|
+
|
|
8
|
+
It is an implementation detail of the package; the public API lives in
|
|
9
|
+
``neg_cycle`` / ``neg_cycle_q`` and is unchanged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import (
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
Generator,
|
|
17
|
+
List,
|
|
18
|
+
Mapping,
|
|
19
|
+
MutableMapping,
|
|
20
|
+
Optional,
|
|
21
|
+
Tuple,
|
|
22
|
+
TypeVar,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Type variables for generic graph components
|
|
26
|
+
Node = TypeVar("Node") # Hashable node type (must implement __hash__)
|
|
27
|
+
Arc = TypeVar("Arc") # Hashable edge type (must implement __hash__)
|
|
28
|
+
Domain = TypeVar(
|
|
29
|
+
"Domain", int, Fraction, float
|
|
30
|
+
) # Numeric type for weights (must support comparison and arithmetic)
|
|
31
|
+
Cycle = List[Arc] # Alias for a list of edges forming a cycle
|
|
32
|
+
|
|
33
|
+
# A point-to map: node -> (predecessor/successor node, connecting edge)
|
|
34
|
+
PointTo = Dict[Node, Tuple[Node, Arc]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _always_true(old: Domain, new: Domain) -> bool:
|
|
38
|
+
"""Default `update_ok` gate: allow every distance update."""
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_cycle(
|
|
43
|
+
digraph: Mapping[Node, Mapping[Node, Arc]], point_to: PointTo
|
|
44
|
+
) -> Generator[Node, None, None]:
|
|
45
|
+
"""Yield each node that starts a cycle in the given point-to map.
|
|
46
|
+
|
|
47
|
+
Uses a coloring algorithm (white/gray/black) to detect cycles: white nodes
|
|
48
|
+
are unvisited, gray nodes are being visited in the current DFS path, and
|
|
49
|
+
black nodes are fully visited. A cycle is found when a node in the current
|
|
50
|
+
path is reached again.
|
|
51
|
+
"""
|
|
52
|
+
visited: Dict[Node, Node] = {} # Maps nodes to their DFS root
|
|
53
|
+
for vtx in filter(lambda vtx: vtx not in visited, digraph):
|
|
54
|
+
utx = vtx
|
|
55
|
+
while True:
|
|
56
|
+
visited[utx] = vtx # Mark as visited with current DFS root
|
|
57
|
+
if utx not in point_to:
|
|
58
|
+
break # Reached a leaf node
|
|
59
|
+
utx, _ = point_to[utx] # Move to predecessor/successor
|
|
60
|
+
if utx in visited:
|
|
61
|
+
if visited[utx] == vtx: # Found cycle back to current root
|
|
62
|
+
yield utx
|
|
63
|
+
break # Cycle or different DFS tree
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def relax_pred(
|
|
67
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
68
|
+
dist: MutableMapping[Node, Domain],
|
|
69
|
+
get_weight: Callable[[Arc], Domain],
|
|
70
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
71
|
+
pred: PointTo,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""Perform one predecessor relaxation pass (Bellman-Ford style).
|
|
74
|
+
|
|
75
|
+
Updates ``dist[v]`` and the predecessor map when the triangle inequality
|
|
76
|
+
``dist[v] > dist[u] + weight(u, v)`` holds AND ``update_ok`` permits it.
|
|
77
|
+
Returns ``True`` if any distance was updated.
|
|
78
|
+
"""
|
|
79
|
+
changed = False
|
|
80
|
+
for utx, neighbors in digraph.items():
|
|
81
|
+
for vtx, edge in neighbors.items():
|
|
82
|
+
distance = dist[utx] + get_weight(edge)
|
|
83
|
+
if dist[vtx] > distance and update_ok(dist[vtx], distance):
|
|
84
|
+
dist[vtx] = distance
|
|
85
|
+
pred[vtx] = (utx, edge) # Update predecessor
|
|
86
|
+
changed = True
|
|
87
|
+
return changed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def relax_succ(
|
|
91
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
92
|
+
dist: MutableMapping[Node, Domain],
|
|
93
|
+
get_weight: Callable[[Arc], Domain],
|
|
94
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
95
|
+
succ: PointTo,
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""Perform one successor relaxation pass (reverse Bellman-Ford style).
|
|
98
|
+
|
|
99
|
+
Updates ``dist[u]`` and the successor map when the triangle inequality
|
|
100
|
+
``dist[u] < dist[v] - weight(u, v)`` holds AND ``update_ok`` permits it.
|
|
101
|
+
Returns ``True`` if any distance was updated.
|
|
102
|
+
"""
|
|
103
|
+
changed = False
|
|
104
|
+
for utx, neighbors in digraph.items():
|
|
105
|
+
for vtx, edge in neighbors.items():
|
|
106
|
+
distance = dist[vtx] - get_weight(edge)
|
|
107
|
+
if dist[utx] < distance and update_ok(dist[utx], distance):
|
|
108
|
+
dist[utx] = distance
|
|
109
|
+
succ[utx] = (vtx, edge) # Update successor
|
|
110
|
+
changed = True
|
|
111
|
+
return changed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepare_edges(
|
|
115
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
116
|
+
get_weight: Callable[[Arc], Domain],
|
|
117
|
+
) -> List[Tuple[Node, Node, Arc, Domain]]:
|
|
118
|
+
"""Flatten the graph into ``(u, v, edge, weight)`` tuples.
|
|
119
|
+
|
|
120
|
+
``get_weight`` is evaluated exactly once per edge. Callers that keep the
|
|
121
|
+
ratio (and hence every edge weight) fixed for the whole search can reuse the
|
|
122
|
+
result across many relaxation passes instead of recomputing it each time.
|
|
123
|
+
"""
|
|
124
|
+
return [
|
|
125
|
+
(utx, vtx, edge, get_weight(edge))
|
|
126
|
+
for utx, neighbors in digraph.items()
|
|
127
|
+
for vtx, edge in neighbors.items()
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def relax_pred_flat(
|
|
132
|
+
edges: List[Tuple[Node, Node, Arc, Domain]],
|
|
133
|
+
dist: MutableMapping[Node, Domain],
|
|
134
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
135
|
+
pred: PointTo,
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""Predecessor relaxation over a pre-flattened, pre-weighted edge list."""
|
|
138
|
+
changed = False
|
|
139
|
+
for utx, vtx, edge, weight in edges:
|
|
140
|
+
distance = dist[utx] + weight
|
|
141
|
+
if dist[vtx] > distance and update_ok(dist[vtx], distance):
|
|
142
|
+
dist[vtx] = distance
|
|
143
|
+
pred[vtx] = (utx, edge)
|
|
144
|
+
changed = True
|
|
145
|
+
return changed
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def relax_succ_flat(
|
|
149
|
+
edges: List[Tuple[Node, Node, Arc, Domain]],
|
|
150
|
+
dist: MutableMapping[Node, Domain],
|
|
151
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
152
|
+
succ: PointTo,
|
|
153
|
+
) -> bool:
|
|
154
|
+
"""Successor relaxation over a pre-flattened, pre-weighted edge list."""
|
|
155
|
+
changed = False
|
|
156
|
+
for utx, vtx, edge, weight in edges:
|
|
157
|
+
distance = dist[vtx] - weight
|
|
158
|
+
if dist[utx] < distance and update_ok(dist[utx], distance):
|
|
159
|
+
dist[utx] = distance
|
|
160
|
+
succ[utx] = (vtx, edge)
|
|
161
|
+
changed = True
|
|
162
|
+
return changed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cycle_list(point_to: PointTo, handle: Node) -> Cycle:
|
|
166
|
+
"""Reconstruct the cycle starting from ``handle`` in the point-to map.
|
|
167
|
+
|
|
168
|
+
Follows predecessor/successor links until returning to the starting node.
|
|
169
|
+
"""
|
|
170
|
+
vtx = handle
|
|
171
|
+
cycle = list()
|
|
172
|
+
while True:
|
|
173
|
+
utx, edge = point_to[vtx] # Get next node and connecting edge
|
|
174
|
+
cycle.append(edge) # Add edge to cycle
|
|
175
|
+
vtx = utx # Move to next node
|
|
176
|
+
if vtx == handle: # Completed the cycle
|
|
177
|
+
break
|
|
178
|
+
return cycle
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_negative(
|
|
182
|
+
point_to: PointTo,
|
|
183
|
+
handle: Node,
|
|
184
|
+
dist: MutableMapping[Node, Domain],
|
|
185
|
+
get_weight: Callable[[Arc], Domain],
|
|
186
|
+
) -> bool:
|
|
187
|
+
"""Return ``True`` if the cycle starting at ``handle`` is negative.
|
|
188
|
+
|
|
189
|
+
A cycle is negative if at least one edge (u, v) on it violates the
|
|
190
|
+
triangle inequality ``dist[v] > dist[u] + weight(u, v)``.
|
|
191
|
+
"""
|
|
192
|
+
vtx = handle
|
|
193
|
+
# C-style do-while loop
|
|
194
|
+
while True:
|
|
195
|
+
utx, edge = point_to[vtx]
|
|
196
|
+
if dist[vtx] > dist[utx] + get_weight(edge): # Found negative cycle
|
|
197
|
+
return True
|
|
198
|
+
vtx = utx
|
|
199
|
+
if vtx == handle: # Completed full cycle
|
|
200
|
+
break
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def howard_search(
|
|
205
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
206
|
+
dist: MutableMapping[Node, Domain],
|
|
207
|
+
get_weight: Callable[[Arc], Domain],
|
|
208
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
209
|
+
point_to: PointTo,
|
|
210
|
+
direction: str,
|
|
211
|
+
verify: bool = True,
|
|
212
|
+
max_iter: Optional[int] = None,
|
|
213
|
+
) -> Generator[Cycle, None, None]:
|
|
214
|
+
"""Template Method: Howard's policy-iteration skeleton.
|
|
215
|
+
|
|
216
|
+
Repeatedly relaxes the distance estimates until no improvement is made or a
|
|
217
|
+
cycle is found, then yields each detected cycle as a list of edge weights.
|
|
218
|
+
|
|
219
|
+
The relaxation pass and the point-to map are selected by ``direction``
|
|
220
|
+
(``"pred"`` for predecessor relaxation, ``"succ"`` for successor). When
|
|
221
|
+
``verify`` is ``True``, each candidate cycle is asserted to be negative
|
|
222
|
+
before being yielded (matching the predecessor variants).
|
|
223
|
+
|
|
224
|
+
``max_iter`` optionally caps the number of relaxation rounds. ``None``
|
|
225
|
+
(default) means unbounded; when set and exceeded without finding a negative
|
|
226
|
+
cycle, ``RuntimeError`` is raised rather than looping forever.
|
|
227
|
+
"""
|
|
228
|
+
point_to.clear()
|
|
229
|
+
found = False
|
|
230
|
+
num_iter = 0
|
|
231
|
+
edges = prepare_edges(digraph, get_weight)
|
|
232
|
+
if direction == "pred":
|
|
233
|
+
|
|
234
|
+
def relax() -> bool:
|
|
235
|
+
return relax_pred_flat(edges, dist, update_ok, point_to)
|
|
236
|
+
|
|
237
|
+
else:
|
|
238
|
+
|
|
239
|
+
def relax() -> bool:
|
|
240
|
+
return relax_succ_flat(edges, dist, update_ok, point_to)
|
|
241
|
+
|
|
242
|
+
while not found and relax():
|
|
243
|
+
num_iter += 1
|
|
244
|
+
for vtx in find_cycle(digraph, point_to):
|
|
245
|
+
if verify:
|
|
246
|
+
# Safety check - verify the cycle is indeed negative
|
|
247
|
+
assert is_negative(point_to, vtx, dist, get_weight)
|
|
248
|
+
found = True
|
|
249
|
+
yield cycle_list(point_to, vtx)
|
|
250
|
+
if not found and max_iter is not None and num_iter >= max_iter:
|
|
251
|
+
raise RuntimeError(
|
|
252
|
+
f"howard_search did not converge within max_iter={max_iter}"
|
|
253
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Shared implementation core for the parametric solvers.
|
|
2
|
+
|
|
3
|
+
This module centralises the parametric-search loop (Template Method) shared by
|
|
4
|
+
:class:`MaxParametricSolver` (parametric.py) and :class:`MinParametricSolver`
|
|
5
|
+
(min_parametric_q.py). The ratio-comparison direction and the cycle-search
|
|
6
|
+
direction alternation are injected as strategies.
|
|
7
|
+
|
|
8
|
+
It is an implementation detail of the package; the public API lives in
|
|
9
|
+
``parametric`` / ``min_parametric_q`` and is unchanged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Callable, Generic, Mapping, MutableMapping, Tuple, TypeVar
|
|
13
|
+
|
|
14
|
+
from .neg_cycle import Arc, Cycle, Domain, NegCycleFinder, Node
|
|
15
|
+
from .neg_cycle_q import NegCycleFinderQ
|
|
16
|
+
|
|
17
|
+
Ratio = TypeVar("Ratio", int, float) # Comparable Ring
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _API(Generic[Node, Arc, Ratio]):
|
|
21
|
+
"""Protocol-ish base for the parametric API strategies."""
|
|
22
|
+
|
|
23
|
+
def distance(self, ratio: Ratio, edge: Arc) -> Ratio:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
def zero_cancel(self, cycle: Cycle) -> Ratio:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_loop(
|
|
31
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
32
|
+
omega: _API,
|
|
33
|
+
dist: MutableMapping[Node, Domain],
|
|
34
|
+
ratio: Ratio,
|
|
35
|
+
*,
|
|
36
|
+
minimize: bool,
|
|
37
|
+
update_ok: Callable[[Domain, Domain], bool] = lambda old, new: True,
|
|
38
|
+
pick_one_only: bool = False,
|
|
39
|
+
alternate_direction: bool = False,
|
|
40
|
+
) -> Tuple[Ratio, Cycle]:
|
|
41
|
+
"""Template Method: the parametric-search loop.
|
|
42
|
+
|
|
43
|
+
Repeatedly finds negative cycles at the current ratio and updates the ratio
|
|
44
|
+
until no improving cycle exists. ``minimize`` selects the comparison
|
|
45
|
+
direction; ``alternate_direction`` (with ``update_ok``) enables the
|
|
46
|
+
predecessor/successor alternation used by the constrained solver.
|
|
47
|
+
|
|
48
|
+
Returns ``(final_ratio, critical_cycle)``.
|
|
49
|
+
"""
|
|
50
|
+
if not dist: # empty graph case - return early with no cycle found
|
|
51
|
+
return ratio, []
|
|
52
|
+
|
|
53
|
+
def get_weight(e: Arc) -> Domain:
|
|
54
|
+
return omega.distance(ratio, e)
|
|
55
|
+
|
|
56
|
+
# Initialize min/max ratio and cycle
|
|
57
|
+
ratio_best = ratio
|
|
58
|
+
cycle_best = []
|
|
59
|
+
cycle = []
|
|
60
|
+
reverse: bool = True # Flag to alternate search direction
|
|
61
|
+
|
|
62
|
+
if alternate_direction:
|
|
63
|
+
ncf: NegCycleFinderQ[Node, Arc, Domain] = NegCycleFinderQ(digraph)
|
|
64
|
+
else:
|
|
65
|
+
ncf = NegCycleFinder(digraph)
|
|
66
|
+
|
|
67
|
+
# Main algorithm loop
|
|
68
|
+
while True:
|
|
69
|
+
if alternate_direction:
|
|
70
|
+
# Search for cycles in either forward or reverse direction
|
|
71
|
+
cycles = (
|
|
72
|
+
ncf.howard_succ(dist, get_weight, update_ok)
|
|
73
|
+
if reverse
|
|
74
|
+
else ncf.howard_pred(dist, get_weight, update_ok)
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
cycles = ncf.howard(dist, get_weight)
|
|
78
|
+
|
|
79
|
+
# Evaluate all found cycles
|
|
80
|
+
for c_i in cycles:
|
|
81
|
+
ratio_i = omega.zero_cancel(c_i)
|
|
82
|
+
if (ratio_best > ratio_i) if minimize else (ratio_best < ratio_i):
|
|
83
|
+
ratio_best = ratio_i
|
|
84
|
+
cycle_best = c_i
|
|
85
|
+
if pick_one_only: # Early exit if we only need one improvement
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
# Termination condition: no better ratio found
|
|
89
|
+
if (ratio_best >= ratio) if minimize else (ratio_best <= ratio):
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
# Update state for next iteration
|
|
93
|
+
cycle = cycle_best
|
|
94
|
+
ratio = ratio_best
|
|
95
|
+
if alternate_direction:
|
|
96
|
+
reverse = not reverse # Alternate search direction
|
|
97
|
+
|
|
98
|
+
return ratio, cycle
|