Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 82 additions & 82 deletions CHANGELOG.md

Large diffs are not rendered by default.

90 changes: 82 additions & 8 deletions docs/reference/api-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ Quick links:
- [CLI Reference](cli.md)
- [DSL Reference](dsl.md)

Generated from source code on: August 21, 2026 at 00:49 UTC
Generated from source code on: August 24, 2026 at 01:58 UTC

Modules auto-discovered: 53
Modules auto-discovered: 54

---

Expand Down Expand Up @@ -488,7 +488,33 @@ Attributes:
Traffic demand specification.

Defines `TrafficDemand`, a user-facing specification used by demand expansion
and placement. Routing behavior is selected via an optional `FlowPolicyPreset`.
and placement. Routing behavior is selected via an optional `FlowPolicyPreset`,
or pinned to explicit routes with `StaticPath`.

### StaticPath

One explicit route a demand can be pinned to (an MPLS-style LSP).

Give exactly one of `nodes` or `links`:

- `nodes`: the node names the route visits, source first and target last.

Each consecutive pair must be adjacent. When several parallel links
connect a pair, the cheapest is used (ties broken by link id); name the
link explicitly to choose a different one.

- `links`: the link ids the route traverses, in order. Unambiguous when

parallel links exist. A link may be traversed in either direction.

Attributes:
nodes: Node names along the route, or empty when `links` is given.
links: Link ids along the route, or empty when `nodes` is given.

**Attributes:**

- `nodes` (Tuple) = ()
- `links` (Tuple) = ()

### TrafficDemand

Expand All @@ -503,6 +529,10 @@ Attributes:
group_mode: How grouped nodes produce demands
("flatten", "per_group", "group_pairwise").
flow_policy: Policy preset for routing.
static_paths: Explicit routes to pin this demand to. When set, the
demand is placed only on these routes: one flow per route, and a
route broken by a failure carries nothing rather than rerouting.
Requires selectors matching exactly one source and one target.
attrs: Arbitrary user metadata.
id: Unique identifier. Auto-generated if empty.

Expand All @@ -515,6 +545,7 @@ Attributes:
- `mode` (str) = combine
- `group_mode` (str) = flatten
- `flow_policy` (Union)
- `static_paths` (Tuple) = ()
- `attrs` (Dict) = {}
- `id` (str)

Expand Down Expand Up @@ -888,7 +919,7 @@ Enumerates common flow policy presets for traffic routing.
These presets map to specific combinations of path algorithms, flow placement
strategies, and edge selection modes provided by NetGraph-Core.

### create_flow_policy(algorithms: 'netgraph_core.Algorithms', graph: 'netgraph_core.Graph', preset: 'FlowPolicyPreset', node_mask=None, edge_mask=None) -> 'netgraph_core.FlowPolicy'
### create_flow_policy(algorithms: 'netgraph_core.Algorithms', graph: 'netgraph_core.Graph', preset: 'FlowPolicyPreset', node_mask=None, edge_mask=None, static_path_count: 'Optional[int]' = None) -> 'netgraph_core.FlowPolicy'

Create a FlowPolicy instance from a preset configuration.

Expand All @@ -899,6 +930,9 @@ Args:
flow-count bounds to apply.
node_mask: Optional numpy bool array for node exclusions (True = include).
edge_mask: Optional numpy bool array for edge exclusions (True = include).
static_path_count: Number of routes the caller will pin with
`FlowPolicy.set_static_paths`. Sets the flow count to match, since
a pinned policy creates one flow per route and never grows.

Returns:
netgraph_core.FlowPolicy: Configured policy instance.
Expand Down Expand Up @@ -2580,7 +2614,9 @@ Captures total flow, cost distribution, and optionally min-cut edges.

Attributes:
total_flow: Maximum flow value achieved.
cost_distribution: Mapping of path cost to flow volume placed at that cost.
cost_distribution: Mapping of cost to flow volume placed at that cost.
Completion-phase entries are marginal costs, so a key need not match
any single path's cost.
min_cut: Edges forming a minimum cut (None if not computed).

**Attributes:**
Expand Down Expand Up @@ -2922,6 +2958,8 @@ Attributes:
volume: Traffic volume to place.
priority: Priority class (lower is higher priority).
policy_preset: FlowPolicy configuration preset.
static_paths: Routes this demand is pinned to, empty when it is
routed by the policy.

**Attributes:**

Expand All @@ -2930,6 +2968,7 @@ Attributes:
- `volume` (float)
- `priority` (int)
- `policy_preset` (FlowPolicyPreset)
- `static_paths` (Tuple[StaticPath, ...]) = ()

### expand_demands(network: 'Network', traffic_demands: 'List[TrafficDemand]', default_policy_preset: 'FlowPolicyPreset' = <FlowPolicyPreset.SHORTEST_PATHS_ECMP: 1>) -> 'DemandExpansion'

Expand Down Expand Up @@ -2957,9 +2996,11 @@ Returns:
DemandExpansion with demands and augmentations.

Raises:
ValueError: If no demands could be expanded, or if two demands share
an id (pseudo node names embed the id, so duplicates would merge
distinct demands' attachment edges into one endpoint).
ValueError: If no demands could be expanded, if two demands share an
id (pseudo node names embed the id, so duplicates would merge
distinct demands' attachment edges into one endpoint), or if a
demand with `static_paths` does not resolve to exactly one
source/target pair.

---

Expand Down Expand Up @@ -3262,6 +3303,39 @@ Raises:

---

## ngraph.analysis.static_paths

Resolution of explicit routes into Core path bundles.

Turns the `StaticPath` entries on a demand into the `PredDAG` bundles that
`FlowPolicy.set_static_paths` pins traffic to. A bundle is a single simple
path: one edge per hop, so a route that names adjacent nodes with parallel
links between them picks one of those links (see `StaticPath`).

### build_static_path_bundles(ctx: "'AnalysisContext'", paths: 'Sequence[StaticPath]', src_name: 'str', dst_name: 'str') -> 'List[netgraph_core.PredDAG]'

Build the Core path bundles a demand is pinned to.

Args:
ctx: Context holding the built graph; routes resolve against it.
paths: Routes to pin, in the order flows should be created.
src_name: Node every route must start at.
dst_name: Node every route must end at.

Returns:
One `PredDAG` per route, in the given order. Results are cached per
context, so repeated calls during a Monte Carlo run resolve once.

Raises:
ValueError: If an endpoint is absent from the context graph, if a
route names an unknown node or a disabled/unknown link, has a hop
whose nodes are joined only by disabled links, has a hop with
no link, traverses a link that does not leave the node it has
reached, does not run from `src_name` to `dst_name`, or revisits
a node (a pinned route must be a simple path).

---

## ngraph.lib.nx

NetworkX graph conversion utilities.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ print(summary.cost_distribution) # Dict[float, float] mapping cost to flow volu

- `analyze(network, *, source=None, sink=None, mode=Mode.COMBINE)` - Create analysis context
- `ctx.max_flow(source, sink, *, mode, shortest_path, require_capacity, flow_placement, excluded_nodes, excluded_links)` - Maximum flow
- `ctx.max_flow_detailed(..., include_min_cut=False)` - Maximum flow with cost distribution and optional min-cut; the min-cut is a true minimum cut (its capacity equals the max flow), not the set of saturated edges
- `ctx.max_flow_detailed(..., include_min_cut=False)` - Maximum flow with cost distribution and optional min-cut; the min-cut is a true minimum cut (its capacity equals the max flow under the default `PROPORTIONAL` placement with `require_capacity=True` and `shortest_path=False`), not the set of saturated edges
- `ctx.sensitivity(...)` - Identify critical edges and their impact on flow
- `ctx.sensitivity_with_flow(...)` - Compute max flow and edge sensitivity together per group pair in a single pass (used by the sensitivity Monte Carlo hot path)
- `ctx.shortest_path_cost(source, sink, *, mode, edge_select=ALL_MIN_COST, excluded_nodes, excluded_links)` - Shortest path cost
Expand Down
26 changes: 21 additions & 5 deletions docs/reference/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,11 @@ See "Routing Semantics: IP/IGP vs SDN/TE" section for detailed explanation.
The residual network is maintained via `FlowState`, which tracks per-edge flow and computes residual capacities on demand. For each edge u→v:

- Forward residual capacity: `capacity(u,v) - flow(u,v)`
- Reverse residual capacity: `flow(u,v)` (used for residual reachability when computing the min-cut and reachable set)
- Reverse residual capacity: `flow(u,v)` (traversed to return previously placed flow during the completion phase, and for residual reachability when computing the min-cut and reachable set)

SPF operates over the residual graph by requesting edges with `require_capacity=true`, which filters to edges with positive residual capacity. The `FlowState` provides a residual capacity view without graph mutation.

Note: Reverse residual arcs are distinct from physical reverse edges added via `add_reverse=True` during graph construction. Physical reverse edges model bidirectional links with independent capacity; reverse residual arcs are bookkeeping over a single edge's flow. The augmenting SPF search traverses forward residual edges only — placed flow is never cancelled across tiers; reverse residual arcs are traversed only for reachability when computing the min-cut and reachable set, while Dinic-style reverse edges allow redistribution within a single tier's placement.
Note: Reverse residual arcs are distinct from physical reverse edges added via `add_reverse=True` during graph construction. Physical reverse edges model bidirectional links with independent capacity; reverse residual arcs are bookkeeping over a single edge's flow. The cost-tier SPF loop traverses forward residual edges only, so it cannot cancel an earlier placement and may stop below the true maximum. A completion phase then runs BFS augmentation over the full residual graph, traversing arcs backwards to return previously placed flow, which makes the result a true maximum flow whose min-cut matches it. The completion phase applies only to max-flow semantics — `PROPORTIONAL` placement with `require_capacity=True` and `shortest_path=False`; `EQUAL_BALANCED` (ECMP admission) and `require_capacity=False` (fixed-cost IP routing) are placement models rather than max-flow computations and keep the tier-loop result. Dinic-style reverse edges additionally allow redistribution within a single tier's placement.

The core loop finds augmenting paths using the cost-aware SPF described above:

Expand All @@ -521,7 +521,7 @@ After the loop, the C++ algorithm computes a FlowSummary which includes:

- min_cut: the list of edges that are saturated and go from reachable to non-reachable (these form the minimum cut)

- cost_distribution: flow volume placed at each path cost tier. Core returns parallel arrays (`costs`, `flows`); AnalysisContext converts these to the `Dict[Cost, Flow]` mapping in `MaxFlowResult.cost_distribution`.
- cost_distribution: flow volume keyed by cost. Tier-loop entries are the cost of the shortest-path DAG the flow was placed on; completion-phase entries are marginal costs (the augmenting path's forward edge costs minus the cost of the flow it cancels), so a key need not correspond to any traversable path. Core returns parallel arrays (`costs`, `flows`); AnalysisContext converts these to the `Dict[Cost, Flow]` mapping in `MaxFlowResult.cost_distribution`.

The summary is returned along with the total flow value.

Expand Down Expand Up @@ -688,6 +688,22 @@ function MAX_FLOW(graph, S, T, placement=PROPORTIONAL, require_capacity=True,
if shortest_path: # Single augmentation pass (IP/IGP mode)
break

# Completion phase: max-flow semantics only. The tier loop above walks
# forward residual edges, so it can stop below the true maximum.
if placement == PROPORTIONAL and require_capacity and not shortest_path:
while True:
# BFS over the full residual graph, including reverse arcs that
# return previously placed flow
path = BFS_AUGMENTING_PATH(flow_state.residual_view(), S, T)
if path is None:
break
placed = flow_state.augment(path)
if placed < kMinFlow:
break
total_flow += placed
# Marginal cost: forward edge costs minus the cost of cancelled flow
cost_distribution[marginal_cost(path)] += placed

# Compute min-cut, reachability, cost distribution
min_cut = flow_state.compute_min_cut(S, node_mask, edge_mask)

Expand All @@ -703,7 +719,7 @@ function MAX_FLOW(graph, S, T, placement=PROPORTIONAL, require_capacity=True,

The flow tolerance constant `kMinFlow` (1/4096 ≈ 2.4e-4) determines when flow placement is considered negligible and iteration terminates.

Each augmentation phase performs one SPF \(O((V+E) \log V)\) and one placement pass over the tier's predecessor DAG. For EQUAL_BALANCED the placement is a single topological pass \(O(V+E)\); for PROPORTIONAL it is a complete Dinic max-flow over the tier DAG (repeated BFS level construction, level-restricted blocking-flow DFS, and a group rebuild from the updated residual), worst case \(O(V^2 E)\). Placed flow is never removed from an edge, so each phase permanently saturates at least one edge before the next SPF runs, bounding the number of phases by \(O(E)\); with PROPORTIONAL placement the tier's path cost also strictly increases between phases, so phases are further bounded by the number of distinct path-cost values. The resulting loose worst-case bound is \(O(E \cdot (V^2 E + (V+E) \log V))\).
Each augmentation phase performs one SPF \(O((V+E) \log V)\) and one placement pass over the tier's predecessor DAG. For EQUAL_BALANCED the placement is a single topological pass \(O(V+E)\); for PROPORTIONAL it is a complete Dinic max-flow over the tier DAG (repeated BFS level construction, level-restricted blocking-flow DFS, and a group rebuild from the updated residual), worst case \(O(V^2 E)\). The tier loop never removes placed flow, so each phase permanently saturates at least one edge before the next SPF runs, bounding the number of phases by \(O(E)\); with PROPORTIONAL placement the tier's path cost also strictly increases between phases, so phases are further bounded by the number of distinct path-cost values. The resulting loose worst-case bound is \(O(E \cdot (V^2 E + (V+E) \log V))\). The completion phase that follows is Edmonds-Karp at \(O(V E^2)\), which this bound dominates.

Practical performance is significantly better than these worst-case bounds: iteration stops as soon as the residual network disconnects source from sink, the phase count in practice equals the small number of cost tiers actually used, and the `kMinFlow` threshold additionally caps the number of phases at \(F / k_{MinFlow}\) for total flow \(F\).

Expand Down Expand Up @@ -775,7 +791,7 @@ NetGraph's design includes several features that differentiate it from tradition

- Configurable flow placement: Proportional (WCMP-style, capacity-weighted) and Equal-Balanced (ECMP-style, uniform) splitting across parallel equal-cost edges

- Cost-aware augmentation: Prefer cheapest capacity first via successive shortest paths. Does not re-route previously placed flow.
- Cost-aware augmentation: Prefer cheapest capacity first via successive shortest paths. The cost-tier loop does not re-route previously placed flow; the max-flow completion phase may cancel earlier placements to reach the true maximum.

- Deterministic simulation with seeding: Random aspects (e.g., failure sampling) are controlled by explicit seeds that propagate through steps. Runs are reproducible given the same scenario and seed.

Expand Down
56 changes: 56 additions & 0 deletions docs/reference/dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -1249,11 +1249,67 @@ demands:
| `mode` | string | Node pairing mode: `combine` or `pairwise` (default: `combine`) |
| `group_mode` | string | How grouped nodes produce demands (default: `flatten`) |
| `flow_policy` | string or integer | Routing policy preset name (case-insensitive, or its integer value); inline policy mappings fail schema validation at scenario load |
| `static_paths` | array | Explicit routes to pin the demand to; see below |
| `attrs` | object | Arbitrary metadata |
| `expand` | object | Variable expansion block |

Each demand receives an auto-generated unique `id`; an explicit `id` key is not accepted in scenario YAML. Duplicate ids can arise only when demands are constructed programmatically, and demand expansion rejects them with `ValueError`.

### Static Paths

`static_paths` pins a demand to routes you choose, instead of letting the flow
policy pick them. This models MPLS-style LSPs: one flow per route, and a route
broken by a failure carries nothing rather than rerouting.

```yaml
demands:
default:
- source: "^A$"
target: "^C$"
volume: 10
mode: pairwise
flow_policy: SHORTEST_PATHS_WCMP
static_paths:
- ["A", "B", "C"] # node names, source first
- links: ["A|D|0", "D|C|0"] # or link ids, in traversal order
```

Each route is either a list of node names or a mapping with `nodes` or
`links`. Name nodes for readability; name links when parallel links connect
the same pair and you need a specific one.

A route is a strict explicit route: every hop is one link. Where parallel
links connect a pair, a node hop takes the cheapest enabled one (ties broken
by link id), so the route carries that single link's capacity and fails when
that link fails — not when the whole bundle does. To model an LSP per parallel
link, list one route per link using the `links` form. Disabled links are never
chosen for a node hop, and naming one in the `links` form is an error.

Semantics:

- One flow per route, created in the order listed. How volume divides depends
on the preset: proportional presets such as `SHORTEST_PATHS_WCMP` fill the
routes in the order you list them, so a volume smaller than the first
route's bottleneck never reaches the second; equal-balanced presets give
every surviving route the same share, so total placement is limited by the
smallest surviving route. Route order is therefore significant under
proportional presets.
- A route whose nodes or links are excluded carries nothing. Traffic does not
move to another route, which is what makes this different from ordinary
routing.
- The policy neither adds routes nor reoptimizes, so preset cost ceilings and
LSP counts do not apply.

Two demands pinned between the same source, target, and priority are rejected,
because their flows would collide. Put every route on one demand, or separate
the demands by priority.

Because routes run between two concrete nodes, a demand using `static_paths`
must set `mode: pairwise` and use selectors matching exactly one source and
one target; anything else raises `ValueError` during expansion. Every route
must start at the source and end at the target, visit adjacent nodes, and not
revisit a node.

### Selector Fields

The `source` and `target` fields accept either:
Expand Down
Loading
Loading