digraphx 0.5__tar.gz → 0.6__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.6}/.github/workflows/multi-platforms.yml +1 -1
- {digraphx-0.5 → digraphx-0.6}/.github/workflows/python-app.yml +3 -3
- {digraphx-0.5 → digraphx-0.6}/CHANGELOG.md +22 -0
- {digraphx-0.5/src/digraphx.egg-info → digraphx-0.6}/PKG-INFO +1 -1
- {digraphx-0.5 → digraphx-0.6}/docs/requirements.txt +1 -0
- {digraphx-0.5 → digraphx-0.6}/experimental/spare_tsv.py +1 -3
- {digraphx-0.5 → digraphx-0.6}/mypy.ini +3 -5
- digraphx-0.6/src/digraphx/__init__.py +36 -0
- digraphx-0.6/src/digraphx/_cycle_base.py +180 -0
- digraphx-0.6/src/digraphx/_parametric_base.py +101 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/csr_digraph.py +5 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/mcf.py +8 -26
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/min_parametric_q.py +14 -46
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/neg_cycle.py +16 -77
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/neg_cycle_q.py +21 -99
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/parametric.py +3 -36
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/tiny_digraph.py +0 -19
- {digraphx-0.5 → digraphx-0.6/src/digraphx.egg-info}/PKG-INFO +1 -1
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/SOURCES.txt +2 -1
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/scm_file_list.json +66 -65
- digraphx-0.6/src/digraphx.egg-info/scm_version.json +8 -0
- digraphx-0.6/tests/test_coverage_extras.py +546 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_min_cycle_ratio_extra.py +15 -13
- {digraphx-0.5 → digraphx-0.6}/tests/test_min_parametric_q_extra.py +20 -12
- digraphx-0.5/.github/workflows/ci.bak +0 -128
- 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.6}/.coveragerc +0 -0
- {digraphx-0.5 → digraphx-0.6}/.github/workflows/jekyll-gh-pages.yml +0 -0
- {digraphx-0.5 → digraphx-0.6}/.github/workflows/python-publish.yml +0 -0
- {digraphx-0.5 → digraphx-0.6}/.gitignore +0 -0
- {digraphx-0.5 → digraphx-0.6}/.isort.cfg +0 -0
- {digraphx-0.5 → digraphx-0.6}/.pre-commit-config.yaml +0 -0
- {digraphx-0.5 → digraphx-0.6}/.readthedocs.yml +0 -0
- {digraphx-0.5 → digraphx-0.6}/.vscode/settings.json +0 -0
- {digraphx-0.5 → digraphx-0.6}/AGENTS.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/AUTHORS.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/CONTRIBUTING.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/GEMINI.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/LICENSE.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/README.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/coverage.json +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/Makefile +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/_static/.gitignore +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/authors.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/changelog.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/conf.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/contributing.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/examples/plot_cycle_detection.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/examples/plot_tiny_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/figures_demo.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/index.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/license.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/docs/readme.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/environment.yml +0 -0
- {digraphx-0.5 → digraphx-0.6}/experimental/__init__.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experimental/test_spare_tsv.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent-new.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent-old.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/experi.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/plot_node_colormap.ipynb +0 -0
- {digraphx-0.5 → digraphx-0.6}/experiments/plot_node_colormap.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/note.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/pyproject.toml +0 -0
- {digraphx-0.5 → digraphx-0.6}/requirements/README.md +0 -0
- {digraphx-0.5 → digraphx-0.6}/requirements/default.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/requirements/doc.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/requirements/test.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/requirements.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/setup.cfg +0 -0
- {digraphx-0.5 → digraphx-0.6}/setup.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/min_cycle_ratio.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx/py.typed +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/dependency_links.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/not-zip-safe +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/requires.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/top_level.txt +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/__init__.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/conftest.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_csr_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_cycle_ratio.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_delay_padding_example_howard.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_edge_cases.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_howard_cycle_cancellation.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_mcf.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_min_cycle_ratio_property.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_min_parametric_q.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_q.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_q_simple_timing.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_simple_timing.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_parametric.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_stress.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_timing_example.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_timing_example_howard.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_tiny_digraph.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/tests/test_tiny_digraph_property.py +0 -0
- {digraphx-0.5 → digraphx-0.6}/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 }}
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Version 0.6 (2026-09-04)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **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)
|
|
7
|
+
- **Public re-exports in `__init__.py`**: Fixes README quick-start imports such as `NegCycleFinder`, `TinyDiGraph`, `MinCycleRatioSolver`. (#1c60bde)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
- **mypy type errors**: Resolved None guards, test generics and networkx config issues. (#13dc943)
|
|
11
|
+
- **RTD doc build**: Added matplotlib and numpy to `docs/requirements.txt`. (#1f49779)
|
|
12
|
+
|
|
13
|
+
### Testing & Code Quality
|
|
14
|
+
- **Coverage raised 85%→99%**: Added coverage extras for MCF solver, TinyDiGraph and solvers. (#2cabc4e)
|
|
15
|
+
- **Style pass**: Reformatted list comprehensions and type annotations. (#2973013)
|
|
16
|
+
|
|
17
|
+
### Code Cleanup
|
|
18
|
+
- **Removed AI slop**: Stripped boilerplate from docstrings and comments. (#be4456a)
|
|
19
|
+
- **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)
|
|
20
|
+
|
|
21
|
+
### Build & CI
|
|
22
|
+
- **Removed stale `.bak` workflow**: Deleted `ci.bak`. (#5f50fe3)
|
|
23
|
+
- **Updated GitHub Actions**: checkout→v4, setup-python→v5, codecov-action→v4 to fix Codecov tokenless upload failure and Node 20 deprecation. (#5334192)
|
|
24
|
+
|
|
3
25
|
## Version 0.5 (2026-07-16)
|
|
4
26
|
|
|
5
27
|
### Features
|
|
@@ -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,180 @@
|
|
|
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
|
+
Tuple,
|
|
21
|
+
TypeVar,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Type variables for generic graph components
|
|
25
|
+
Node = TypeVar("Node") # Hashable node type (must implement __hash__)
|
|
26
|
+
Arc = TypeVar("Arc") # Hashable edge type (must implement __hash__)
|
|
27
|
+
Domain = TypeVar(
|
|
28
|
+
"Domain", int, Fraction, float
|
|
29
|
+
) # Numeric type for weights (must support comparison and arithmetic)
|
|
30
|
+
Cycle = List[Arc] # Alias for a list of edges forming a cycle
|
|
31
|
+
|
|
32
|
+
# A point-to map: node -> (predecessor/successor node, connecting edge)
|
|
33
|
+
PointTo = Dict[Node, Tuple[Node, Arc]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _always_true(old: Domain, new: Domain) -> bool:
|
|
37
|
+
"""Default `update_ok` gate: allow every distance update."""
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_cycle(
|
|
42
|
+
digraph: Mapping[Node, Mapping[Node, Arc]], point_to: PointTo
|
|
43
|
+
) -> Generator[Node, None, None]:
|
|
44
|
+
"""Yield each node that starts a cycle in the given point-to map.
|
|
45
|
+
|
|
46
|
+
Uses a coloring algorithm (white/gray/black) to detect cycles: white nodes
|
|
47
|
+
are unvisited, gray nodes are being visited in the current DFS path, and
|
|
48
|
+
black nodes are fully visited. A cycle is found when a node in the current
|
|
49
|
+
path is reached again.
|
|
50
|
+
"""
|
|
51
|
+
visited: Dict[Node, Node] = {} # Maps nodes to their DFS root
|
|
52
|
+
for vtx in filter(lambda vtx: vtx not in visited, digraph):
|
|
53
|
+
utx = vtx
|
|
54
|
+
while True:
|
|
55
|
+
visited[utx] = vtx # Mark as visited with current DFS root
|
|
56
|
+
if utx not in point_to:
|
|
57
|
+
break # Reached a leaf node
|
|
58
|
+
utx, _ = point_to[utx] # Move to predecessor/successor
|
|
59
|
+
if utx in visited:
|
|
60
|
+
if visited[utx] == vtx: # Found cycle back to current root
|
|
61
|
+
yield utx
|
|
62
|
+
break # Cycle or different DFS tree
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def relax_pred(
|
|
66
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
67
|
+
dist: MutableMapping[Node, Domain],
|
|
68
|
+
get_weight: Callable[[Arc], Domain],
|
|
69
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
70
|
+
pred: PointTo,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Perform one predecessor relaxation pass (Bellman-Ford style).
|
|
73
|
+
|
|
74
|
+
Updates ``dist[v]`` and the predecessor map when the triangle inequality
|
|
75
|
+
``dist[v] > dist[u] + weight(u, v)`` holds AND ``update_ok`` permits it.
|
|
76
|
+
Returns ``True`` if any distance was updated.
|
|
77
|
+
"""
|
|
78
|
+
changed = False
|
|
79
|
+
for utx, neighbors in digraph.items():
|
|
80
|
+
for vtx, edge in neighbors.items():
|
|
81
|
+
distance = dist[utx] + get_weight(edge)
|
|
82
|
+
if dist[vtx] > distance and update_ok(dist[vtx], distance):
|
|
83
|
+
dist[vtx] = distance
|
|
84
|
+
pred[vtx] = (utx, edge) # Update predecessor
|
|
85
|
+
changed = True
|
|
86
|
+
return changed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def relax_succ(
|
|
90
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
91
|
+
dist: MutableMapping[Node, Domain],
|
|
92
|
+
get_weight: Callable[[Arc], Domain],
|
|
93
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
94
|
+
succ: PointTo,
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Perform one successor relaxation pass (reverse Bellman-Ford style).
|
|
97
|
+
|
|
98
|
+
Updates ``dist[u]`` and the successor map when the triangle inequality
|
|
99
|
+
``dist[u] < dist[v] - weight(u, v)`` holds AND ``update_ok`` permits it.
|
|
100
|
+
Returns ``True`` if any distance was updated.
|
|
101
|
+
"""
|
|
102
|
+
changed = False
|
|
103
|
+
for utx, neighbors in digraph.items():
|
|
104
|
+
for vtx, edge in neighbors.items():
|
|
105
|
+
distance = dist[vtx] - get_weight(edge)
|
|
106
|
+
if dist[utx] < distance and update_ok(dist[utx], distance):
|
|
107
|
+
dist[utx] = distance
|
|
108
|
+
succ[utx] = (vtx, edge) # Update successor
|
|
109
|
+
changed = True
|
|
110
|
+
return changed
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cycle_list(point_to: PointTo, handle: Node) -> Cycle:
|
|
114
|
+
"""Reconstruct the cycle starting from ``handle`` in the point-to map.
|
|
115
|
+
|
|
116
|
+
Follows predecessor/successor links until returning to the starting node.
|
|
117
|
+
"""
|
|
118
|
+
vtx = handle
|
|
119
|
+
cycle = list()
|
|
120
|
+
while True:
|
|
121
|
+
utx, edge = point_to[vtx] # Get next node and connecting edge
|
|
122
|
+
cycle.append(edge) # Add edge to cycle
|
|
123
|
+
vtx = utx # Move to next node
|
|
124
|
+
if vtx == handle: # Completed the cycle
|
|
125
|
+
break
|
|
126
|
+
return cycle
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_negative(
|
|
130
|
+
point_to: PointTo,
|
|
131
|
+
handle: Node,
|
|
132
|
+
dist: MutableMapping[Node, Domain],
|
|
133
|
+
get_weight: Callable[[Arc], Domain],
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""Return ``True`` if the cycle starting at ``handle`` is negative.
|
|
136
|
+
|
|
137
|
+
A cycle is negative if at least one edge (u, v) on it violates the
|
|
138
|
+
triangle inequality ``dist[v] > dist[u] + weight(u, v)``.
|
|
139
|
+
"""
|
|
140
|
+
vtx = handle
|
|
141
|
+
# C-style do-while loop
|
|
142
|
+
while True:
|
|
143
|
+
utx, edge = point_to[vtx]
|
|
144
|
+
if dist[vtx] > dist[utx] + get_weight(edge): # Found negative cycle
|
|
145
|
+
return True
|
|
146
|
+
vtx = utx
|
|
147
|
+
if vtx == handle: # Completed full cycle
|
|
148
|
+
break
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def howard_search(
|
|
153
|
+
digraph: Mapping[Node, Mapping[Node, Arc]],
|
|
154
|
+
dist: MutableMapping[Node, Domain],
|
|
155
|
+
get_weight: Callable[[Arc], Domain],
|
|
156
|
+
update_ok: Callable[[Domain, Domain], bool],
|
|
157
|
+
point_to: PointTo,
|
|
158
|
+
direction: str,
|
|
159
|
+
verify: bool = True,
|
|
160
|
+
) -> Generator[Cycle, None, None]:
|
|
161
|
+
"""Template Method: Howard's policy-iteration skeleton.
|
|
162
|
+
|
|
163
|
+
Repeatedly relaxes the distance estimates until no improvement is made or a
|
|
164
|
+
cycle is found, then yields each detected cycle as a list of edge weights.
|
|
165
|
+
|
|
166
|
+
The relaxation pass and the point-to map are selected by ``direction``
|
|
167
|
+
(``"pred"`` for predecessor relaxation, ``"succ"`` for successor). When
|
|
168
|
+
``verify`` is ``True``, each candidate cycle is asserted to be negative
|
|
169
|
+
before being yielded (matching the predecessor variants).
|
|
170
|
+
"""
|
|
171
|
+
point_to.clear()
|
|
172
|
+
found = False
|
|
173
|
+
relax = relax_pred if direction == "pred" else relax_succ
|
|
174
|
+
while not found and relax(digraph, dist, get_weight, update_ok, point_to):
|
|
175
|
+
for vtx in find_cycle(digraph, point_to):
|
|
176
|
+
if verify:
|
|
177
|
+
# Safety check - verify the cycle is indeed negative
|
|
178
|
+
assert is_negative(point_to, vtx, dist, get_weight)
|
|
179
|
+
found = True
|
|
180
|
+
yield cycle_list(point_to, vtx)
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
DomainType = type(next(iter(dist.values())))
|
|
54
|
+
|
|
55
|
+
# Define a weight function that calculates distance based on current ratio
|
|
56
|
+
def get_weight(e: Arc) -> Domain:
|
|
57
|
+
return DomainType(omega.distance(ratio, e))
|
|
58
|
+
|
|
59
|
+
# Initialize min/max ratio and cycle
|
|
60
|
+
ratio_best = ratio
|
|
61
|
+
cycle_best = []
|
|
62
|
+
cycle = []
|
|
63
|
+
reverse: bool = True # Flag to alternate search direction
|
|
64
|
+
|
|
65
|
+
if alternate_direction:
|
|
66
|
+
ncf: NegCycleFinderQ[Node, Arc, Domain] = NegCycleFinderQ(digraph)
|
|
67
|
+
else:
|
|
68
|
+
ncf = NegCycleFinder(digraph)
|
|
69
|
+
|
|
70
|
+
# Main algorithm loop
|
|
71
|
+
while True:
|
|
72
|
+
if alternate_direction:
|
|
73
|
+
# Search for cycles in either forward or reverse direction
|
|
74
|
+
cycles = (
|
|
75
|
+
ncf.howard_succ(dist, get_weight, update_ok)
|
|
76
|
+
if reverse
|
|
77
|
+
else ncf.howard_pred(dist, get_weight, update_ok)
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
cycles = ncf.howard(dist, get_weight)
|
|
81
|
+
|
|
82
|
+
# Evaluate all found cycles
|
|
83
|
+
for c_i in cycles:
|
|
84
|
+
ratio_i = omega.zero_cancel(c_i)
|
|
85
|
+
if (ratio_best > ratio_i) if minimize else (ratio_best < ratio_i):
|
|
86
|
+
ratio_best = ratio_i
|
|
87
|
+
cycle_best = c_i
|
|
88
|
+
if pick_one_only: # Early exit if we only need one improvement
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
# Termination condition: no better ratio found
|
|
92
|
+
if (ratio_best >= ratio) if minimize else (ratio_best <= ratio):
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
# Update state for next iteration
|
|
96
|
+
cycle = cycle_best
|
|
97
|
+
ratio = ratio_best
|
|
98
|
+
if alternate_direction:
|
|
99
|
+
reverse = not reverse # Alternate search direction
|
|
100
|
+
|
|
101
|
+
return ratio, cycle
|
|
@@ -91,6 +91,7 @@ class CSRDiGraph(Mapping):
|
|
|
91
91
|
def add_edge(self, u: int, v: int, **attr) -> None: # type: ignore
|
|
92
92
|
"""Add a directed edge ``u → v`` with optional attributes."""
|
|
93
93
|
assert not self._frozen, "graph is frozen"
|
|
94
|
+
assert self._edges is not None
|
|
94
95
|
self._edges[u].append((v, attr if attr else {}))
|
|
95
96
|
|
|
96
97
|
def freeze(self) -> None:
|
|
@@ -100,6 +101,7 @@ class CSRDiGraph(Mapping):
|
|
|
100
101
|
"""
|
|
101
102
|
if self._frozen:
|
|
102
103
|
return
|
|
104
|
+
assert self._edges is not None
|
|
103
105
|
indptr = array("i", [0]) # cumulative counts
|
|
104
106
|
for node_edges in self._edges:
|
|
105
107
|
indptr.append(indptr[-1] + len(node_edges))
|
|
@@ -123,6 +125,9 @@ class CSRDiGraph(Mapping):
|
|
|
123
125
|
def __getitem__(self, u: int):
|
|
124
126
|
if not self._frozen:
|
|
125
127
|
self.freeze()
|
|
128
|
+
assert self._indptr is not None
|
|
129
|
+
assert self._indices is not None
|
|
130
|
+
assert self._data is not None
|
|
126
131
|
start = self._indptr[u]
|
|
127
132
|
end = self._indptr[u + 1]
|
|
128
133
|
return _CSRNeighbors(self._indices, self._data, start, end)
|
|
@@ -106,33 +106,17 @@ def _build_residual(g, flow):
|
|
|
106
106
|
for u, neighbors in g.items():
|
|
107
107
|
if u not in residual:
|
|
108
108
|
residual[u] = {}
|
|
109
|
-
for v
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
f = flow.get(u, {}).get(v, 0)
|
|
113
|
-
|
|
114
|
-
if f < cap:
|
|
115
|
-
edge = {
|
|
116
|
-
"cost": wgt,
|
|
117
|
-
"capacity": cap - f,
|
|
118
|
-
"orig": (u, v),
|
|
119
|
-
"forward": True,
|
|
120
|
-
}
|
|
109
|
+
for v in neighbors:
|
|
110
|
+
fwd, bwd = _residual_edge(g, u, v, flow)
|
|
111
|
+
if fwd is not None:
|
|
121
112
|
# Keep the more negative cost when edges collide at same (u,v)
|
|
122
113
|
prev = residual[u].get(v)
|
|
123
|
-
if prev is None or
|
|
124
|
-
residual[u][v] =
|
|
125
|
-
|
|
126
|
-
if f > 0:
|
|
127
|
-
edge = {
|
|
128
|
-
"cost": -wgt,
|
|
129
|
-
"capacity": f,
|
|
130
|
-
"orig": (u, v),
|
|
131
|
-
"forward": False,
|
|
132
|
-
}
|
|
114
|
+
if prev is None or fwd["cost"] < prev["cost"]:
|
|
115
|
+
residual[u][v] = fwd
|
|
116
|
+
if bwd is not None:
|
|
133
117
|
prev = residual.setdefault(v, {}).get(u)
|
|
134
|
-
if prev is None or
|
|
135
|
-
residual[v][u] =
|
|
118
|
+
if prev is None or bwd["cost"] < prev["cost"]:
|
|
119
|
+
residual[v][u] = bwd
|
|
136
120
|
|
|
137
121
|
return residual
|
|
138
122
|
|
|
@@ -297,8 +281,6 @@ def cycle_canceling_mcf(g, demands, sink=None):
|
|
|
297
281
|
if f > 0 and u != sink:
|
|
298
282
|
vf.used.add(u)
|
|
299
283
|
break # one outgoing edge is enough to mark as used
|
|
300
|
-
if vf is not None:
|
|
301
|
-
print(f"Used init: {len(vf.used)}")
|
|
302
284
|
|
|
303
285
|
all_nodes = None
|
|
304
286
|
|
|
@@ -43,14 +43,16 @@ from abc import abstractmethod
|
|
|
43
43
|
from fractions import Fraction
|
|
44
44
|
from typing import Callable, Generic, Mapping, MutableMapping, Tuple, TypeVar
|
|
45
45
|
|
|
46
|
-
from .
|
|
46
|
+
from ._parametric_base import _run_loop
|
|
47
|
+
from .neg_cycle_q import Arc, Cycle, Node
|
|
48
|
+
from .parametric import ParametricAPI
|
|
47
49
|
|
|
48
50
|
# Define type variables for domain (numeric types) and ratio (fraction or float)
|
|
49
51
|
Domain = TypeVar("Domain", int, Fraction, float) # Comparable Ring
|
|
50
52
|
Ratio = TypeVar("Ratio", Fraction, float)
|
|
51
53
|
|
|
52
54
|
|
|
53
|
-
class MinParametricAPI(
|
|
55
|
+
class MinParametricAPI(ParametricAPI[Node, Arc, Ratio]):
|
|
54
56
|
@abstractmethod
|
|
55
57
|
def distance(self, ratio: Ratio, edge: Arc) -> Ratio:
|
|
56
58
|
"""
|
|
@@ -117,7 +119,6 @@ class MinParametricSolver(Generic[Node, Arc, Ratio, Domain]):
|
|
|
117
119
|
>>> digraph = {'a': {'b': {'cost': 5, 'time': 1}}}
|
|
118
120
|
>>> omega = MinParametricAPI() # Requires concrete implementation
|
|
119
121
|
"""
|
|
120
|
-
# self.ncf = NegCycleFinderQ(digraph)
|
|
121
122
|
self.digraph = digraph
|
|
122
123
|
self.omega: MinParametricAPI[Node, Arc, Ratio] = omega
|
|
123
124
|
|
|
@@ -149,46 +150,13 @@ class MinParametricSolver(Generic[Node, Arc, Ratio, Domain]):
|
|
|
149
150
|
- The minimum ratio found (ratio)
|
|
150
151
|
- The cycle that corresponds to this ratio (cycle)
|
|
151
152
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
cycle = []
|
|
163
|
-
reverse: bool = True # Flag to alternate search direction
|
|
164
|
-
|
|
165
|
-
# Initialize the negative cycle finder with our graph
|
|
166
|
-
ncf: NegCycleFinderQ[Node, Arc, Domain] = NegCycleFinderQ(self.digraph)
|
|
167
|
-
|
|
168
|
-
# Main optimization loop
|
|
169
|
-
while True:
|
|
170
|
-
# Search for cycles in either forward or reverse direction
|
|
171
|
-
if reverse:
|
|
172
|
-
cycles = ncf.howard_succ(dist, get_weight, update_ok)
|
|
173
|
-
else:
|
|
174
|
-
cycles = ncf.howard_pred(dist, get_weight, update_ok)
|
|
175
|
-
|
|
176
|
-
# Evaluate all found cycles
|
|
177
|
-
for c_i in cycles:
|
|
178
|
-
ratio_i = self.omega.zero_cancel(c_i)
|
|
179
|
-
if ratio_max < ratio_i:
|
|
180
|
-
ratio_max = ratio_i
|
|
181
|
-
cycle_max = c_i
|
|
182
|
-
if pick_one_only: # Early exit if we only need one improvement
|
|
183
|
-
break
|
|
184
|
-
|
|
185
|
-
# Termination condition: no better ratio found
|
|
186
|
-
if ratio_max <= ratio:
|
|
187
|
-
break
|
|
188
|
-
|
|
189
|
-
# Update state for next iteration
|
|
190
|
-
cycle = cycle_max
|
|
191
|
-
ratio = ratio_max
|
|
192
|
-
reverse = not reverse # Alternate search direction
|
|
193
|
-
|
|
194
|
-
return ratio, cycle
|
|
153
|
+
return _run_loop(
|
|
154
|
+
self.digraph,
|
|
155
|
+
self.omega,
|
|
156
|
+
dist,
|
|
157
|
+
ratio,
|
|
158
|
+
minimize=False,
|
|
159
|
+
update_ok=update_ok,
|
|
160
|
+
pick_one_only=pick_one_only,
|
|
161
|
+
alternate_direction=True,
|
|
162
|
+
)
|