generalized-maxflow 0.3.0__py3-none-win_arm64.whl

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.
@@ -0,0 +1,686 @@
1
+ Metadata-Version: 2.1
2
+ Name: generalized-maxflow
3
+ Version: 0.3.0
4
+ Summary: Generalized maximum flow: maximum flow with per-edge gain multipliers
5
+ Keywords: maximum-flow,generalized-flow,network-flow,graph,optimization,lossy-network
6
+ Author: wesboyt
7
+ Maintainer: wesboyt
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 wesboyt
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Programming Language :: C++
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: Implementation :: CPython
37
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
38
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
39
+ Project-URL: Homepage, https://github.com/wesboyt/generalized-maxflow
40
+ Project-URL: Source, https://github.com/wesboyt/generalized-maxflow
41
+ Project-URL: Issues, https://github.com/wesboyt/generalized-maxflow/issues
42
+ Requires-Python: >=3.8
43
+ Description-Content-Type: text/markdown
44
+
45
+ # generalized-maxflow
46
+
47
+ Maximum flow through a network whose edges **change the amount that passes
48
+ through them**.
49
+
50
+ Every edge carries a capacity and a multiplier. Pushing `x` units into an edge
51
+ consumes `x` units of its capacity and delivers `multiplier · x` units at the
52
+ far end, so flow can leak away (multiplier below 1), be conserved (exactly 1) or
53
+ be amplified (above 1). This is the *generalized maximum flow* problem, and it
54
+ is what you want for power transmission with line losses, currency conversion,
55
+ evaporation and spoilage in logistics, or any pipeline whose stages have a
56
+ yield.
57
+
58
+ The interface is a JSON adjacency list, the same for every binding:
59
+
60
+ ```json
61
+ {
62
+ "source": "s",
63
+ "sink": "t",
64
+ "adjacency": {
65
+ "s": [{"to": "a", "capacity": 10, "multiplier": 1.0}],
66
+ "a": [{"to": "t", "capacity": 5, "multiplier": 2.0}]
67
+ }
68
+ }
69
+ ```
70
+
71
+ ```json
72
+ {
73
+ "status": "optimal",
74
+ "max_flow": 10,
75
+ "source_outflow": 5,
76
+ "paths": [
77
+ {
78
+ "kind": "path",
79
+ "flow_in": 5, "flow_out": 10, "gain": 2,
80
+ "nodes": ["s", "a", "t"],
81
+ "steps": [
82
+ {"edge": 0, "from": "s", "to": "a", "direction": "forward", "multiplier": 1, "flow_in": 5, "flow_out": 5},
83
+ {"edge": 1, "from": "a", "to": "t", "direction": "forward", "multiplier": 2, "flow_in": 5, "flow_out": 10}
84
+ ],
85
+ "bottleneck": {"edge": 1, "from": "a", "to": "t", "direction": "forward", "step": 1}
86
+ }
87
+ ],
88
+ "certificate": {"no_augmenting_path": true, "no_generating_cycle_reaching_sink": true}
89
+ }
90
+ ```
91
+
92
+ Note what the answer is **not**: 5. The constriction is `a -> t`, which admits
93
+ only 5 units, but those 5 units are doubled on the way through. A generalized
94
+ flow solver has to account for gain downstream of the constriction.
95
+
96
+ ## Try it
97
+
98
+ ### **[→ wesboyt.github.io/generalized-maxflow](https://wesboyt.github.io/generalized-maxflow/)**
99
+
100
+ The solver compiled to WebAssembly, running in the page: the worked examples
101
+ below plus a box for your own graphs. Nothing is uploaded — the whole solver is
102
+ 148 KB of WebAssembly and every graph is solved locally.
103
+
104
+ To run it yourself:
105
+
106
+ ```bash
107
+ bindings/wasm/build.sh # needs the Emscripten SDK
108
+ cp bindings/wasm/pkg/{index.mjs,gmaxflow.js,gmaxflow.wasm} docs/demo/
109
+ python -m http.server -d docs/demo 8000 # then open localhost:8000
110
+ ```
111
+
112
+ Every push also builds it in CI, downloadable as the `demo-site` artifact of the
113
+ **demo** workflow; running that workflow by hand republishes the hosted copy.
114
+
115
+ ## Install
116
+
117
+ ```bash
118
+ pip install generalized-maxflow
119
+ ```
120
+
121
+ The wheels are tagged `py3-none-<platform>`: the solver is a plain C library
122
+ loaded through `ctypes`, never linked against libpython, so one wheel per OS and
123
+ CPU serves every CPython from 3.8 up, the free-threaded builds, and PyPy. There
124
+ are wheels for Linux (x86-64 and aarch64, glibc and musl), macOS (x86-64 and
125
+ arm64) and Windows (x86-64 and ARM64); anywhere else pip builds the sdist, which
126
+ needs CMake and a C++17 compiler. The solve releases the GIL.
127
+
128
+ ```bash
129
+ npm install generalized-maxflow # WebAssembly build, browser and Node
130
+ ```
131
+
132
+ From source you need CMake 3.18 and a C++17 compiler:
133
+
134
+ ```bash
135
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
136
+ ./build/gmaxflow graph.json
137
+ ```
138
+
139
+ ## Use it
140
+
141
+ Python:
142
+
143
+ ```python
144
+ import gmaxflow
145
+
146
+ answer = gmaxflow.solve(graph) # graph is a dict or JSON text
147
+ print(answer["max_flow"])
148
+
149
+ for path in answer["paths"]:
150
+ print(path["nodes"], path["flow_in"], "->", path["flow_out"],
151
+ "constricted at", path["bottleneck"]["edge"])
152
+ ```
153
+
154
+ JavaScript / WebAssembly:
155
+
156
+ ```js
157
+ import createSolver from "generalized-maxflow";
158
+
159
+ const solver = await createSolver();
160
+ const answer = solver.solve(graph); // takes and returns plain objects
161
+ console.log(answer.max_flow);
162
+ ```
163
+
164
+ Command line (reads a file or stdin, writes JSON):
165
+
166
+ ```bash
167
+ gmaxflow graph.json
168
+ cat graph.json | gmaxflow --compact
169
+ ```
170
+
171
+ C++:
172
+
173
+ ```cpp
174
+ #include "gmf/gmf.hpp"
175
+
176
+ gmf::Problem problem;
177
+ problem.num_nodes = 3;
178
+ problem.source = 0;
179
+ problem.sink = 2;
180
+ problem.edges = {{0, 1, 10.0, 1.0}, {1, 2, 5.0, 2.0}};
181
+
182
+ const gmf::Result result = gmf::solve(problem);
183
+ // result.max_flow == 10, result.augmentations holds the filled paths
184
+ ```
185
+
186
+ ## Input
187
+
188
+ | field | meaning |
189
+ | --- | --- |
190
+ | `source`, `sink` | node labels; any string or number. Aliases: `src`/`start`, `dst`/`target`/`end` |
191
+ | `adjacency` | `{tail: [edge, ...]}`, or an array whose index is the tail |
192
+ | `edges` | a flat list; each edge names its own `from` |
193
+ | `nodes` | optional, fixes the node ordering |
194
+ | `options` | see below |
195
+
196
+ An edge is an object with `to`, `capacity` and `multiplier` (aliases: `head`/`v`,
197
+ `cap`, `gain`/`mult`; `from`/`tail`/`u` when it is not implied by the adjacency
198
+ key), or a positional array — `[to, capacity, multiplier]` inside an adjacency
199
+ list, `[from, to, capacity, multiplier]` in an `edges` list. A missing or null
200
+ capacity means uncapacitated. Capacities must be non-negative and multipliers
201
+ finite and strictly positive.
202
+
203
+ | option | default | meaning |
204
+ | --- | --- | --- |
205
+ | `epsilon` | `1e-12` | relative tolerance, scaled by the largest capacity |
206
+ | `max_iterations` | size-derived | stop after this many augmentations |
207
+ | `collect_paths` | `true` | set false to drop the `paths` array and save memory |
208
+ | `cancel_generating_cycles` | `true` | set false to reject amplifying inputs instead of solving them |
209
+ | `use_potentials` | `true` | set false to force the slow reference search (for cross-checking) |
210
+ | `fat_path` | `true` | run the Tardos–Wayne Fat-Path scaling phases; false is plain highest-gain augmentation |
211
+ | `gain_scaling` | `true` | round gains to powers of $`(1+\xi)^{1/n}`$ inside those phases |
212
+ | `pretty` | `false` | indent the output |
213
+
214
+ ## Output
215
+
216
+ `status` is one of `optimal`, `unbounded`, `iteration_limit`, `suboptimal`,
217
+ `generating_cycle_rejected` or `invalid_input`, and `optimal` is a boolean.
218
+
219
+ `max_flow` is what arrives at the sink and `source_outflow` is what left the
220
+ source; with lossy or amplifying edges these differ, and that is the point.
221
+
222
+ `paths` is the sequence of augmentations in the order they were filled, each
223
+ with the `nodes` it visits, the `steps` it took, the flow entering and leaving,
224
+ the `gain`, and the `bottleneck` — the constricting edge that decided how much
225
+ this path could carry. A step's `direction` is `backward` when the augmentation
226
+ pushed flow *back* over an edge to re-route earlier flow. A `kind` of
227
+ `flow_generating_cycle` means the entry is a cycle whose multipliers multiply to
228
+ more than one, together with the route carrying the flow it manufactured.
229
+
230
+ `certificate` is the proof the answer is maximum, and `stats` reports iterations,
231
+ searches, the Fat-Path phases and augmentations, the cycles cancelled, a timing
232
+ breakdown by phase and the solver's own memory footprint.
233
+
234
+ ---
235
+
236
+ # The algorithm
237
+
238
+ ![How a constriction is found and how the update propagates](docs/algorithm-flow.svg)
239
+
240
+ ## 1. The problem
241
+
242
+ Let $`G=(V,E)`$ be a directed graph with a source $`s`$ and a sink $`t`$. Each arc
243
+ $`e\in E`$ has a capacity $`u(e)\in[0,\infty]`$ and a **multiplier** (or gain)
244
+ $`\gamma(e)>0`$. A *generalized flow* is $`f:E\to\mathbb{R}`$ with
245
+
246
+ ```math
247
+ 0 \le f(e) \le u(e) \quad \forall e \in E,
248
+ ```
249
+
250
+ ```math
251
+ \sum_{e=(w,v)} \gamma(e)f(e) \;-\!\! \sum_{e=(v,w)} f(e) \;=\; 0
252
+ \qquad \forall v \in V\setminus\{s,t\}.
253
+ ```
254
+
255
+ Pushing $`f(e)`$ units into $`e=(v,w)`$ at $`v`$ delivers $`\gamma(e)f(e)`$ at $`w`$. The
256
+ objective is the net amount arriving at the sink,
257
+
258
+ ```math
259
+ |f| \;=\; \sum_{e=(w,t)} \gamma(e)f(e) \;-\!\! \sum_{e=(t,w)} f(e),
260
+ ```
261
+
262
+ to be maximised. With $`\gamma \equiv 1`$ this is ordinary maximum flow. It is a
263
+ linear program, which is how `tools/lp_check.py` verifies the solver
264
+ independently.
265
+
266
+ For a path or cycle $`P`$ write $`\gamma(P)=\prod_{e\in P}\gamma(e)`$. A cycle $`C`$
267
+ with $`\gamma(C)>1`$ is **flow-generating**: traversing it returns more than was
268
+ put in.
269
+
270
+ ## 2. The residual network
271
+
272
+ Each arc $`e=(v,w)`$ contributes two residual arcs,
273
+
274
+ | arc | capacity | multiplier |
275
+ | --- | --- | --- |
276
+ | $`e`$, forward | $`u(e)-f(e)`$ | $`\gamma(e)`$ |
277
+ | $`e^{-1}=(w,v)`$, backward | $`\gamma(e)f(e)`$ | $`1/\gamma(e)`$ |
278
+
279
+ The backward capacity is what the arc *delivered*, and its multiplier is the
280
+ reciprocal, because retracting $`y`$ units at $`w`$ returns $`y/\gamma(e)`$ at $`v`$.
281
+ Pushing $`x`$ into either direction is the single rule
282
+
283
+ ```math
284
+ r(a) \mathrel{-}= x, \qquad r(a^{-1}) \mathrel{+}= x\,\gamma(a).
285
+ ```
286
+
287
+ Reverse arcs are required for correctness, not speed. Greedy highest-gain
288
+ filling without them gets stuck exactly as plain Ford–Fulkerson does: the
289
+ instance in `examples/rerouting.json` has optimum 3 and greedy reaches 2. Note
290
+ also that the reverse of a lossy arc is a gain-amplifying one, so after the
291
+ first augmentation no search may assume all multipliers are below 1.
292
+
293
+ ## 3. The bottleneck obeys the chain rule
294
+
295
+ Along a path $`P=(a_1,\dots,a_k)`$ the amount entering $`a_i`$ when $`x`$ units enter
296
+ $`a_1`$ is $`x\prod_{j<i}\gamma(a_j)`$. The largest feasible $`x`$ is therefore
297
+
298
+ ```math
299
+ \delta(P) \;=\; \min_{1\le i\le k} \; \frac{r(a_i)}{\prod_{j<i}\gamma(a_j)},
300
+ ```
301
+
302
+ and the arc attaining the minimum is the **constricting edge**. It is not the
303
+ arc of smallest residual capacity: gain downstream of a constriction counts.
304
+
305
+ When a walk uses an arc in both directions — a cycle one way and the route
306
+ carrying its excess the other — the two uses *cancel* rather than compete.
307
+ Writing $`\alpha`$ for the total coefficient on the forward arc of an edge and
308
+ $`\beta`$ for that on its backward arc, the net change to that edge's flow per
309
+ unit $`x`$ is $`\nu = \alpha - \beta/\gamma`$, and the limit is
310
+
311
+ ```math
312
+ x \;\le\; \begin{cases}
313
+ r(e)\,/\,\nu & \nu > 0,\\[2pt]
314
+ r(e^{-1})\,/\,(-\nu\,\gamma) & \nu < 0,\\[2pt]
315
+ \infty & \nu = 0.
316
+ \end{cases}
317
+ ```
318
+
319
+ Charging the two directions separately stays *feasible*, which is why the error
320
+ is easy to miss; it only understates what the edge allows. It also made one
321
+ 40-node instance repeat a single cycle forever with $`x`$ shrinking by a tenth
322
+ each time — 500 000 iterations moved the delivered flow by $`3\times10^{-6}`$.
323
+
324
+ ## 4. Highest-gain augmentation
325
+
326
+ Maximising a product of multipliers is minimising a sum of logarithms. Set
327
+
328
+ ```math
329
+ c(a) \;=\; -\ln \gamma(a),
330
+ ```
331
+
332
+ so a highest-gain path is a shortest path under $`c`$, and a flow-generating cycle
333
+ is a negative-cost cycle. The solver works in this log space throughout, which
334
+ also stops long lossy chains from underflowing: fifty halvings give
335
+ $`\gamma(P)=2^{-50}`$, and a solver multiplying doubles loses such paths.
336
+
337
+ Onaga's loop: while a residual $`s\to t`$ path exists, augment along one of
338
+ maximum gain. The choice of *maximum* is what makes it correct. It is the exact
339
+ finish of this solver; most of the flow is first placed by the Fat-Path phases
340
+ of section 8, which choose paths by capacity as well as gain.
341
+
342
+ > **Theorem (Onaga, 1966).** If the residual network contains no
343
+ > flow-generating cycle, then augmenting along a maximum-gain path leaves it
344
+ > with none.
345
+
346
+ Augmenting along a lesser path can break that invariant, after which the
347
+ stopping test below no longer certifies anything.
348
+
349
+ ## 5. Node potentials
350
+
351
+ Maintain $`\pi:V\to\mathbb{R}`$ with non-negative **reduced costs**
352
+
353
+ ```math
354
+ c_\pi(a) \;=\; c(a) + \pi(v) - \pi(w) \;\ge\; 0 \qquad \forall a=(v,w)
355
+ \text{ residual}.
356
+ ```
357
+
358
+ Two facts make this the centre of the implementation.
359
+
360
+ **Such a $`\pi`$ exists if and only if there is no flow-generating cycle.**
361
+ Summing $`c_\pi\ge 0`$ around a cycle telescopes to $`\sum c(a)\ge 0`$, i.e.
362
+ $`\gamma(C)\le 1`$. So a valid potential *is* a certificate that no amplifying
363
+ cycle exists, and the search for one is the same computation as the search for
364
+ the other.
365
+
366
+ **With $`\pi`$ in hand each augmentation is one Dijkstra.** Reduced costs are
367
+ non-negative, so the highest-gain path comes from an early-exit Dijkstra over
368
+ $`c_\pi`$ instead of a label-correcting sweep over every arc. Tardos and Wayne put
369
+ it the same way: *"an augmentation takes $`\tilde O(m)`$ time, using an algorithm
370
+ based on Dijkstra's shortest path algorithm."*
371
+
372
+ After a search that settles $`S`$ with distances $`d`$, stopping at $`d_{\text{stop}}`$:
373
+
374
+ ```math
375
+ \pi(v) \;\mathrel{+}=\; d(v) - d_{\text{stop}} \qquad \forall v \in S,
376
+ ```
377
+
378
+ leaving every other potential alone. This is valid because adding a constant to
379
+ *all* potentials changes no reduced cost, so the update is the "shift by
380
+ $`d_{\text{stop}}`$" that costs nothing plus a correction only on settled nodes.
381
+ For $`a=(v,w)`$ with $`v`$ settled and $`w`$ not, $`d(w)\ge d_{\text{stop}}`$ gives
382
+ $`c_\pi(a)+d(v)-d_{\text{stop}}\ge 0`$; the other cases are immediate. Arcs on the
383
+ chosen path end at $`c_\pi=0`$, hence so do their reverses, which is why the new
384
+ residual arcs an augmentation creates keep $`\pi`$ valid.
385
+
386
+ To re-establish $`\pi`$ from scratch, run a label-correcting sweep with every node
387
+ of the sink-reaching subgraph starting at $`\lambda(v)=0`$ and relaxing
388
+ $`\lambda(w)\ge\lambda(v)+\ln\gamma(a)`$; then $`\pi=-\lambda`$. Starting every node
389
+ at zero, rather than only the source, is what lets it find a cycle *anywhere* —
390
+ a cycle can manufacture flow with the source cut off entirely, as
391
+ `examples/currency_arbitrage.json` does. Any $`\lambda`$ satisfying the
392
+ constraints works, not just the minimal one, so after a push the sweep is
393
+ re-seeded only from the nodes that push disturbed.
394
+
395
+ ## 6. Finding cycles without paying for them
396
+
397
+ The textbook negative-cycle test waits for a node to be relaxed more than $`|V|`$
398
+ times, which means up to $`|V|`$ passes over every arc *after* the cycle has
399
+ already formed. The parent forest closes within a few relaxations of the cycle
400
+ appearing, so the solver scans it for a cycle every $`|V|`$ relaxations — an
401
+ $`O(V)`$ walk with three-colour marking, so $`O(1)`$ amortised per relaxation. On a
402
+ dense amplifying network this replaced 14 million relaxations per cycle found
403
+ with a few thousand.
404
+
405
+ Two guards keep it honest. Relaxation requires improvement by at least
406
+ $`\varepsilon`$, so a chain can close on a cycle whose gain is not actually above
407
+ one; every hit is verified before being acted on. And parent pointers outlive
408
+ the search that set them, so a cycle read off them may name arcs that have since
409
+ been saturated — every arc is re-checked for usable residual, because acting on
410
+ a stale cycle once caused the solver to retire a live arc and return a flow 26%
411
+ below optimum labelled `optimal`.
412
+
413
+ ## 7. Stopping, with a certificate
414
+
415
+ A flow-generating cycle together with a residual path from it to the sink is a
416
+ **generalized augmenting path** (GAP): it manufactures flow and delivers it.
417
+
418
+ > **Theorem (Onaga; Truemper, 1977).** A feasible generalized flow is maximum if
419
+ > and only if its residual network contains neither an $`s\to t`$ path nor a
420
+ > flow-generating cycle with a path to the sink.
421
+
422
+ This follows from LP duality. An improving direction is a flow in the residual
423
+ network conserved off $`\{s,t\}`$ that raises the net at $`t`$; decomposing it gives
424
+ source-to-sink paths, flow-generating cycles with their excess routed somewhere,
425
+ flow-absorbing cycles, and *bicycles* — a generating cycle joined to an
426
+ absorbing one. Only two kinds raise the net at $`t`$, so if any direction
427
+ improves, one of those two is present.
428
+
429
+ The solver stops on this condition and reports it. Both checks are recomputed
430
+ from scratch at the end, deliberately using the *slow* routines — plain
431
+ reachability for the path, full Bellman–Ford rounds for the cycle — rather than
432
+ the accelerated ones the main loop runs on, because a certificate sharing the
433
+ loop's heuristics could only confirm the loop's own blind spots. `status` is
434
+ `optimal` only when both hold, and the certificate also carries the largest
435
+ conservation error and capacity violation.
436
+
437
+ `tools/gap_probe.py` performs the same two checks in Python from the reported
438
+ edge flows, sharing no code with the solver.
439
+
440
+ ## 8. Fat-Path with gain-scaling
441
+
442
+ Onaga's loop has no useful bound on the number of augmentations: highest-gain
443
+ first will happily spend an augmentation on a path that carries $`10^{-9}`$. The
444
+ solver therefore runs Tardos and Wayne's *Rounded Fat-Path* (IPCO 1998, §6)
445
+ first, and leaves Onaga only the last scrap of the gap.
446
+
447
+ **Fat paths.** Write $`\mu(v)`$ for the inverse of the best gain from $`v`$ to the
448
+ sink, the *canonical label*. An arc is $`\delta`$-*fat* when it can raise the
449
+ sink's excess by $`\delta`$, i.e. $`r(a)/\mu(v)\ge\delta`$, and a path is fat
450
+ when every arc is. With potentials, $`\mu(v)=e^{\pi(t)-\pi(v)}`$, so the test is
451
+ one comparison per arc against a per-node threshold. The phases are:
452
+
453
+ ```math
454
+ \begin{aligned}
455
+ &\Delta \leftarrow \Delta_0 \ge \mathrm{OPT}-|f| \\
456
+ &\textbf{repeat}\\
457
+ &\quad \textsc{CancelCycles} \quad\text{(the relabelled network becomes lossy)}\\
458
+ &\quad \textsc{FatAugmentations}(\Delta/2m) \quad\text{(highest-gain fat paths until none)}\\
459
+ &\quad \Delta \leftarrow \Delta/2\\
460
+ &\textbf{until } \Delta \text{ is below the tolerance}
461
+ \end{aligned}
462
+ ```
463
+
464
+ Each fat augmentation either delivers $`\delta=\Delta/2m`$ or empties its source,
465
+ so a phase makes at most $`n+2m`$ of them, and the discrepancy halves per phase:
466
+ $`O(\log(n/\xi))`$ phases for a $`\xi`$-optimal flow. $`\Delta_0`$ is the cheaper
467
+ of two cuts, what the sink's residual in-arcs can deliver and the relabelled
468
+ capacity out of the source; a phase in which nothing fat exists jumps $`\Delta`$
469
+ straight down to the fattest arc the search had to skip, since every path leaves
470
+ the fat subnetwork through one.
471
+
472
+ **Canonical labels.** Every phase starts from exact labels: one Dijkstra
473
+ *backwards* from the sink over the reduced costs gives $`D(v)`$, and
474
+ $`\pi(v)\leftarrow\pi(v)-D(v)`$ makes $`e^{\pi(v)-\pi(t)}`$ the true best gain to
475
+ the sink while keeping every reduced cost non-negative (it is the triangle
476
+ inequality $`D(v)\le c_\pi(v,w)+D(w)`$). Reading fatness off stale potentials
477
+ instead — which on a lossy network claim a gain of one everywhere — filled paths
478
+ that delivered almost nothing and cost 99 000 cycle cancellations on a 1 000-node
479
+ instance.
480
+
481
+ **Gain-scaling.** Rounding each gain down to a power of $`b=(1+\xi)^{1/n}`$ is
482
+ rounding its cost up to a multiple of $`q=\ln b`$. In the relabelled lossy network
483
+ those costs are the reduced costs, so the rounded network is a Dijkstra on the
484
+ integer keys $`\lceil c_\pi(a)/q\rceil`$. A search on those keys leaves every
485
+ reduced cost above $`-q`$ — never below, however many searches run — so the phases
486
+ keep $`q`$-optimal potentials, and CancelCycles need only remove cycles whose total
487
+ log-gain exceeds $`q`$: exactly the cycles a sweep that ignores improvements
488
+ smaller than $`q`$ cannot settle around. $`\xi`$ shrinks with $`\Delta`$ as
489
+ $`\xi=\min(\tfrac12,\Delta/\Delta_0)`$. Rounding costs at most a factor
490
+ $`b^{|P|}\le 1+\xi`$ on any path, which is Tardos and Wayne's Theorem 4.
491
+
492
+ **CancelCycles.** After a phase the potentials are valid on every arc except the
493
+ thin ones the phase skipped, and every generating cycle passes through one of
494
+ those. For a violating arc $`(v,w)`$ the most generating cycle through it is the
495
+ arc plus the highest-gain $`w\to v`$ path over the certified arcs — one early-exit
496
+ Dijkstra, after whose potential update the arc's reduced cost *is* the cycle's
497
+ log-loss. Cancelling through violating arcs first, and only then running the
498
+ label-correcting sweep for whatever two violating arcs share, cut a 3 200-node
499
+ amplifying instance from 5 878 cancellations and 15.9 s to 313 and 1.3 s. A
500
+ cancelled cycle's excess is routed to the sink by the highest-gain path over the
501
+ certified arcs, not the fewest hops: the excess belongs on the rest of the path
502
+ it was rerouted from, and an arbitrary route creates new cycles as fast as they
503
+ are removed.
504
+
505
+ **Two departures from the paper.** Both come from reporting a flow that conserves
506
+ at every node. The paper keeps its flow in the rounded network and
507
+ *interprets* it in the original at the end, which strands excess at interior
508
+ nodes; here a path chosen in the rounded network is pushed with its true gains,
509
+ so it delivers at least what the rounded network promised and nothing is
510
+ stranded. And the paper's CancelCycles leaves each cycle's excess on the cycle
511
+ for the next augmentations to collect; here it is carried to the sink with the
512
+ cycle, as a generalized augmenting path. The paper's exact algorithm ends by
513
+ rounding a $`B^{-4m}`$-optimal flow, which means nothing in floating point; instead
514
+ the phases stop once $`\Delta`$ is below the tolerance and Onaga's loop finishes
515
+ on the true gains, from a flow already within the last $`\Delta`$ of optimal.
516
+ `fat_path: false` and `gain_scaling: false` turn each layer off, and the tests
517
+ check all three variants against each other.
518
+
519
+ ## 9. Complexity
520
+
521
+ Each search is $`O(m\log n)`$ with early exit. The Fat-Path phases bound the
522
+ augmentations at $`n+2m`$ per phase over $`O(\log(n/\xi))`$ phases, which is what
523
+ replaces Onaga's unbounded count — Onaga's algorithm alone *"may run in
524
+ exponential time (or may not even terminate for irrational inputs)"*. What the
525
+ implementation does not inherit is the paper's bound on CancelCycles, which
526
+ rests on Goldberg–Tarjan cancel-and-tighten over integer costs; the
527
+ violating-arc Dijkstra and the label-correcting sweep here measure faster on
528
+ every input tried but carry no such guarantee, and neither does the exact
529
+ finish. There is a limit, `1000 + 20(n+m)` iterations by default, and reaching
530
+ it is reported as `iteration_limit` rather than dressed up as an answer.
531
+
532
+ | | bound | note |
533
+ | --- | --- | --- |
534
+ | Goldberg–Plotkin–Tardos 1991 | first polynomial combinatorial | cancels *all* generating cycles as a batch |
535
+ | Goldfarb–Jin–Orlin 1997 | $`\tilde O(m^3\log B)`$ | capacity scaling, highest-gain paths |
536
+ | **Tardos–Wayne 1998** | matches best known | **implemented here** (section 8): Fat-Path with gain-scaling |
537
+ | Radzik 2004 | $`\tilde O(m^2 n\log B)`$ | best known weakly polynomial |
538
+ | Végh 2016 | strongly polynomial | first such result; continuous scaling |
539
+ | Olver–Végh 2017 | $`O((m+n\log n)\,mn\log(n^2/m))`$ | simpler and faster strongly polynomial |
540
+
541
+ The strongly polynomial results are landmarks but not implementation guides: at
542
+ $`n=10^3`$, $`m=5\times10^3`$ the Olver–Végh bound is on the order of $`10^{11}`$
543
+ operations, far above what augmenting paths cost in practice. The preflow-push
544
+ variant in the Tardos–Wayne paper is the natural next step.
545
+
546
+ Two notes on floating point. `epsilon` is relative, scaled by the largest finite
547
+ capacity, so "optimal" means optimal to that tolerance. An arc is retired from
548
+ the search only when nothing at all can move through it.
549
+
550
+ ## 10. On concurrency
551
+
552
+ The breadth-first sweep is the one phase that fans out naturally, so it was
553
+ built multi-threaded, measured, and then removed. The measurements:
554
+
555
+ | | breadth-first sweep | share of solve | ceiling at 16 cores |
556
+ | --- | --- | --- | --- |
557
+ | lossy, 16k nodes | 0.001 s | **0.06 %** | 1.0006× |
558
+ | amplifying, 3.2k nodes | 0.232 s | 68 % | 2.7× in theory |
559
+
560
+ The dominant phase is Dijkstra, which is order-dependent: it settles nodes in
561
+ non-decreasing distance, and that order is what lets each node be finalised
562
+ once. Relaxed concurrent priority queues (skip-list or MultiQueue based) do give
563
+ parallel shortest paths, but they settle nodes out of order and re-settle them,
564
+ and here the *exact* maximum-gain path is what Onaga's invariant rests on — so
565
+ the risk lands on correctness, not just speed.
566
+
567
+ Meanwhile the parallelisable phase has been optimised into insignificance. The
568
+ sweep is 0.06 % of a lossy solve, so Amdahl caps the whole benefit at 1.0006×
569
+ no matter how many cores are thrown at it. On amplifying inputs there is real
570
+ headroom, but the absolute numbers are already a quarter of a second, each round
571
+ of the frontier is tens of microseconds of work, and a barrier costs a
572
+ comparable amount. Atomic doubles work — the removed implementation used
573
+ compare-exchange on `std::atomic<double>` — but WebAssembly needs
574
+ `SharedArrayBuffer` and cross-origin isolation for threads at all, so the
575
+ browser build would stay sequential regardless.
576
+
577
+ The parallel sweep as built was also 780× *slower* than the sequential one, for
578
+ two reasons worth recording: it spawned threads per frontier round rather than
579
+ once, and it lacked the parent-forest cycle check, so it ran the full $`|V|`$-round
580
+ bound while the sequential path aborted in a few thousand relaxations. Fixing
581
+ both would have bought a fraction of a 1.5× on one input class. It was removed
582
+ rather than kept.
583
+
584
+ ## Performance
585
+
586
+ AMD, single core, release build. `layered` is $`\sqrt n`$ layers of $`\sqrt n`$
587
+ nodes — deep graphs where shortest paths are hundreds of arcs long; `uniform` is
588
+ a random graph.
589
+
590
+ | instance | nodes | edges | Fat-Path | Onaga only | solver memory |
591
+ | --- | --- | --- | --- | --- | --- |
592
+ | lossy, layered | 40 002 | 159 600 | **9.2 s** | 15.2 s | 18 MB |
593
+ | lossy, layered | 15 878 | 63 252 | **1.03 s** | 1.59 s | 7 MB |
594
+ | lossy, uniform | 16 000 | 80 000 | 0.10 s | 0.11 s | 6 MB |
595
+ | lossy, uniform | 100 000 | 500 000 | 0.78 s | **0.49 s** | 40 MB |
596
+ | lossy, uniform | 250 000 | 1 250 000 | 2.2 s | 2.5 s | 100 MB |
597
+ | amplifying, layered | 3 194 | 12 656 | 1.33 s | **1.15 s** | 1 MB |
598
+ | amplifying, uniform | 3 200 | 16 000 | 0.35 s | 0.36 s | 1 MB |
599
+
600
+ "Onaga only" is `fat_path: false`. Fat-Path pays off where paths are long and
601
+ many: on the deep layered networks it fills fat paths with smaller searches, and
602
+ the margin grows with size. On shallow random graphs a handful of highest-gain
603
+ augmentations already finish the job and the phases are overhead — a backward
604
+ Dijkstra and a potential repair each — and on amplifying inputs much of the work
605
+ is the input's own cycles, which both variants cancel the same way. Against the
606
+ same benchmark before the potentials work: the 16k layered case was 11.07 s and
607
+ the 3 200-node amplifying case was **588 s**. Run it yourself with
608
+ `build/gmf_bench`, or `gmf_bench layered 16000 4 0.95 onaga` (or `exact`, for
609
+ Fat-Path without the gain rounding) to compare.
610
+
611
+ The layout is built for the inner loop. Arcs are renumbered so those leaving a
612
+ node are contiguous, and the relaxation reads head, residual and cost straight
613
+ through memory with no indirection. Multiplier and capacity live once per edge
614
+ rather than twice per arc — and are not copied from the caller's problem at all.
615
+ Incoming arcs are derived as twins of outgoing ones instead of a second
616
+ adjacency. Scratch arrays are stamped rather than cleared, so a search costs the
617
+ subgraph it touches rather than $`O(V)`$. The steps of every augmentation share
618
+ one array at 16 bytes each; `collect_paths: false` drops it entirely.
619
+
620
+ ## How it is checked
621
+
622
+ - Networks whose multipliers are all 1 are compared against an independent
623
+ Dinic maximum flow implementation; 300 random instances per run.
624
+ - `tools/lp_check.py` states the problem as a linear program and solves it with
625
+ scipy's HiGHS, which shares no code with the solver. Several thousand random
626
+ instances across lossy and amplifying regimes, up to 80 nodes, agree to 1e-6.
627
+ This is what caught every defect in `tests/test_regression.cpp`, including one
628
+ that only appeared on a single 40-node graph after thousands had passed.
629
+ - `tools/gap_probe.py` rebuilds the residual network from a reported answer in
630
+ Python and independently looks for the two things the certificate claims are
631
+ absent.
632
+ - The Dijkstra engine is checked against the label-correcting reference
633
+ (`use_potentials: false`) on random instances, and every answer is checked for
634
+ capacity feasibility and flow conservation.
635
+ - Fat-Path with gain-scaling, Fat-Path on exact gains and plain Onaga
636
+ (`fat_path: false`) are checked against each other on 240 random instances
637
+ across both regimes.
638
+ - `tests/test_regression.cpp` holds the instances that once produced a wrong
639
+ answer, each with the optimum from the linear program and a note on the defect
640
+ it caught.
641
+
642
+ ```bash
643
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
644
+ ./build/gmf_tests
645
+ python tools/lp_check.py --trials 400 --max-multiplier 2.0
646
+ python tools/lp_check.py --graph examples/power_grid.json
647
+ pytest tests/test_python.py
648
+ ```
649
+
650
+ ## Examples
651
+
652
+ `examples/` holds three graphs worth running, each annotated with what it shows:
653
+
654
+ | file | what it shows |
655
+ | --- | --- |
656
+ | `power_grid.json` | transmission with line losses: 1012 units leave the plant, 918 reach the city |
657
+ | `rerouting.json` | the optimum needs flow pushed *back* over an edge; greedy filling stops at 2 of 3 |
658
+ | `currency_arbitrage.json` | exchange rates as multipliers, so a rate cycle whose product exceeds 1 manufactures 69.78 USD from no starting capital |
659
+
660
+ ```bash
661
+ ./build/gmaxflow examples/currency_arbitrage.json
662
+ ```
663
+
664
+ ## References
665
+
666
+ - K. Onaga, *Dynamic programming of optimum flows in lossy communication nets*,
667
+ IEEE Trans. Circuit Theory, 1966.
668
+ - K. Truemper, *On max flows with gains and pure min-cost flows*, SIAM J. Appl.
669
+ Math., 1977.
670
+ - A. V. Goldberg, S. A. Plotkin, É. Tardos, *Combinatorial algorithms for the
671
+ generalized circulation problem*, Math. of OR 16(2), 1991.
672
+ - D. Goldfarb, Z. Jin, J. B. Orlin, *Polynomial-time highest-gain augmenting path
673
+ algorithms for the generalized circulation problem*, Math. of OR 22(4), 1997.
674
+ - É. Tardos, K. D. Wayne, *Simple generalized maximum flow algorithms*, IPCO
675
+ 1998. ([pdf](https://www.cs.princeton.edu/~wayne/papers/gain-scaling.pdf))
676
+ - T. Radzik, *Improving time bounds on maximum generalised flow computations*,
677
+ Theor. Comput. Sci. 312, 2004.
678
+ - L. A. Végh, *A strongly polynomial algorithm for generalized flow
679
+ maximization*, Math. of OR 42(2), 2017.
680
+ - N. Olver, L. A. Végh, *A simpler and faster strongly polynomial algorithm for
681
+ generalized flow maximization*, J. ACM 67(2), 2020.
682
+ - R. K. Ahuja, T. L. Magnanti, J. B. Orlin, *Network Flows*, 1993, chapter 15.
683
+
684
+ ## License
685
+
686
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ gmaxflow/__init__.py,sha256=ewcr-uowEcrAYXQaHY5tLv9YG_WkdYiwD4Ar98HQCtE,4205
2
+ gmaxflow/__main__.py,sha256=V1aoKenDW4BcfApRmI1Bz-51CsK5W2ShlrtkJ-fleM4,1964
3
+ gmaxflow/_native/gmaxflow_core.dll,sha256=oOuFRGfIOERHwKqFL8WOnvgvZ1z6YkozxgjKlzvTsfw,267776
4
+ gmaxflow/_native.py,sha256=jA4yv3i8_VAVWhvs_sIUCTjmSfaZ7_yn7rNym0uqIcc,2265
5
+ gmaxflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ generalized_maxflow-0.3.0.dist-info/METADATA,sha256=G5oiIfA-xV5JjyGX5faQn_UXF0jBEYRYiwPtfg4bIiQ,32219
7
+ generalized_maxflow-0.3.0.dist-info/WHEEL,sha256=D69HPxC3SlbabnD-f_ROoJhjrQemtxP7K3VXxdgu2k4,102
8
+ generalized_maxflow-0.3.0.dist-info/entry_points.txt,sha256=GatboyWibkoW3FvBO4RD0iTgwsawgaHMoDpzS-BVnPo,53
9
+ generalized_maxflow-0.3.0.dist-info/licenses/LICENSE,sha256=UEVF4aLh-igVqqdFowYCunJg4iQBDLWriO1w89Mq4lM,1064
10
+ generalized_maxflow-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: scikit-build-core 1.1.1
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_arm64
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gmaxflow = gmaxflow.__main__:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wesboyt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
gmaxflow/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ """Generalized maximum flow: maximum flow with per-edge gain multipliers.
2
+
3
+ Every edge carries a capacity and a multiplier. Pushing x units into an edge
4
+ consumes x units of its capacity and delivers ``multiplier * x`` units at the
5
+ other end, so flow can be lost (a multiplier below 1) or amplified (above 1).
6
+ The solver maximises what arrives at the sink.
7
+
8
+ >>> import gmaxflow
9
+ >>> gmaxflow.max_flow({
10
+ ... "source": "s",
11
+ ... "sink": "t",
12
+ ... "adjacency": {
13
+ ... "s": [{"to": "a", "capacity": 10, "multiplier": 1.0}],
14
+ ... "a": [{"to": "t", "capacity": 5, "multiplier": 2.0}],
15
+ ... },
16
+ ... })
17
+ 10.0
18
+
19
+ The answer also carries the sequence of paths that were filled, each with the
20
+ flow that entered it, the flow it delivered and the edge that constricted it::
21
+
22
+ answer = gmaxflow.solve(graph)
23
+ for path in answer["paths"]:
24
+ print(path["nodes"], path["flow_in"], "->", path["flow_out"])
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from typing import Any, Dict, Mapping, Optional, Union
31
+
32
+ from . import _native
33
+
34
+ __all__ = [
35
+ "solve",
36
+ "solve_json",
37
+ "max_flow",
38
+ "version",
39
+ "InvalidGraphError",
40
+ "NotOptimalError",
41
+ ]
42
+
43
+ Graph = Union[Mapping[str, Any], str, bytes]
44
+
45
+ class InvalidGraphError(ValueError):
46
+ """The graph itself was rejected: a bad endpoint, capacity or multiplier."""
47
+
48
+
49
+ class NotOptimalError(RuntimeError):
50
+ """The solver stopped without certifying the answer as maximum.
51
+
52
+ The partial answer is on the exception as ``.answer``.
53
+ """
54
+
55
+ def __init__(self, message: str, answer: Dict[str, Any]) -> None:
56
+ super().__init__(message)
57
+ self.answer = answer
58
+
59
+
60
+ def solve_json(request: str) -> str:
61
+ """Solve a problem given as a JSON document and return a JSON document.
62
+
63
+ This is the raw interface the C++ library exposes; the bytes are not parsed
64
+ on the way in or out.
65
+ """
66
+ return _native.solve_json(request)
67
+
68
+
69
+ def solve(
70
+ graph: Graph,
71
+ *,
72
+ epsilon: Optional[float] = None,
73
+ max_iterations: Optional[int] = None,
74
+ collect_paths: Optional[bool] = None,
75
+ cancel_generating_cycles: Optional[bool] = None,
76
+ fat_path: Optional[bool] = None,
77
+ gain_scaling: Optional[bool] = None,
78
+ require_optimal: bool = False,
79
+ ) -> Dict[str, Any]:
80
+ """Solve a generalized maximum flow problem.
81
+
82
+ `graph` is a mapping (or JSON text) with a ``source``, a ``sink`` and either
83
+ an ``adjacency`` map or an ``edges`` list. Options may be passed here as
84
+ keywords or inside the graph under ``options``; keywords win.
85
+
86
+ With `require_optimal`, an answer the solver could not certify raises
87
+ `NotOptimalError` instead of being returned.
88
+
89
+ Raises `InvalidGraphError` when the graph is malformed.
90
+ """
91
+ if isinstance(graph, (str, bytes)):
92
+ request = json.loads(graph)
93
+ else:
94
+ request = dict(graph)
95
+ if not isinstance(request, dict):
96
+ raise InvalidGraphError("the graph must be a JSON object")
97
+
98
+ options = dict(request.get("options") or {})
99
+ overrides = {
100
+ "epsilon": epsilon,
101
+ "max_iterations": max_iterations,
102
+ "collect_paths": collect_paths,
103
+ "cancel_generating_cycles": cancel_generating_cycles,
104
+ "fat_path": fat_path,
105
+ "gain_scaling": gain_scaling,
106
+ }
107
+ options.update({name: value for name, value in overrides.items() if value is not None})
108
+ if options:
109
+ request["options"] = options
110
+
111
+ answer = json.loads(_native.solve_json(json.dumps(request)))
112
+
113
+ if answer.get("status") == "invalid_input":
114
+ raise InvalidGraphError(answer.get("error", "invalid graph"))
115
+ if require_optimal and not answer.get("optimal"):
116
+ raise NotOptimalError(
117
+ f"{answer.get('status')}: {answer.get('message', '')}".strip(), answer
118
+ )
119
+ return answer
120
+
121
+
122
+ def max_flow(graph: Graph, **options: Any) -> float:
123
+ """The maximum flow delivered at the sink.
124
+
125
+ Accepts the same options as `solve`. Returns ``float('inf')`` when the
126
+ network admits unbounded flow.
127
+ """
128
+ return float(solve(graph, **options)["max_flow"])
129
+
130
+
131
+ def version() -> str:
132
+ """The version of the native solver."""
133
+ return _native.version()
gmaxflow/__main__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Command line entry point: ``gmaxflow graph.json`` or JSON on stdin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Optional, Sequence
9
+
10
+ from . import solve_json, version
11
+
12
+
13
+ def main(argv: Optional[Sequence[str]] = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="gmaxflow",
16
+ description="Generalized maximum flow: maximum flow with per-edge gain multipliers.",
17
+ )
18
+ parser.add_argument(
19
+ "graph",
20
+ nargs="?",
21
+ default="-",
22
+ help="a JSON file holding the graph; omit it to read stdin",
23
+ )
24
+ parser.add_argument("-o", "--output", help="write the answer here instead of stdout")
25
+ parser.add_argument("-c", "--compact", action="store_true", help="emit one line of JSON")
26
+ parser.add_argument("-v", "--version", action="version", version=version())
27
+ arguments = parser.parse_args(argv)
28
+
29
+ if arguments.graph == "-":
30
+ text = sys.stdin.read()
31
+ else:
32
+ with open(arguments.graph, encoding="utf-8") as handle:
33
+ text = handle.read()
34
+ if not text.strip():
35
+ parser.error("no input: pass a file or pipe JSON on stdin")
36
+
37
+ try:
38
+ request = json.loads(text)
39
+ except json.JSONDecodeError as problem:
40
+ print(json.dumps({"status": "invalid_input", "error": str(problem)}), file=sys.stdout)
41
+ return 1
42
+ if isinstance(request, dict):
43
+ options = dict(request.get("options") or {})
44
+ options["pretty"] = not arguments.compact
45
+ request["options"] = options
46
+
47
+ answer = solve_json(json.dumps(request))
48
+
49
+ if arguments.output:
50
+ with open(arguments.output, "w", encoding="utf-8") as handle:
51
+ handle.write(answer + "\n")
52
+ else:
53
+ sys.stdout.write(answer + "\n")
54
+
55
+ try:
56
+ return 1 if json.loads(answer).get("status") == "invalid_input" else 0
57
+ except json.JSONDecodeError:
58
+ return 1
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
Binary file
gmaxflow/_native.py ADDED
@@ -0,0 +1,75 @@
1
+ """ctypes binding to the native solver. Internal; use the public functions.
2
+
3
+ The solver is a shared library with a plain C ABI, so it never links against
4
+ libpython: one wheel per OS and CPU serves every CPython, free-threaded builds
5
+ included, and PyPy. ctypes releases the GIL for the length of each call, so
6
+ other threads keep running while a large graph is solved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import os
13
+ import sys
14
+
15
+ _HERE = os.path.dirname(os.path.abspath(__file__))
16
+ _ENV = "GMAXFLOW_LIB"
17
+
18
+
19
+ def _library_path() -> str:
20
+ """$GMAXFLOW_LIB if set, else the library inside the installed package."""
21
+ override = os.environ.get(_ENV)
22
+ if override:
23
+ return override
24
+ name = {"win32": "gmaxflow_core.dll", "darwin": "gmaxflow_core.dylib"}.get(
25
+ sys.platform, "gmaxflow_core.so"
26
+ )
27
+ return os.path.join(_HERE, "_native", name)
28
+
29
+
30
+ def _load() -> ctypes.CDLL:
31
+ path = _library_path()
32
+ if not os.path.exists(path):
33
+ raise ImportError(
34
+ "the gmaxflow native solver was not found (%s). Install a wheel "
35
+ "(`pip install generalized-maxflow`), or build from a checkout with "
36
+ "`pip install .`; a library built by hand with CMake can be named in "
37
+ "$%s." % (path, _ENV)
38
+ )
39
+ return ctypes.CDLL(path)
40
+
41
+
42
+ _lib = _load()
43
+
44
+ _lib.gmf_solve_json_n.restype = ctypes.c_void_p
45
+ _lib.gmf_solve_json_n.argtypes = [
46
+ ctypes.c_char_p,
47
+ ctypes.c_size_t,
48
+ ctypes.POINTER(ctypes.c_size_t),
49
+ ]
50
+ _lib.gmf_free.restype = None
51
+ _lib.gmf_free.argtypes = [ctypes.c_void_p]
52
+ _lib.gmf_version.restype = ctypes.c_char_p
53
+ _lib.gmf_version.argtypes = []
54
+
55
+
56
+ def solve_json(request: str) -> str:
57
+ """Solve the JSON request and return the JSON answer."""
58
+ data = request.encode("utf-8")
59
+ size = ctypes.c_size_t(0)
60
+ answer = _lib.gmf_solve_json_n(data, len(data), ctypes.byref(size))
61
+ if not answer:
62
+ raise MemoryError("the solver ran out of memory")
63
+ try:
64
+ return ctypes.string_at(answer, size.value).decode("utf-8")
65
+ finally:
66
+ _lib.gmf_free(answer)
67
+
68
+
69
+ def version() -> str:
70
+ return _lib.gmf_version().decode("ascii")
71
+
72
+
73
+ def library_path() -> str:
74
+ """Where the loaded native library lives, for diagnostics."""
75
+ return _library_path()
gmaxflow/py.typed ADDED
File without changes