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.
Files changed (100) hide show
  1. {digraphx-0.5 → digraphx-0.6}/.github/workflows/multi-platforms.yml +1 -1
  2. {digraphx-0.5 → digraphx-0.6}/.github/workflows/python-app.yml +3 -3
  3. {digraphx-0.5 → digraphx-0.6}/CHANGELOG.md +22 -0
  4. {digraphx-0.5/src/digraphx.egg-info → digraphx-0.6}/PKG-INFO +1 -1
  5. {digraphx-0.5 → digraphx-0.6}/docs/requirements.txt +1 -0
  6. {digraphx-0.5 → digraphx-0.6}/experimental/spare_tsv.py +1 -3
  7. {digraphx-0.5 → digraphx-0.6}/mypy.ini +3 -5
  8. digraphx-0.6/src/digraphx/__init__.py +36 -0
  9. digraphx-0.6/src/digraphx/_cycle_base.py +180 -0
  10. digraphx-0.6/src/digraphx/_parametric_base.py +101 -0
  11. {digraphx-0.5 → digraphx-0.6}/src/digraphx/csr_digraph.py +5 -0
  12. {digraphx-0.5 → digraphx-0.6}/src/digraphx/mcf.py +8 -26
  13. {digraphx-0.5 → digraphx-0.6}/src/digraphx/min_parametric_q.py +14 -46
  14. {digraphx-0.5 → digraphx-0.6}/src/digraphx/neg_cycle.py +16 -77
  15. {digraphx-0.5 → digraphx-0.6}/src/digraphx/neg_cycle_q.py +21 -99
  16. {digraphx-0.5 → digraphx-0.6}/src/digraphx/parametric.py +3 -36
  17. {digraphx-0.5 → digraphx-0.6}/src/digraphx/tiny_digraph.py +0 -19
  18. {digraphx-0.5 → digraphx-0.6/src/digraphx.egg-info}/PKG-INFO +1 -1
  19. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/SOURCES.txt +2 -1
  20. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/scm_file_list.json +66 -65
  21. digraphx-0.6/src/digraphx.egg-info/scm_version.json +8 -0
  22. digraphx-0.6/tests/test_coverage_extras.py +546 -0
  23. {digraphx-0.5 → digraphx-0.6}/tests/test_min_cycle_ratio_extra.py +15 -13
  24. {digraphx-0.5 → digraphx-0.6}/tests/test_min_parametric_q_extra.py +20 -12
  25. digraphx-0.5/.github/workflows/ci.bak +0 -128
  26. digraphx-0.5/src/digraphx/__init__.py +0 -14
  27. digraphx-0.5/src/digraphx.egg-info/scm_version.json +0 -8
  28. digraphx-0.5/tests/test_coverage_extras.py +0 -51
  29. {digraphx-0.5 → digraphx-0.6}/.coveragerc +0 -0
  30. {digraphx-0.5 → digraphx-0.6}/.github/workflows/jekyll-gh-pages.yml +0 -0
  31. {digraphx-0.5 → digraphx-0.6}/.github/workflows/python-publish.yml +0 -0
  32. {digraphx-0.5 → digraphx-0.6}/.gitignore +0 -0
  33. {digraphx-0.5 → digraphx-0.6}/.isort.cfg +0 -0
  34. {digraphx-0.5 → digraphx-0.6}/.pre-commit-config.yaml +0 -0
  35. {digraphx-0.5 → digraphx-0.6}/.readthedocs.yml +0 -0
  36. {digraphx-0.5 → digraphx-0.6}/.vscode/settings.json +0 -0
  37. {digraphx-0.5 → digraphx-0.6}/AGENTS.md +0 -0
  38. {digraphx-0.5 → digraphx-0.6}/AUTHORS.md +0 -0
  39. {digraphx-0.5 → digraphx-0.6}/CONTRIBUTING.md +0 -0
  40. {digraphx-0.5 → digraphx-0.6}/GEMINI.md +0 -0
  41. {digraphx-0.5 → digraphx-0.6}/LICENSE.txt +0 -0
  42. {digraphx-0.5 → digraphx-0.6}/README.md +0 -0
  43. {digraphx-0.5 → digraphx-0.6}/coverage.json +0 -0
  44. {digraphx-0.5 → digraphx-0.6}/docs/Makefile +0 -0
  45. {digraphx-0.5 → digraphx-0.6}/docs/_static/.gitignore +0 -0
  46. {digraphx-0.5 → digraphx-0.6}/docs/authors.md +0 -0
  47. {digraphx-0.5 → digraphx-0.6}/docs/changelog.md +0 -0
  48. {digraphx-0.5 → digraphx-0.6}/docs/conf.py +0 -0
  49. {digraphx-0.5 → digraphx-0.6}/docs/contributing.md +0 -0
  50. {digraphx-0.5 → digraphx-0.6}/docs/examples/plot_cycle_detection.py +0 -0
  51. {digraphx-0.5 → digraphx-0.6}/docs/examples/plot_tiny_digraph.py +0 -0
  52. {digraphx-0.5 → digraphx-0.6}/docs/figures_demo.md +0 -0
  53. {digraphx-0.5 → digraphx-0.6}/docs/index.md +0 -0
  54. {digraphx-0.5 → digraphx-0.6}/docs/license.md +0 -0
  55. {digraphx-0.5 → digraphx-0.6}/docs/readme.md +0 -0
  56. {digraphx-0.5 → digraphx-0.6}/environment.yml +0 -0
  57. {digraphx-0.5 → digraphx-0.6}/experimental/__init__.py +0 -0
  58. {digraphx-0.5 → digraphx-0.6}/experimental/test_spare_tsv.py +0 -0
  59. {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent-new.py +0 -0
  60. {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent-old.py +0 -0
  61. {digraphx-0.5 → digraphx-0.6}/experiments/experi-descent.py +0 -0
  62. {digraphx-0.5 → digraphx-0.6}/experiments/experi.py +0 -0
  63. {digraphx-0.5 → digraphx-0.6}/experiments/plot_node_colormap.ipynb +0 -0
  64. {digraphx-0.5 → digraphx-0.6}/experiments/plot_node_colormap.py +0 -0
  65. {digraphx-0.5 → digraphx-0.6}/note.md +0 -0
  66. {digraphx-0.5 → digraphx-0.6}/pyproject.toml +0 -0
  67. {digraphx-0.5 → digraphx-0.6}/requirements/README.md +0 -0
  68. {digraphx-0.5 → digraphx-0.6}/requirements/default.txt +0 -0
  69. {digraphx-0.5 → digraphx-0.6}/requirements/doc.txt +0 -0
  70. {digraphx-0.5 → digraphx-0.6}/requirements/test.txt +0 -0
  71. {digraphx-0.5 → digraphx-0.6}/requirements.txt +0 -0
  72. {digraphx-0.5 → digraphx-0.6}/setup.cfg +0 -0
  73. {digraphx-0.5 → digraphx-0.6}/setup.py +0 -0
  74. {digraphx-0.5 → digraphx-0.6}/src/digraphx/min_cycle_ratio.py +0 -0
  75. {digraphx-0.5 → digraphx-0.6}/src/digraphx/py.typed +0 -0
  76. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/dependency_links.txt +0 -0
  77. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/not-zip-safe +0 -0
  78. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/requires.txt +0 -0
  79. {digraphx-0.5 → digraphx-0.6}/src/digraphx.egg-info/top_level.txt +0 -0
  80. {digraphx-0.5 → digraphx-0.6}/tests/__init__.py +0 -0
  81. {digraphx-0.5 → digraphx-0.6}/tests/conftest.py +0 -0
  82. {digraphx-0.5 → digraphx-0.6}/tests/test_csr_digraph.py +0 -0
  83. {digraphx-0.5 → digraphx-0.6}/tests/test_cycle_ratio.py +0 -0
  84. {digraphx-0.5 → digraphx-0.6}/tests/test_delay_padding_example_howard.py +0 -0
  85. {digraphx-0.5 → digraphx-0.6}/tests/test_edge_cases.py +0 -0
  86. {digraphx-0.5 → digraphx-0.6}/tests/test_howard_cycle_cancellation.py +0 -0
  87. {digraphx-0.5 → digraphx-0.6}/tests/test_mcf.py +0 -0
  88. {digraphx-0.5 → digraphx-0.6}/tests/test_min_cycle_ratio_property.py +0 -0
  89. {digraphx-0.5 → digraphx-0.6}/tests/test_min_parametric_q.py +0 -0
  90. {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle.py +0 -0
  91. {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_q.py +0 -0
  92. {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_q_simple_timing.py +0 -0
  93. {digraphx-0.5 → digraphx-0.6}/tests/test_neg_cycle_simple_timing.py +0 -0
  94. {digraphx-0.5 → digraphx-0.6}/tests/test_parametric.py +0 -0
  95. {digraphx-0.5 → digraphx-0.6}/tests/test_stress.py +0 -0
  96. {digraphx-0.5 → digraphx-0.6}/tests/test_timing_example.py +0 -0
  97. {digraphx-0.5 → digraphx-0.6}/tests/test_timing_example_howard.py +0 -0
  98. {digraphx-0.5 → digraphx-0.6}/tests/test_tiny_digraph.py +0 -0
  99. {digraphx-0.5 → digraphx-0.6}/tests/test_tiny_digraph_property.py +0 -0
  100. {digraphx-0.5 → digraphx-0.6}/tox.ini +0 -0
@@ -10,7 +10,7 @@ jobs:
10
10
  os: ["ubuntu-latest", "windows-latest"]
11
11
  version: ["3.10", "3.13"]
12
12
  steps:
13
- - uses: actions/checkout@v3
13
+ - uses: actions/checkout@v4
14
14
  - uses: conda-incubator/setup-miniconda@v3
15
15
  with:
16
16
  activate-environment: test
@@ -14,9 +14,9 @@ jobs:
14
14
  runs-on: ubuntu-latest
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v2
17
+ - uses: actions/checkout@v4
18
18
  - name: Set up Python 3.10
19
- uses: actions/setup-python@v2
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@v1.5.2
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digraphx
3
- Version: 0.5
3
+ Version: 0.6
4
4
  Summary: Network Optimization Python Code
5
5
  Home-page: https://github.com/luk036/digraphx
6
6
  Author: Wai-Shing Luk
@@ -6,3 +6,4 @@ git+https://github.com/luk036/mywheel.git
6
6
  myst-parser[linkify]
7
7
  sphinx>=3.2.1
8
8
  sphinxcontrib-svgbob
9
+ matplotlib
@@ -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
- ignore_missing_imports = True
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, data in neighbors.items():
110
- cap = data.get("capacity", float("inf"))
111
- wgt = data.get("weight", 0)
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 edge["cost"] < prev["cost"]:
124
- residual[u][v] = edge
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 edge["cost"] < prev["cost"]:
135
- residual[v][u] = edge
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 .neg_cycle_q import Arc, Cycle, NegCycleFinderQ, Node
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(Generic[Node, Arc, Ratio]):
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
- # Determine the numeric type used in distance calculations
153
- DomainType = type(next(iter(dist.values())))
154
-
155
- # Helper function to calculate edge weights based on current ratio
156
- def get_weight(e: Arc) -> Domain:
157
- return DomainType(self.omega.distance(ratio, e))
158
-
159
- # Initialize tracking variables for minimum ratio and corresponding cycle
160
- ratio_max = ratio
161
- cycle_max = []
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
+ )