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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.23.0] - 2026-09-13

### Fixed

Placement results that were wrong in plausible scenarios. Re-run analyses that used these configurations.

- Combine mode with a `SHORTEST_PATHS_*` preset routed the whole demand from the pseudo source, so only the sources nearest the targets carried traffic and the equal hash across sources let one small source scale the rest down. The pseudo source is now a pool: every source that can reach a target originates an even share, routed to its nearest targets over a DAG built by Core's new reverse SPF. Under `SHORTEST_PATHS_ECMP` the pool is admitted as one demand at a single lossless scale (two equal-cost sources of capacity 100 and 10 admit 20 of 110; `SHORTEST_PATHS_ECMP_LOSSY` delivers 65). `TE_*` presets keep letting capacity decide which sources originate. A fixed per-source matrix is `group_mode: per_group` with `group_by: name`, or `pairwise`
- `SHORTEST_PATHS_ECMP` skipped next hops filled by earlier demands, overstating lossless capacity. It now models a load-blind forwarding table: a filled next hop blocks later demands hashed onto it (Core placement `EQUAL_BALANCED_FIXED`)
- `TE_WCMP_UNLIM` stopped rerouting after 100 cost tiers and dropped the remainder silently. The loop is now bounded by the edge count
- `MaximumSupportedDemand` failed with "No feasible alpha found" for LSP presets over many small demands. Core never places less than 1/4096 on a flow, so those demands were always short by a fraction of that. Feasible now means every demand is placed to within that resolution and none placed nothing; the failure message reports the best ratio seen
- `create_flow_policy` left Core's `require_capacity` at its default for the `SHORTEST_PATHS_*` presets, so a FlowPolicy built from an IGP preset selected paths with residual awareness and the WCMP one rerouted. Both engines now read one `preset_config`, and a hop-by-hop FlowPolicy places exactly what the cached engine places

### Changed

- `TrafficMatrixPlacement` resolves `parallelism: auto` to 1 unless the demand set uses an LSP preset or the interpreter is free-threaded. Iterations for the other presets are Python-bound between short engine calls, and the CPU-count default made Monte Carlo about 2x slower than serial. Explicit worker counts are unchanged
- Minimum `netgraph-core` raised to 0.9.0 (new placement modes, per-link drop reporting, reverse SPF)
- Documentation describes placement as greedy and sequential: priority order, input order within a priority, order-dependent totals under contention

### Added

- `SHORTEST_PATHS_ECMP_LOSSY` preset: hop-by-hop ECMP forwarded best-effort. Every link carries what fits and drops the rest, `placed` is the delivered volume, and with `include_flow_details` each entry reports `dropped_edges`, the lost volume per link. On a 100/10 parallel pair offered 100 units, `SHORTEST_PATHS_ECMP` admits 20 and the lossy preset delivers 60
- `ngraph.model.flow.policy_config.preset_config` and `HOP_BY_HOP_PRESETS`; `PlacementSummary.max_shortfall` and `unserved_demands`; `resolve_placement_parallelism`

## [0.22.0] - 2026-08-24

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Python-test](https://github.com/networmix/NetGraph/actions/workflows/python-test.yml/badge.svg?branch=main)](https://github.com/networmix/NetGraph/actions/workflows/python-test.yml)

Network modeling and analysis framework combining Python with high-performance C++ graph algorithms.
Network modeling and analysis framework: Python front end, C++ graph algorithms.

## What It Does

Expand Down Expand Up @@ -105,6 +105,7 @@ workflow:

```bash
ngraph run scenario.yml --output results/
jq '.steps.max_demand.data.alpha_star' results/scenario.results.json
```

This scenario builds a dual-site Clos fabric from blueprints, finds the maximum supportable demand, then runs 100 Monte Carlo iterations with random link failures - exporting results to JSON.
Expand All @@ -118,7 +119,7 @@ See [DSL Reference](https://networmix.github.io/NetGraph/reference/dsl/) and [Ex
- **Routing modes** for IP routing (cost-based) and traffic engineering (capacity-aware)
- **Flow placement** strategies for ECMP and WCMP with max-flow and capacity envelopes
- **Reproducible results** via seeded randomness and stable edge IDs
- **C++ performance** with GIL released via [NetGraph-Core](https://github.com/networmix/NetGraph-Core)
- **C++ algorithms** with the GIL released, via [NetGraph-Core](https://github.com/networmix/NetGraph-Core)

## Documentation

Expand Down
34 changes: 34 additions & 0 deletions docs/examples/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,37 @@ for pair, path_list in k_paths.items():
for i, path in enumerate(path_list, 1):
print(f" {i}. Cost: {path.cost}")
```

## Demand Placement

Max-flow asks how much the network could carry. Demand placement asks how much of a given volume it does carry under a routing model. The same 6 units from A to C give a different answer under each preset:

```python
from ngraph.analysis.functions import demand_placement_analysis

for preset in ("SHORTEST_PATHS_ECMP", "SHORTEST_PATHS_ECMP_LOSSY",
"SHORTEST_PATHS_WCMP", "TE_WCMP_UNLIM"):
result = demand_placement_analysis(
network,
excluded_nodes=set(),
excluded_links=set(),
demands_config=[{"source": "^A$", "target": "^C$", "volume": 6,
"mode": "pairwise", "flow_policy": preset}],
include_flow_details=True,
)
entry = result.flows[0]
print(f"{preset}: placed={entry.placed:g} dropped={entry.dropped:g} "
f"by_cost={entry.cost_distribution} {entry.data}")

# SHORTEST_PATHS_ECMP: placed=2 dropped=4 by_cost={2.0: 2.0} {}
# SHORTEST_PATHS_ECMP_LOSSY: placed=2.5 dropped=3.5 by_cost={2.0: 2.5} {'dropped_edges': {'A|B|0:fwd': 2.0, 'A|B|1:fwd': 1.0, 'B|C|0:fwd': 0.5}}
# SHORTEST_PATHS_WCMP: placed=3 dropped=3 by_cost={2.0: 3.0} {}
# TE_WCMP_UNLIM: placed=6 dropped=0 by_cost={2.0: 3.0, 4.0: 3.0} {}
```

- `SHORTEST_PATHS_ECMP` hashes 3 units onto each parallel link of the cost-2 path. The capacity-1 link admits only 1 without loss, so the whole demand is admitted at that scale: 2 units.
- `SHORTEST_PATHS_ECMP_LOSSY` sends the same 3 and 3, and each link carries what fits. 2.5 units arrive; `dropped_edges` says where the other 3.5 were lost.
- `SHORTEST_PATHS_WCMP` splits by capacity, so the cost-2 path carries its full 3 units. The demand does not leave the shortest path, so 3 units are unmet.
- `TE_WCMP_UNLIM` reroutes the remainder onto the cost-4 path and places everything.

In a scenario file the same choice is the demand's `flow_policy`; see the [Tutorial](../getting-started/tutorial.md) for placement inside a workflow.
35 changes: 35 additions & 0 deletions docs/examples/bundled-scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,41 @@ ngraph run scenarios/nsfnet.yaml --output out
ngraph run scenarios/nsfnet.yaml --keys node_to_node_capacity_matrix_1 --stdout
```

## Reading the results

`--stdout` prints only the JSON, so it pipes into `jq`:

```bash
# The largest traffic multiplier that still fits (square_mesh: 1.0)
ngraph run scenarios/square_mesh.yaml --no-results --stdout --keys msd_baseline \
| jq '.steps.msd_baseline.data.alpha_star'

# One line per distinct failure pattern: which links failed, how many
# iterations drew it, and the fraction of demand still placed
ngraph run scenarios/square_mesh.yaml --no-results --stdout --keys tm_placement \
| jq -c '.steps.tm_placement.data.flow_results[]
| {links: .failure_state.excluded_links, n: .occurrence_count, ratio: .summary.overall_ratio}'

# Pairwise capacity matrix: 676 source/destination pairs in the no-failure baseline
ngraph run scenarios/nsfnet.yaml --no-results --stdout --keys node_to_node_capacity_matrix_1 \
| jq '.steps.node_to_node_capacity_matrix_1.data.baseline.flows | length'

# Capex and power per metro from the components library
ngraph run scenarios/backbone_clos.yml --no-results --stdout --keys cost_power \
| jq -c '.steps.cost_power.data.levels["1"][] | {path, capex_total, power_total_watts}'
```

The `square_mesh` placement output looks like this (1000 iterations, six single-link patterns):

```text
{"links":["N1|N2|0"],"n":164,"ratio":0.8333333333333334}
{"links":["N3|N4|0"],"n":180,"ratio":0.8333333333333334}
{"links":["N2|N4|0"],"n":165,"ratio":1.0}
{"links":["N2|N3|0"],"n":183,"ratio":0.8333333333333334}
{"links":["N1|N3|0"],"n":164,"ratio":1.0}
{"links":["N1|N4|0"],"n":144,"ratio":0.8333333333333334}
```

## Notes on results

All runs emit a consistent JSON shape with `workflow`, `steps`, and `scenario` sections. Steps like `MaxFlow` and `TrafficMatrixPlacement` store a list under `data.flow_results` with one entry per unique failure pattern - patterns are deduplicated across iterations, so the list holds at most `iterations` entries and usually far fewer - alongside a single unfailed entry under `data.baseline`; with no `failure_policy`, `flow_results` is empty. Each entry carries a `summary` and per-flow `flows` entries whose `cost_distribution` is populated when `include_flow_details` is set (and `{}` otherwise), and with `include_min_cut` the min-cut edges appear under a flow entry's `data` (`edges` plus `edges_kind: "min_cut"`). See Reference -> Workflow for the exact schema.
51 changes: 50 additions & 1 deletion docs/examples/clos-fabric.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ for lk in network.links.values():
(s.startswith("my_clos2/spine") and t.startswith("my_clos1/spine")):
groups[(s, t)].append(lk)
for i, key in enumerate(sorted(groups.keys())):
links = sorted(groups[key], key=lambda x: (x.source, x.target, id(x)))
links = sorted(groups[key], key=lambda x: x.id)
caps = [4.0, 0.25, 0.25, 0.25] if i % 2 == 0 else [2.0, 1.0, 0.5, 0.25]
for lk, cap in zip(links, caps):
lk.capacity = cap
Expand Down Expand Up @@ -207,6 +207,55 @@ Uneven WCMP: {('b1|b2', 'b1|b2'): 248.0}

As expected, WCMP achieves higher throughput than ECMP when parallel links within equal-cost bundles have uneven capacities. ECMP is limited by the link with the lowest capacity in the equal-cost group.

## Failure Analysis

The same ECMP-versus-WCMP question under failures, this time with `FailureManager` running a Monte Carlo over random spine failures in `my_clos1`. Each iteration fails two spines; identical failure patterns are run once and weighted by how often they were drawn.

```python
from collections import Counter
from ngraph import FailureManager, FlowPlacement
from ngraph.model.failure.policy import FailurePolicy, FailureMode, FailureRule
from ngraph.model.failure.policy_set import FailurePolicySet

# Restore symmetric inter-spine links for this section
for lk in network.links.values():
if lk.source.startswith("my_clos1/spine") or lk.source.startswith("my_clos2/spine"):
lk.capacity = 1.0

two_spines = FailurePolicy(modes=[FailureMode(weight=1.0, rules=[
FailureRule(scope="node", mode="choice", count=2, path="^my_clos1/spine/"),
])])
fm = FailureManager(
network=network,
failure_policy_set=FailurePolicySet(policies={"two_spines": two_spines}),
policy_name="two_spines",
)

for placement in (FlowPlacement.EQUAL_BALANCED, FlowPlacement.PROPORTIONAL):
mc = fm.run_max_flow_monte_carlo(
source=r"my_clos1.*(b[0-9]*)/t1",
target=r"my_clos2.*(b[0-9]*)/t1",
mode="combine",
iterations=100,
parallelism=1,
seed=1,
shortest_path=True,
flow_placement=placement,
)
capacity = Counter()
for item in mc["results"]:
capacity[item.summary.total_placed] += item.occurrence_count
print(placement.name, "baseline", mc["baseline"].summary.total_placed,
"under failure", dict(sorted(capacity.items())))
```

```text
EQUAL_BALANCED baseline 256.0 under failure {192.0: 13, 224.0: 87}
PROPORTIONAL baseline 256.0 under failure {248.0: 100}
```

Losing two spines removes 8 of 256 inter-spine links. WCMP loses exactly that capacity in every iteration. ECMP loses 32, or 64 when both failed spines serve the same t2 switch, because the surviving equal-cost next hops still receive equal shares and the smallest one caps the whole split.

## Network Structure Analysis

We can also analyze the network structure using the NetworkExplorer:
Expand Down
53 changes: 43 additions & 10 deletions docs/getting-started/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,66 @@ ngraph run scenarios/square_mesh.yaml --keys msd_baseline --stdout

See also: `scenarios/backbone_clos.yml` and `scenarios/nsfnet.yaml`.

## Programmatic: minimal example
## Programmatic: a small workflow

A three-node network, one demand, and the two steps most analyses start with: find the largest multiplier of the traffic matrix that still fits (`MaximumSupportedDemand`), then place the matrix under random single-link failures (`TrafficMatrixPlacement`).

```python
from ngraph.scenario import Scenario

scenario_yaml = """
seed: 42

network:
nodes:
A: {}
B: {}
nodes: {A: {}, B: {}, C: {}}
links:
- {source: A, target: B, capacity: 10.0, cost: 1.0}
- {source: A, target: B, capacity: 10, cost: 1}
- {source: B, target: C, capacity: 10, cost: 1}
- {source: A, target: C, capacity: 5, cost: 3}

failures:
single_link:
modes:
- weight: 1.0
rules: [{scope: link, mode: choice, count: 1}]

demands:
default:
- {source: ^A$, target: ^C$, volume: 8, mode: pairwise, flow_policy: TE_WCMP_UNLIM}

workflow:
- type: NetworkStats
name: baseline_stats
- {type: MaximumSupportedDemand, name: msd, demand_set: default}
- {type: TrafficMatrixPlacement, name: placement, demand_set: default,
failure_policy: single_link, iterations: 20}
"""

scenario = Scenario.from_yaml(scenario_yaml)
scenario.run()
steps = scenario.results.to_dict()["steps"]

print("alpha_star:", steps["msd"]["data"]["alpha_star"])
placement = steps["placement"]["data"]
print("baseline placed:", placement["baseline"]["summary"]["total_placed"])
for pattern in placement["flow_results"]:
failed = pattern["failure_state"]["excluded_links"]
summary = pattern["summary"]
print(f" {failed} x{pattern['occurrence_count']}: "
f"placed {summary['total_placed']:.0f} of {summary['total_demand']:.0f}")
```

exported = scenario.results.to_dict()
print(list(exported["steps"].keys()))
```text
alpha_star: 1.875
baseline placed: 8.0
['B|C|0'] x8: placed 5 of 8
['A|C|0'] x7: placed 8 of 8
['A|B|0'] x5: placed 5 of 8
```

`alpha_star` is 1.875 because A can reach C with 15 units in total (10 through B plus 5 direct) and the demand is 8. The 20 failure iterations collapse into three distinct patterns; `occurrence_count` says how many iterations drew each one. Losing either link of the B path leaves only the 5-unit direct link.

## Results structure

Results are exported with a fixed structure containing `workflow`, `steps`, and `scenario` sections. Steps such as `MaxFlow`, `TrafficMatrixPlacement`, and `MaximumSupportedDemand` write their outputs under their step name. See the Workflow Reference for field details.
Results have a fixed shape with `workflow`, `steps`, and `scenario` sections. Each step writes `metadata` and `data` under its name: `MaximumSupportedDemand` writes `data.alpha_star`, and `MaxFlow` and `TrafficMatrixPlacement` write a no-failure `data.baseline` plus `data.flow_results`, one entry per distinct failure pattern with its `occurrence_count`, per-flow `flows`, and a `summary`. See the [Workflow Reference](../reference/workflow.md) for every field.

## Next steps

Expand Down
6 changes: 3 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ NetGraph uses a hybrid Python+C++ architecture, split across two layers:

### Traffic Engineering

- **Routing Modes**: Unified modeling of IP routing (static costs, oblivious to congestion) and traffic engineering (dynamic residuals, congestion-aware).
- **Routing Modes**: IP routing (cost-only, fixed paths) and traffic engineering (capacity-aware) in one model.
- **Flow Placement**: Strategies for ECMP (Equal-Cost Multi-Path) and WCMP (Weighted Cost Multi-Path).
- **Capacity Analysis**: Compute max-flow envelopes and demand allocation with configurable placement policies.
- **Capacity Analysis**: Max-flow between node groups and traffic-matrix placement with selectable placement policies.

### Workflow & Integration

- **Structured Results**: Export analysis artifacts to JSON for downstream processing.
- **Structured Results**: JSON export with a fixed shape.
- **CLI**: Validate, inspect, and run scenarios from the command line.
- **Python API**: Programmatic access to the same modeling and solving entry points.

Expand Down
Loading
Loading