NETWORKSTRESS TEST Solver Guide
HOW TO IMPLEMENT AND SWAP IN A DIFFERENT FLOW ENGINE ยท Estay Dynamics

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.

1. Where the solver sits

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.

The swap boundary. Everything downstream of 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.

2. The exact interface contract

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; }

3. A complete, verified worked example

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 };
};
Honest performance note. A textbook comparison would expect potentials + Dijkstra to beat plain Bellman-Ford at scale. Measured on this app's actual reference network (330 nodes, ~1,100 edges), it doesn't: the plain Bellman-Ford version above ran in 40ms against the shipped solver's 85ms on the identical graph, same result both times. Why: the shipped solver's Dijkstra step is a simple 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.

4. Wiring it in

Two ways to actually use a replacement, depending on whether you want to keep the original around:

Replace in place (simplest)

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.

Add alongside and switch the reference

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.

5. Testing it before you trust it

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:

  • Cost-based routing: a cheap path fills first, a more expensive one only gets the spillover.
  • A demand you can't fully satisfy caps out at the true bottleneck rather than erroring.
  • A disconnected graph returns zero flow rather than throwing.
  • Performance at this app's actual scale (a few hundred nodes) stays well under a second per solve โ€” re-run 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.

6. Beyond a drop-in replacement

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:

  • It needs a real linear (or mixed-integer) programming formulation โ€” one variable per product-per-edge, one shared capacity constraint per edge summed across all products, one flow- conservation constraint per product-per-node. That's a different mathematical object than the single-commodity min-cost flow this app solves today.
  • Hand-written JavaScript doesn't have a competitive general-purpose LP/MILP solver. The realistic paths are a WebAssembly-compiled solver (e.g. a port of HiGHS or GLPK) bundled as a vendored dependency the way this site already vendors other libraries, or calling out to a server-side solver โ€” both bigger architectural decisions than anything else in this guide.
  • 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.

7. Checklist

  • Same class shape: constructor(numNodes), addEdge(from,to,cap,cost) โ†’ index, run(s,t,maxFlow) โ†’ {flow,cost}
  • edges[index].flow and graph[nodeId] both readable in the documented shape after run()
  • Dual-exported (module.exports for Node, window.NST.flowSolver for the browser)
  • Verified against known-answer hand-built networks in plain Node before touching the UI
  • Timed at this app's real scale (a few hundred nodes) โ€” comfortably sub-second per solve
  • Full Playwright suite re-run end-to-end after wiring it in, console asserted error-free
  • If the optimality guarantee or the per-product approximation changed, the in-app "How the solver works" text updated to match โ€” this app's own rule throughout has been to describe what the math actually does, not what it's supposed to do