digraphx 0.6__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.
Files changed (99) hide show
  1. {digraphx-0.6 → digraphx-0.7}/CHANGELOG.md +19 -0
  2. {digraphx-0.6/src/digraphx.egg-info → digraphx-0.7}/PKG-INFO +1 -1
  3. digraphx-0.7/benchmarks/test_benchmarks.py +131 -0
  4. {digraphx-0.6 → digraphx-0.7}/src/digraphx/_cycle_base.py +75 -2
  5. {digraphx-0.6 → digraphx-0.7}/src/digraphx/_parametric_base.py +1 -4
  6. {digraphx-0.6 → digraphx-0.7}/src/digraphx/neg_cycle.py +12 -1
  7. {digraphx-0.6 → digraphx-0.7}/src/digraphx/neg_cycle_q.py +24 -5
  8. {digraphx-0.6 → digraphx-0.7}/src/digraphx/tiny_digraph.py +34 -6
  9. {digraphx-0.6 → digraphx-0.7/src/digraphx.egg-info}/PKG-INFO +1 -1
  10. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/SOURCES.txt +2 -0
  11. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/scm_file_list.json +3 -1
  12. digraphx-0.7/src/digraphx.egg-info/scm_version.json +8 -0
  13. {digraphx-0.6 → digraphx-0.7}/tests/test_neg_cycle.py +29 -0
  14. digraphx-0.7/tunable_parameter.md +153 -0
  15. digraphx-0.6/src/digraphx.egg-info/scm_version.json +0 -8
  16. {digraphx-0.6 → digraphx-0.7}/.coveragerc +0 -0
  17. {digraphx-0.6 → digraphx-0.7}/.github/workflows/jekyll-gh-pages.yml +0 -0
  18. {digraphx-0.6 → digraphx-0.7}/.github/workflows/multi-platforms.yml +0 -0
  19. {digraphx-0.6 → digraphx-0.7}/.github/workflows/python-app.yml +0 -0
  20. {digraphx-0.6 → digraphx-0.7}/.github/workflows/python-publish.yml +0 -0
  21. {digraphx-0.6 → digraphx-0.7}/.gitignore +0 -0
  22. {digraphx-0.6 → digraphx-0.7}/.isort.cfg +0 -0
  23. {digraphx-0.6 → digraphx-0.7}/.pre-commit-config.yaml +0 -0
  24. {digraphx-0.6 → digraphx-0.7}/.readthedocs.yml +0 -0
  25. {digraphx-0.6 → digraphx-0.7}/.vscode/settings.json +0 -0
  26. {digraphx-0.6 → digraphx-0.7}/AGENTS.md +0 -0
  27. {digraphx-0.6 → digraphx-0.7}/AUTHORS.md +0 -0
  28. {digraphx-0.6 → digraphx-0.7}/CONTRIBUTING.md +0 -0
  29. {digraphx-0.6 → digraphx-0.7}/GEMINI.md +0 -0
  30. {digraphx-0.6 → digraphx-0.7}/LICENSE.txt +0 -0
  31. {digraphx-0.6 → digraphx-0.7}/README.md +0 -0
  32. {digraphx-0.6 → digraphx-0.7}/coverage.json +0 -0
  33. {digraphx-0.6 → digraphx-0.7}/docs/Makefile +0 -0
  34. {digraphx-0.6 → digraphx-0.7}/docs/_static/.gitignore +0 -0
  35. {digraphx-0.6 → digraphx-0.7}/docs/authors.md +0 -0
  36. {digraphx-0.6 → digraphx-0.7}/docs/changelog.md +0 -0
  37. {digraphx-0.6 → digraphx-0.7}/docs/conf.py +0 -0
  38. {digraphx-0.6 → digraphx-0.7}/docs/contributing.md +0 -0
  39. {digraphx-0.6 → digraphx-0.7}/docs/examples/plot_cycle_detection.py +0 -0
  40. {digraphx-0.6 → digraphx-0.7}/docs/examples/plot_tiny_digraph.py +0 -0
  41. {digraphx-0.6 → digraphx-0.7}/docs/figures_demo.md +0 -0
  42. {digraphx-0.6 → digraphx-0.7}/docs/index.md +0 -0
  43. {digraphx-0.6 → digraphx-0.7}/docs/license.md +0 -0
  44. {digraphx-0.6 → digraphx-0.7}/docs/readme.md +0 -0
  45. {digraphx-0.6 → digraphx-0.7}/docs/requirements.txt +0 -0
  46. {digraphx-0.6 → digraphx-0.7}/environment.yml +0 -0
  47. {digraphx-0.6 → digraphx-0.7}/experimental/__init__.py +0 -0
  48. {digraphx-0.6 → digraphx-0.7}/experimental/spare_tsv.py +0 -0
  49. {digraphx-0.6 → digraphx-0.7}/experimental/test_spare_tsv.py +0 -0
  50. {digraphx-0.6 → digraphx-0.7}/experiments/experi-descent-new.py +0 -0
  51. {digraphx-0.6 → digraphx-0.7}/experiments/experi-descent-old.py +0 -0
  52. {digraphx-0.6 → digraphx-0.7}/experiments/experi-descent.py +0 -0
  53. {digraphx-0.6 → digraphx-0.7}/experiments/experi.py +0 -0
  54. {digraphx-0.6 → digraphx-0.7}/experiments/plot_node_colormap.ipynb +0 -0
  55. {digraphx-0.6 → digraphx-0.7}/experiments/plot_node_colormap.py +0 -0
  56. {digraphx-0.6 → digraphx-0.7}/mypy.ini +0 -0
  57. {digraphx-0.6 → digraphx-0.7}/note.md +0 -0
  58. {digraphx-0.6 → digraphx-0.7}/pyproject.toml +0 -0
  59. {digraphx-0.6 → digraphx-0.7}/requirements/README.md +0 -0
  60. {digraphx-0.6 → digraphx-0.7}/requirements/default.txt +0 -0
  61. {digraphx-0.6 → digraphx-0.7}/requirements/doc.txt +0 -0
  62. {digraphx-0.6 → digraphx-0.7}/requirements/test.txt +0 -0
  63. {digraphx-0.6 → digraphx-0.7}/requirements.txt +0 -0
  64. {digraphx-0.6 → digraphx-0.7}/setup.cfg +0 -0
  65. {digraphx-0.6 → digraphx-0.7}/setup.py +0 -0
  66. {digraphx-0.6 → digraphx-0.7}/src/digraphx/__init__.py +0 -0
  67. {digraphx-0.6 → digraphx-0.7}/src/digraphx/csr_digraph.py +0 -0
  68. {digraphx-0.6 → digraphx-0.7}/src/digraphx/mcf.py +0 -0
  69. {digraphx-0.6 → digraphx-0.7}/src/digraphx/min_cycle_ratio.py +0 -0
  70. {digraphx-0.6 → digraphx-0.7}/src/digraphx/min_parametric_q.py +0 -0
  71. {digraphx-0.6 → digraphx-0.7}/src/digraphx/parametric.py +0 -0
  72. {digraphx-0.6 → digraphx-0.7}/src/digraphx/py.typed +0 -0
  73. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/dependency_links.txt +0 -0
  74. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/not-zip-safe +0 -0
  75. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/requires.txt +0 -0
  76. {digraphx-0.6 → digraphx-0.7}/src/digraphx.egg-info/top_level.txt +0 -0
  77. {digraphx-0.6 → digraphx-0.7}/tests/__init__.py +0 -0
  78. {digraphx-0.6 → digraphx-0.7}/tests/conftest.py +0 -0
  79. {digraphx-0.6 → digraphx-0.7}/tests/test_coverage_extras.py +0 -0
  80. {digraphx-0.6 → digraphx-0.7}/tests/test_csr_digraph.py +0 -0
  81. {digraphx-0.6 → digraphx-0.7}/tests/test_cycle_ratio.py +0 -0
  82. {digraphx-0.6 → digraphx-0.7}/tests/test_delay_padding_example_howard.py +0 -0
  83. {digraphx-0.6 → digraphx-0.7}/tests/test_edge_cases.py +0 -0
  84. {digraphx-0.6 → digraphx-0.7}/tests/test_howard_cycle_cancellation.py +0 -0
  85. {digraphx-0.6 → digraphx-0.7}/tests/test_mcf.py +0 -0
  86. {digraphx-0.6 → digraphx-0.7}/tests/test_min_cycle_ratio_extra.py +0 -0
  87. {digraphx-0.6 → digraphx-0.7}/tests/test_min_cycle_ratio_property.py +0 -0
  88. {digraphx-0.6 → digraphx-0.7}/tests/test_min_parametric_q.py +0 -0
  89. {digraphx-0.6 → digraphx-0.7}/tests/test_min_parametric_q_extra.py +0 -0
  90. {digraphx-0.6 → digraphx-0.7}/tests/test_neg_cycle_q.py +0 -0
  91. {digraphx-0.6 → digraphx-0.7}/tests/test_neg_cycle_q_simple_timing.py +0 -0
  92. {digraphx-0.6 → digraphx-0.7}/tests/test_neg_cycle_simple_timing.py +0 -0
  93. {digraphx-0.6 → digraphx-0.7}/tests/test_parametric.py +0 -0
  94. {digraphx-0.6 → digraphx-0.7}/tests/test_stress.py +0 -0
  95. {digraphx-0.6 → digraphx-0.7}/tests/test_timing_example.py +0 -0
  96. {digraphx-0.6 → digraphx-0.7}/tests/test_timing_example_howard.py +0 -0
  97. {digraphx-0.6 → digraphx-0.7}/tests/test_tiny_digraph.py +0 -0
  98. {digraphx-0.6 → digraphx-0.7}/tests/test_tiny_digraph_property.py +0 -0
  99. {digraphx-0.6 → digraphx-0.7}/tox.ini +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
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
+
3
22
  ## Version 0.6 (2026-09-04)
4
23
 
5
24
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digraphx
3
- Version: 0.6
3
+ Version: 0.7
4
4
  Summary: Network Optimization Python Code
5
5
  Home-page: https://github.com/luk036/digraphx
6
6
  Author: Wai-Shing Luk
@@ -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)
@@ -17,6 +17,7 @@ from typing import (
17
17
  List,
18
18
  Mapping,
19
19
  MutableMapping,
20
+ Optional,
20
21
  Tuple,
21
22
  TypeVar,
22
23
  )
@@ -110,6 +111,57 @@ def relax_succ(
110
111
  return changed
111
112
 
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
+
113
165
  def cycle_list(point_to: PointTo, handle: Node) -> Cycle:
114
166
  """Reconstruct the cycle starting from ``handle`` in the point-to map.
115
167
 
@@ -157,6 +209,7 @@ def howard_search(
157
209
  point_to: PointTo,
158
210
  direction: str,
159
211
  verify: bool = True,
212
+ max_iter: Optional[int] = None,
160
213
  ) -> Generator[Cycle, None, None]:
161
214
  """Template Method: Howard's policy-iteration skeleton.
162
215
 
@@ -167,14 +220,34 @@ def howard_search(
167
220
  (``"pred"`` for predecessor relaxation, ``"succ"`` for successor). When
168
221
  ``verify`` is ``True``, each candidate cycle is asserted to be negative
169
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.
170
227
  """
171
228
  point_to.clear()
172
229
  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):
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
175
244
  for vtx in find_cycle(digraph, point_to):
176
245
  if verify:
177
246
  # Safety check - verify the cycle is indeed negative
178
247
  assert is_negative(point_to, vtx, dist, get_weight)
179
248
  found = True
180
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
+ )
@@ -50,11 +50,8 @@ def _run_loop(
50
50
  if not dist: # empty graph case - return early with no cycle found
51
51
  return ratio, []
52
52
 
53
- DomainType = type(next(iter(dist.values())))
54
-
55
- # Define a weight function that calculates distance based on current ratio
56
53
  def get_weight(e: Arc) -> Domain:
57
- return DomainType(omega.distance(ratio, e))
54
+ return omega.distance(ratio, e)
58
55
 
59
56
  # Initialize min/max ratio and cycle
60
57
  ratio_best = ratio
@@ -46,6 +46,7 @@ from typing import (
46
46
  Generic,
47
47
  Mapping,
48
48
  MutableMapping,
49
+ Optional,
49
50
  Tuple,
50
51
  Union,
51
52
  )
@@ -241,12 +242,16 @@ class NegCycleFinder(Generic[Node, Arc, Domain]):
241
242
  self,
242
243
  dist: MutableMapping[Node, Domain],
243
244
  get_weight: Callable[[Arc], Domain],
245
+ max_iter: Optional[int] = None,
244
246
  ) -> Generator[Cycle, None, None]:
245
247
  """Main algorithm to find negative cycles using Howard's method.
246
248
 
247
249
  Args:
248
250
  dist: Initial distance estimates (often initialized to zero)
249
251
  get_weight: Function to get edge weights
252
+ max_iter: Optional cap on relaxation rounds; ``None`` (default) is
253
+ unbounded. Exceeding the cap without a negative cycle raises
254
+ ``RuntimeError``.
250
255
 
251
256
  Yields:
252
257
  Generator[Cycle, None, None]: Each found negative cycle as a list of edges
@@ -274,5 +279,11 @@ class NegCycleFinder(Generic[Node, Arc, Domain]):
274
279
  False
275
280
  """
276
281
  yield from _howard_search(
277
- self.digraph, dist, get_weight, _always_true, self.pred, "pred"
282
+ self.digraph,
283
+ dist,
284
+ get_weight,
285
+ _always_true,
286
+ self.pred,
287
+ "pred",
288
+ max_iter=max_iter,
278
289
  )
@@ -44,6 +44,7 @@ from typing import (
44
44
  Generic,
45
45
  Mapping,
46
46
  MutableMapping,
47
+ Optional,
47
48
  Tuple,
48
49
  Union,
49
50
  )
@@ -202,6 +203,7 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
202
203
  dist: MutableMapping[Node, Domain],
203
204
  get_weight: Callable[[Arc], Domain],
204
205
  update_ok: Callable[[Domain, Domain], bool],
206
+ max_iter: Optional[int] = None,
205
207
  ) -> Generator[Cycle, None, None]:
206
208
  """Find negative cycles using predecessor-based Howard's algorithm.
207
209
 
@@ -209,6 +211,9 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
209
211
  dist: Initial distance estimates (often zero-initialized)
210
212
  get_weight: Function to get weight of an edge
211
213
  update_ok: Function to determine if distance updates are allowed
214
+ max_iter: Optional cap on relaxation rounds; ``None`` (default) is
215
+ unbounded. Exceeding the cap without a negative cycle raises
216
+ ``RuntimeError``.
212
217
 
213
218
  Yields:
214
219
  Generator[Cycle, None, None]: Each negative cycle found as a list of edges
@@ -237,7 +242,13 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
237
242
  False
238
243
  """
239
244
  yield from _howard_search(
240
- self.digraph, dist, get_weight, update_ok, self.pred, "pred"
245
+ self.digraph,
246
+ dist,
247
+ get_weight,
248
+ update_ok,
249
+ self.pred,
250
+ "pred",
251
+ max_iter=max_iter,
241
252
  )
242
253
 
243
254
  def howard_succ(
@@ -245,6 +256,7 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
245
256
  dist: MutableMapping[Node, Domain],
246
257
  get_weight: Callable[[Arc], Domain],
247
258
  update_ok: Callable[[Domain, Domain], bool],
259
+ max_iter: Optional[int] = None,
248
260
  ) -> Generator[Cycle, None, None]:
249
261
  """Find negative cycles using successor-based Howard's algorithm.
250
262
 
@@ -252,9 +264,9 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
252
264
  dist: Initial distance estimates (often zero-initialized)
253
265
  get_weight: Function to get weight of an edge
254
266
  update_ok: Function to determine if distance updates are allowed
255
-
256
- Yields:
257
- Generator[Cycle, None, None]: Each negative cycle found as a list of edges
267
+ max_iter: Optional cap on relaxation rounds; ``None`` (default) is
268
+ unbounded. Exceeding the cap without a negative cycle raises
269
+ ``RuntimeError``.
258
270
 
259
271
  Note:
260
272
  Similar to howard_pred but uses successor updates instead of predecessor
@@ -278,7 +290,14 @@ class NegCycleFinderQ(Generic[Node, Arc, Domain]):
278
290
  False
279
291
  """
280
292
  yield from _howard_search(
281
- self.digraph, dist, get_weight, update_ok, self.succ, "succ", verify=False
293
+ self.digraph,
294
+ dist,
295
+ get_weight,
296
+ update_ok,
297
+ self.succ,
298
+ "succ",
299
+ verify=False,
300
+ max_iter=max_iter,
282
301
  )
283
302
 
284
303
  def cycle_list(self, handle: Node, point_to: Dict[Node, Tuple[Node, Arc]]) -> Cycle:
@@ -158,7 +158,9 @@ class TinyDiGraph(DiGraphAdapter):
158
158
  >>> adj_list = graph.cheat_adjlist_outer_dict()
159
159
  >>> list(adj_list.keys())
160
160
  [0, 1]
161
- >>> adj_list[0] is _UNINIT
161
+ >>> adj_list[0]
162
+ {}
163
+ >>> adj_list.lst[0] is _UNINIT
162
164
  True
163
165
  """
164
166
  return _LazyMapAdapter([_UNINIT] * self.num_nodes)
@@ -238,18 +240,44 @@ class TinyDiGraph(DiGraphAdapter):
238
240
  return self._succ.lst[n] is _UNINIT
239
241
 
240
242
  def add_edge(self, u_of_edge, v_of_edge, **attr): # type: ignore
243
+ """Add the edge ``u_of_edge -> v_of_edge`` with optional attributes.
244
+
245
+ Resolves the lazy adjacency slots with direct list access instead of
246
+ routing through ``nx.DiGraph.add_edge``, which performs several
247
+ Python-level ``_LazyMapAdapter`` lookups per edge.
248
+ """
241
249
  u, v = u_of_edge, v_of_edge
242
- self._ensure_adj(u)
243
- self._ensure_adj(v)
244
- super().add_edge(u, v, **attr)
250
+ succ = self._succ.lst
251
+ pred = self._pred.lst
252
+ if succ[u] is _UNINIT:
253
+ succ[u] = {}
254
+ pred[u] = {}
255
+ if succ[v] is _UNINIT:
256
+ succ[v] = {}
257
+ pred[v] = {}
258
+ neighbors = succ[u]
259
+ datadict = neighbors.get(v)
260
+ if datadict is None:
261
+ datadict = {}
262
+ neighbors[v] = datadict
263
+ if attr:
264
+ datadict.update(attr)
265
+ pred[v][u] = datadict
266
+ nx._clear_cache(self)
245
267
 
246
268
  def add_edges_from(self, ebunch_to_add, **attr): # type: ignore
247
269
  for e in ebunch_to_add:
248
270
  ne = len(e)
249
271
  if ne == 3:
250
272
  u, v, dd = e
251
- d = {**attr, **dd}
252
- self.add_edge(u, v, **d)
273
+ if attr and dd:
274
+ self.add_edge(u, v, **{**attr, **dd})
275
+ elif attr:
276
+ self.add_edge(u, v, **attr)
277
+ elif dd:
278
+ self.add_edge(u, v, **dd)
279
+ else:
280
+ self.add_edge(u, v)
253
281
  elif ne == 2:
254
282
  u, v = e
255
283
  if attr:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digraphx
3
- Version: 0.6
3
+ Version: 0.7
4
4
  Summary: Network Optimization Python Code
5
5
  Home-page: https://github.com/luk036/digraphx
6
6
  Author: Wai-Shing Luk
@@ -19,11 +19,13 @@ requirements.txt
19
19
  setup.cfg
20
20
  setup.py
21
21
  tox.ini
22
+ tunable_parameter.md
22
23
  .github/workflows/jekyll-gh-pages.yml
23
24
  .github/workflows/multi-platforms.yml
24
25
  .github/workflows/python-app.yml
25
26
  .github/workflows/python-publish.yml
26
27
  .vscode/settings.json
28
+ benchmarks/test_benchmarks.py
27
29
  docs/Makefile
28
30
  docs/authors.md
29
31
  docs/changelog.md
@@ -17,6 +17,7 @@
17
17
  "GEMINI.md",
18
18
  "LICENSE.txt",
19
19
  "README.md",
20
+ "benchmarks/test_benchmarks.py",
20
21
  "coverage.json",
21
22
  "docs/Makefile",
22
23
  "docs/_static/.gitignore",
@@ -86,6 +87,7 @@
86
87
  "tests/test_timing_example_howard.py",
87
88
  "tests/test_tiny_digraph.py",
88
89
  "tests/test_tiny_digraph_property.py",
89
- "tox.ini"
90
+ "tox.ini",
91
+ "tunable_parameter.md"
90
92
  ]
91
93
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.7",
3
+ "distance": 0,
4
+ "node": "g0368caf1c0b8dde863eec2db7af4d8bb2bd55ecb",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-09"
8
+ }
@@ -3,6 +3,7 @@ from __future__ import print_function
3
3
 
4
4
  from typing import Any, Callable, Dict, Union
5
5
 
6
+ import pytest
6
7
  from mywheel.map_adapter import MapAdapter
7
8
 
8
9
  from digraphx.neg_cycle import NegCycleFinder
@@ -112,3 +113,31 @@ def test_neg_cycle_is_negative_edge_case() -> None:
112
113
 
113
114
  # This should return False as the triangle inequality is not violated
114
115
  assert not finder.is_negative(0, dist, lambda edge: edge.get("weight", 1))
116
+
117
+
118
+ def test_howard_max_iter_default_unbounded() -> None:
119
+ digraph: DiGraphAdapter = DiGraphAdapter()
120
+ digraph.add_edge(0, 1, weight=1)
121
+ dist: Dict[int, int] = {0: 0, 1: 5}
122
+ finder: NegCycleFinder[Any, Any, Any] = NegCycleFinder(digraph)
123
+ assert list(finder.howard(dist, lambda edge: edge.get("weight", 1))) == []
124
+
125
+
126
+ def test_howard_max_iter_finds_cycle() -> None:
127
+ digraph: DiGraphAdapter = DiGraphAdapter()
128
+ digraph.add_edge(0, 1, weight=1)
129
+ digraph.add_edge(1, 0, weight=-3)
130
+ dist: Dict[int, int] = {0: 0, 1: 0}
131
+ finder: NegCycleFinder[Any, Any, Any] = NegCycleFinder(digraph)
132
+ cycles = list(finder.howard(dist, lambda edge: edge.get("weight", 1), max_iter=100))
133
+ assert len(cycles) >= 1
134
+
135
+
136
+ def test_howard_max_iter_raises_when_exceeded() -> None:
137
+ digraph: DiGraphAdapter = DiGraphAdapter()
138
+ digraph.add_edge(0, 1, weight=1)
139
+ dist: Dict[int, int] = {0: 0, 1: 5}
140
+ finder: NegCycleFinder[Any, Any, Any] = NegCycleFinder(digraph)
141
+ with pytest.raises(RuntimeError):
142
+ for _ in finder.howard(dist, lambda edge: edge.get("weight", 1), max_iter=1):
143
+ pass
@@ -0,0 +1,153 @@
1
+ # Tunable Parameters in `digraphx`
2
+
3
+ A complete reference of every user-tunable parameter of the algorithms in this
4
+ project, together with its default value and source location.
5
+
6
+ ## Key finding
7
+
8
+ `digraphx` has **no `Options` / config dataclass**. There are zero `@dataclass`
9
+ or `NamedTuple` definitions in the package. Every tunable is a plain
10
+ function/method parameter or an injected callback. The dominant policy knob is
11
+ the `update_ok` predicate that gates Bellman-Ford relaxation.
12
+
13
+ This report separates the categories explicitly so it is clear what a caller
14
+ can actually adjust.
15
+
16
+ ---
17
+
18
+ ## 1. Negative-cycle finders
19
+
20
+ Files: `src/digraphx/neg_cycle.py`, `src/digraphx/neg_cycle_q.py`,
21
+ internal `src/digraphx/_cycle_base.py`.
22
+
23
+ | API | Parameters | Default |
24
+ | --- | --- | --- |
25
+ | `NegCycleFinder(digraph)` | `digraph` | required |
26
+ | `NegCycleFinder.howard(dist, get_weight, max_iter=None)` | `max_iter: int \| None` | `None` (unbounded) |
27
+ | `NegCycleFinderQ(digraph)` | `digraph` | required |
28
+ | `NegCycleFinderQ.relax_pred(dist, get_weight, update_ok)` | `update_ok` (predicate) | required |
29
+ | `NegCycleFinderQ.relax_succ(dist, get_weight, update_ok)` | `update_ok` | required |
30
+ | `NegCycleFinderQ.howard_pred(dist, get_weight, update_ok, max_iter=None)` | `update_ok`; `max_iter` | required; `None` |
31
+ | `NegCycleFinderQ.howard_succ(dist, get_weight, update_ok, max_iter=None)` | `update_ok`; `max_iter` | required; `None` |
32
+ | `howard_search(..., direction, verify=True, max_iter=None)` (internal) | `verify: bool`; `max_iter` | `True`; `None` |
33
+
34
+ Notes:
35
+
36
+ - `update_ok(old, new) -> bool` is the relaxation-gate callback. When
37
+ `NegCycleFinder` omits it, the gate is `_always_true`
38
+ (`src/digraphx/_cycle_base.py:36-38`).
39
+ - `NegCycleFinderQ.howard_succ` hard-wires `verify=False`; `howard_pred` uses
40
+ `verify=True` (`src/digraphx/neg_cycle_q.py:281` and `:239-241`).
41
+
42
+ ---
43
+
44
+ ## 2. Parametric solvers
45
+
46
+ Files: `src/digraphx/parametric.py`, `src/digraphx/min_parametric_q.py`,
47
+ internal `src/digraphx/_parametric_base.py`.
48
+
49
+ | API | Parameters | Default |
50
+ | --- | --- | --- |
51
+ | `MaxParametricSolver(digraph, omega)` | `omega` (strategy) | required |
52
+ | `MaxParametricSolver.run(dist, ratio)` | — | no tunables (internally `minimize=True`) |
53
+ | `MinParametricSolver(digraph, omega)` | `omega` | required |
54
+ | `MinParametricSolver.run(dist, ratio, update_ok, pick_one_only=False)` | `update_ok`; `pick_one_only: bool` | required; `False` |
55
+ | `_run_loop(..., *, minimize, update_ok=..., pick_one_only=False, alternate_direction=False)` (internal) | `update_ok`, `pick_one_only`, `alternate_direction` | `lambda old, new: True`; `False`; `False` |
56
+
57
+ - `MinParametricSolver.run` is the public surface
58
+ (`src/digraphx/min_parametric_q.py:125-131`).
59
+ - `alternate_direction=True` is fixed internally for the constrained solver.
60
+
61
+ ---
62
+
63
+ ## 3. Minimum cycle ratio
64
+
65
+ File: `src/digraphx/min_cycle_ratio.py`.
66
+
67
+ | API | Parameters | Default |
68
+ | --- | --- | --- |
69
+ | `MinCycleRatioSolver(digraph)` | `digraph` | required |
70
+ | `MinCycleRatioSolver.run(dist, ratio0)` | `ratio0` (initial ratio) | required |
71
+ | `CycleRatioAPI(digraph, result_type)` | `result_type` (`Fraction` or `float`) | required (selects numeric precision) |
72
+ | `set_default(digraph, weight, value)` | `weight: str`, `value` | required |
73
+
74
+ ---
75
+
76
+ ## 4. Min-cost flow (cycle-canceling)
77
+
78
+ File: `src/digraphx/mcf.py`.
79
+
80
+ | API | Parameters | Default |
81
+ | --- | --- | --- |
82
+ | `cycle_canceling_mcf(g, demands, sink=None)` | `sink` | `None` |
83
+ | `VertexFilter(sink)` | `sink` | required |
84
+
85
+ - When `sink` is provided, the vertex-disjoint constraint is enabled; when
86
+ `None`, no constraint is enforced.
87
+ - Edge-data defaults consumed internally: `capacity` defaults to `inf`,
88
+ `weight` defaults to `0`; the bottleneck is truncated via `int(...)`
89
+ (`src/digraphx/mcf.py:61-62`, `:239`, `:304`).
90
+
91
+ ---
92
+
93
+ ## 5. Graph containers
94
+
95
+ Files: `src/digraphx/tiny_digraph.py`, `src/digraphx/csr_digraph.py`.
96
+
97
+ | API | Parameters | Default |
98
+ | --- | --- | --- |
99
+ | `TinyDiGraph.init_nodes(num_nodes)` | `num_nodes: int` | required |
100
+ | `TinyDiGraph.add_edge(u, v, **attr)` | free-form attributes | — |
101
+ | `CSRDiGraph.init_nodes(num_nodes)` | `num_nodes: int` | required |
102
+ | `CSRDiGraph.add_edge(u, v, **attr)` | free-form attributes | — |
103
+ | `CSRDiGraph.freeze()` | — | — |
104
+ | `DiGraphAdapter.get_edge_data(u, v, default=None)` | `default` | `None` |
105
+
106
+ ---
107
+
108
+ ## 6. Repository utilities (experiments, not core library)
109
+
110
+ File: `experimental/spare_tsv.py`.
111
+
112
+ | Function | Parameters | Default |
113
+ | --- | --- | --- |
114
+ | `formGraph(T, pos, mu, eta, seed=None)` | `seed` | `None` |
115
+ | `vdc(n, base=2)` | `base` | `2` |
116
+ | `vdcorput(n, base=2)` | `base` | `2` |
117
+ | `vdcorput_iter(n, base=2)` | `base` | `2` |
118
+ | `showPaths(gra, pos, N, edgeProbs=1.0, path=None, visibleNodes=None, guards=None)` | `edgeProbs`, `path`, `visibleNodes`, `guards` | `1.0`, `None`, `None`, `None` |
119
+ | `setup_network_flow(gra, pos, primal_count, capacity)` | `capacity` | required |
120
+ | `solve_network_flow(gra, sink_node)` | `sink_node` | required |
121
+
122
+ ---
123
+
124
+ ## 7. Fixed values in tests and benchmarks (not library defaults)
125
+
126
+ | Source | Constant | Value |
127
+ | --- | --- | --- |
128
+ | `benchmarks/test_benchmarks.py:21-23` | `SEED`, `SIZES`, `MCR_SIZES` | `1234`, `[200, 1000]`, `[50, 100]` |
129
+ | `benchmarks/test_benchmarks.py:53-61` | node degree | `5` / `6` |
130
+ | `tests/test_*howard*.py:14,64,107` | test-local Howard helper `max_iter` | `2000` |
131
+ | `experiments/experi.py:13-23` and `experi-descent*.py` | `N`, `M`, `r`, `mu`, `eta`, `seed`, `xbase`, `ybase` | `155`, `40`, `4`, `0.12`, `1.6`, `5`, `2`, `3` |
132
+ | `docs/examples/plot_*.py` | `seed` | `42` |
133
+
134
+ ---
135
+
136
+ ## Summary of genuine tunables
137
+
138
+ | Tunable | Default | Location |
139
+ | --- | --- | --- |
140
+ | `update_ok` predicate (relaxation gate) | `lambda old, new: True` | `src/digraphx/_cycle_base.py:37`, `src/digraphx/_parametric_base.py:37` |
141
+ | `howard`/`howard_pred`/`howard_succ` `max_iter` | `None` (unbounded; raises `RuntimeError` if set and exceeded) | `src/digraphx/neg_cycle.py`, `src/digraphx/neg_cycle_q.py` |
142
+ | `MinParametricSolver.run(..., pick_one_only)` | `False` | `src/digraphx/min_parametric_q.py:130` |
143
+ | `cycle_canceling_mcf(..., sink)` | `None` | `src/digraphx/mcf.py:254` |
144
+ | `CycleRatioAPI(..., result_type)` | required (`Fraction` / `float`) | `src/digraphx/min_cycle_ratio.py:113` |
145
+ | `howard_search(..., verify)` (internal) | `True` | `src/digraphx/_cycle_base.py:210` |
146
+ | `_run_loop(..., alternate_direction)` (internal) | `False` | `src/digraphx/_parametric_base.py:39` |
147
+ | `formGraph(..., seed)` | `None` | `experimental/spare_tsv.py:34` |
148
+ | `vdc` / `vdcorput` / `vdcorput_iter(..., base)` | `2` | `experimental/spare_tsv.py:10,20,25` |
149
+ | `showPaths` overlay options | `edgeProbs=1.0`, `path=None`, `visibleNodes=None`, `guards=None` | `experimental/spare_tsv.py:54` |
150
+
151
+ Everything else is problem data (graph, distances, ratio, demands) or a
152
+ hardcoded constant (`capacity=inf`, `weight=0`, integer bottleneck,
153
+ `verify=False` on `howard_succ`).
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.6",
3
- "distance": 0,
4
- "node": "g0ed3c93296539f391931daa4c1b99888a64a389f",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-04"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes