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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Analyses that previously returned wrong numbers without any error. If you rely o
- Seeded Monte Carlo runs were not reproducible across identical scenario rebuilds: link ids carried a random uuid suffix whose sort order changed per build, remapping seeded draws onto different parallel links. Link ids are now a deterministic per-(source, target) sequence (`A|B|0`), and `add_link` raises instead of silently overwriting when an id collides (possible when node names contain `|`) or when the same link is added twice
- A risk group sharing its name with a node or link excluded the wrong entity: the node was excluded and the group's members were left up. Failed entities are now classified by rule scope
- `expand_groups` expanded differently depending on whether a failure came from an entity rule or a `risk_group` rule, and never reached members of nested groups. Expansion is now transitive and identical for both rule kinds
- **BREAKING**: `MaxFlowResult.min_cut` (`max_flow_detailed` with `include_min_cut=True`) returns a true minimum cut whose capacity equals the max flow, instead of all saturated edges. Saturated-edge analysis remains available via `sensitivity()`
- **BREAKING**: `MaxFlowResult.min_cut` (`max_flow_detailed` with `include_min_cut=True`) returns a true minimum cut whose capacity equals the max flow, instead of all saturated edges. The capacities match for the default max-flow configuration (`PROPORTIONAL` placement, `require_capacity=True`, `shortest_path=False`); the other configurations are placement models rather than max-flow computations. Saturated-edge analysis remains available via `sensitivity()`
- Monte Carlo with `seed=None` now falls back to the failure policy's own seed; a seeded policy previously produced one identical failure pattern on every iteration
- `group_mode: per_group` now matches its documented semantics: `combine` creates one demand per source group, `pairwise` pairs nodes within each same-label group, and volume splits evenly across groups. A skipped group's share is not redistributed
- `k_shortest_paths` between multi-node groups merges results across all source/sink pairs instead of returning paths for the single best pair, and breaks ties structurally so results no longer depend on `PYTHONHASHSEED`; `shortest_paths` ordering is likewise deterministic
Expand Down Expand Up @@ -79,13 +79,14 @@ Other fixes.

### Added

- Demands can be pinned to explicit routes with `static_paths`, modelling MPLS-style LSPs: one flow per route, and a route broken by a failure carries nothing instead of rerouting. A route is a list of node names or link ids (link ids pick a specific one of several parallel links). Requires `mode: pairwise` with selectors matching exactly one source and one target
- `AnalysisContext.sensitivity_with_flow`: max flow and edge sensitivity per group pair in a single pass, used by `sensitivity_analysis` and the sensitivity Monte Carlo path (results unchanged)
- `AnalysisContext.build_node_mask`/`build_edge_mask` as public methods, for analysis functions calling Core primitives directly
- `Scenario.run(step_hook=...)`: a callable returning a context manager entered around each step, used by the CLI `--profile` path
- `FailurePolicy.apply_failures_typed`, returning scope-typed failure sets (`apply_failures` remains as a merged-list wrapper), plus `prepare_weights`, `build_risk_group_index`, and the `prepared_rg_index` parameter on `apply_failures` for reuse across Monte Carlo iterations
- `build_demand_placement_inputs`, exported from `ngraph.analysis`; `demand_placement_analysis` accepts precomputed `expansion=` and `resolved_ids=`
- `TrafficDemand.to_dict()`, `Mode.from_string` in `ngraph.types`, and `link_path_key` in `ngraph.model.selectors` (the canonical "source|target" key used when path-matching links)
- Minimum `netgraph-core` raised to 0.7.0, the API family this release targets; the previous 0.3.0 floor predated APIs now in use
- Minimum `netgraph-core` raised to 0.8.0, which introduces the `FlowPolicy.set_static_paths` and `PredDAG.from_edges` APIs that pinned routes are built on, and whose max-flow completion phase makes `max_flow` return a true maximum (values can increase against 0.7.x)

### Removed

Expand Down
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
Loading
Loading