This app's flow engine is deliberately small and narrowly-scoped, sitting behind one exact interface that exactly one other file talks to. That's what makes it swappable: if you implement that same interface โ and keep its optimality guarantee โ the rest of the app (map, tables, scenarios, Monte Carlo, everything) keeps working unchanged. This page is the reference for doing that: the exact contract, a complete second implementation (independently verified against the shipped one before this page was published โ not just described, actually checked), how to wire it in, and how to test it before you trust it with real numbers.
web/src/flowSolver.js is a pure min-cost flow algorithm โ successive shortest augmenting
paths with node potentials (one Bellman-Ford pass for initial potentials, then Dijkstra with reduced
costs per augmentation). It knows nothing about production sites, warehouses, consumers, products, or
transport modes; it just sees an abstract directed graph with capacities and costs.
web/src/network.js is the only file that constructs and calls it. For each product, it
builds one graph: production and warehouse nodes are split into an in-node and out-node joined by an
internal edge capped at that node's capacity for the product (the standard technique for
node-capacitated flow), consumer demand becomes edges into a sink, and it calls the solver once per
product, in ascending priority order, each product consuming from whatever edge capacity earlier
products left behind.
network.js โ the map, the KPI
dashboard, the N-1 scan, Monte Carlo, the resilience check, the adversarial search โ calls
solveNetwork()/solveOneProduct(), never the solver directly. So a
same-shaped solver replacement is a one-file change with zero ripple effects elsewhere.
network.js uses exactly four things from the solver module, all on one class:
new Solver(numNodes) // constructor: total node count in THIS product's graph
solver.addEdge(from, to, cap, cost) // returns an integer edge index
solver.run(source, sink, maxFlow) // returns { flow: number, cost: number }
solver.edges[edgeIndex].flow // read AFTER run() -- flow actually assigned to that edge
solver.graph[nodeId] // array of edge-indices whose edge.from === nodeId
Two details matter more than they look:
addEdge's returned index is what network.js later uses to read
solver.edges[thatIndex].flow for every real lane, to build the per-edge flow map the
map view and KPI dashboard render. If your class reorganizes edges internally, the index you
return from addEdge still has to be a valid key into a flow-bearing
object at that same index after run().
solver.graph[nodeId] is walked directly (not through a method) to sum a consumer's
received flow: network.js looks for edges in that node's list whose to is
the sink and whose flow > 0. Your class needs a graph array in that
exact shape, even if you also keep your own different internal structure for the algorithm itself.
The module also needs to dual-export the same way every other module here does, so it works in both Node (for testing) and the browser:
const mod = { MinCostFlow: YourSolverClass };
if (typeof module !== "undefined" && module.exports) module.exports = mod;
else { global.NST = global.NST || {}; global.NST.flowSolver = mod; }
Below is a second, independently-implemented solver: successive shortest augmenting paths using plain Bellman-Ford on every augmentation instead of the shipped version's node-potentials + Dijkstra trick. It's a legitimate, different implementation of the same optimality guarantee โ not a toy โ and it was checked line-for-line against the shipped solver on five test networks (a hand-built bottleneck case, a cheap-path-first cost-ordering case, an infeasible-demand case, a disconnected graph, and a 150-node random network) before this page was written: identical flow and identical cost on every one.
function BellmanFordMinCostFlow(numNodes) {
this.n = numNodes;
this.graph = [];
for (let i = 0; i < numNodes; i++) this.graph.push([]);
this.edges = [];
}
BellmanFordMinCostFlow.prototype.addEdge = function (from, to, cap, cost) {
this.graph[from].push(this.edges.length);
this.edges.push({ to: to, cap: cap, cost: cost, flow: 0 });
this.graph[to].push(this.edges.length);
this.edges.push({ to: from, cap: 0, cost: -cost, flow: 0 }); // reverse/residual edge
return this.edges.length - 2;
};
BellmanFordMinCostFlow.prototype.run = function (s, t, maxFlow) {
const n = this.n;
let totalFlow = 0, totalCost = 0;
while (totalFlow < maxFlow) {
// plain Bellman-Ford shortest path in the residual graph -- handles
// negative reverse-edge costs directly, no potentials needed
const dist = new Array(n).fill(Infinity);
const prevEdge = new Array(n).fill(-1);
dist[s] = 0;
for (let iter = 0; iter < n - 1; iter++) {
let changed = false;
for (let u = 0; u < n; u++) {
if (dist[u] === Infinity) continue;
for (const ei of this.graph[u]) {
const e = this.edges[ei];
if (e.cap - e.flow <= 1e-9) continue;
if (dist[u] + e.cost < dist[e.to] - 1e-9) {
dist[e.to] = dist[u] + e.cost;
prevEdge[e.to] = ei;
changed = true;
}
}
}
if (!changed) break; // early exit once nothing improves
}
if (dist[t] === Infinity) break; // sink unreachable -- no more augmenting paths
// bottleneck capacity along the path just found
let aug = maxFlow - totalFlow;
for (let v = t; v !== s;) {
const ei = prevEdge[v];
aug = Math.min(aug, this.edges[ei].cap - this.edges[ei].flow);
v = this.edges[ei ^ 1].to;
}
if (aug <= 1e-9) break;
// push that much flow along the path
let pathCost = 0;
for (let v = t; v !== s;) {
const ei = prevEdge[v];
this.edges[ei].flow += aug;
this.edges[ei ^ 1].flow -= aug;
pathCost += this.edges[ei].cost * aug;
v = this.edges[ei ^ 1].to;
}
totalFlow += aug;
totalCost += pathCost;
}
return { flow: totalFlow, cost: totalCost };
};
O(Vยฒ) linear scan for the minimum-distance node, not a
priority-queue-based one, which erases most of Dijkstra's usual advantage at this scale. If you want
the solver that's actually fastest here, implementing a binary-heap Dijkstra (keeping the potentials)
is the real next step โ not something this guide builds, but the accurate direction if performance is
your reason for touching this file.
Two ways to actually use a replacement, depending on whether you want to keep the original around:
Edit web/src/flowSolver.js directly: swap the class body, keep the export block
identical. Nothing else in the repo changes โ network.js's
require("./flowSolver.js") (Node) and global.NST.flowSolver (browser)
references resolve to your new code automatically.
Save your version as a new file (e.g. web/src/flowSolverBF.js, using the same dual-export
pattern), add a <script src="src/flowSolverBF.js"></script> tag in
index.html before network.js's tag, then change one line in
network.js:
// before
const MinCostFlow = flowSolverMod.MinCostFlow;
// after -- point at your module's export instead
const MinCostFlow = flowSolverBFMod.MinCostFlow;
This keeps both implementations in the repo, useful if you want to A/B them later.
This app's own development followed one rule throughout: every algorithm module is verified in plain Node, against hand-built networks with a known correct answer, before it's ever wired to the UI. Apply the same rule to a replacement solver. A minimal version of what to check:
const { MinCostFlow } = require('./src/flowSolver.js'); // your replacement
// P1 --E1--> W1 --E2 (cap 40)--> C1 (demand 60)
// --E3 (cap 100)--> C2 (demand 50)
// S=0, P1in=1, P1out=2, W1in=3, W1out=4, C1in=5, C2in=6, T=7
const g = new MinCostFlow(8);
g.addEdge(0, 1, 100, 0); g.addEdge(1, 2, 100, 0); // S -> P1 (node cap)
g.addEdge(2, 3, 100, 1); // P1 -> W1 (E1)
g.addEdge(3, 4, 100, 0); // W1 (node cap)
g.addEdge(4, 5, 40, 2); g.addEdge(4, 6, 100, 5); // W1 -> C1 (E2), W1 -> C2 (E3)
g.addEdge(5, 7, 60, 0); g.addEdge(6, 7, 50, 0); // demand sinks
const result = g.run(0, 7, 110);
console.assert(result.flow === 90, 'C1 is bottlenecked at 40 by E2; total should be 40+50=90');
console.assert(result.cost === 40 * 1 * 1 + 40 * 2 + 50 * 1 + 50 * 5, 'exact known cost');
Beyond that single case, check at minimum:
web/src/generator.js's reference network (330 nodes) through your solver and time it.
Only once those hold should you load it in the browser and re-run the app's own Playwright suite
end-to-end (see the project README for how that suite is structured) โ
a solver that's correct in isolation can still expose a bug in how network.js reads its
output if the interface contract in section 2 wasn't followed exactly.
Everything above assumes you're keeping network.js's per-product, sequential-by-priority
design and just changing how each individual product's flow problem gets solved. That design
is itself a documented approximation of the much harder general multi-commodity flow problem โ
several products competing for the same lane capacity simultaneously, solved as one joint
optimum instead of one product at a time claiming leftovers from the last.
Getting to genuine joint optimization is a materially bigger change, not a solver swap:
network.js's return shape (perProduct keyed by product, each with its own
edgeFlows) would need rethinking too, since "this product's flow" stops being a
separable concept once every product is solved together.
None of that is built here โ this section exists so you know exactly what's actually involved before starting, not to talk you out of it.
edges[index].flow and graph[nodeId] both readable in the documented shape after run()module.exports for Node, window.NST.flowSolver for the browser)