Skip to content

Commit 357db63

Browse files
committed
Add sensitivity analysis exploration script
Exercises the sensitivity analysis at three API levels: 1. Low-level AnalysisContext.sensitivity() on a diamond network 2. Mid-level FailureManager.run_sensitivity_monte_carlo() on a ring network 3. High-level YAML Scenario with Sensitivity workflow step 4. NSFNET real-world topology (NewYork -> PaloAlto) https://claude.ai/code/session_01KrRkR2uPQRZVxuovvDGHiM
1 parent 6b49522 commit 357db63

1 file changed

Lines changed: 396 additions & 0 deletions

File tree

explore_sensitivity.py

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
#!/usr/bin/env python3
2+
"""Sensitivity analysis exploration script for NetGraph.
3+
4+
Exercises sensitivity analysis at three levels of abstraction:
5+
1. Low-level: AnalysisContext.sensitivity() on a simple network
6+
2. Mid-level: FailureManager.run_sensitivity_monte_carlo() with failure scenarios
7+
3. High-level: YAML scenario with Sensitivity workflow step via CLI
8+
4. NSFNET: Real-world topology sensitivity analysis
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import textwrap
14+
import time
15+
16+
from ngraph.analysis.context import Mode, analyze
17+
from ngraph.analysis.failure_manager import FailureManager
18+
from ngraph.model.failure.parser import build_failure_policy_set
19+
from ngraph.model.network import Link, Network, Node
20+
from ngraph.scenario import Scenario
21+
from ngraph.types.base import FlowPlacement
22+
from ngraph.utils.seed_manager import SeedManager
23+
24+
# ── Helper ───────────────────────────────────────────────────────────────
25+
26+
27+
def build_network(nodes: list[str], edges: list[tuple[str, str, float, int]]) -> Network:
28+
"""Build a Network from node names and (src, dst, capacity, cost) tuples."""
29+
net = Network()
30+
for name in nodes:
31+
net.add_node(Node(name=name))
32+
for src, dst, cap, cost in edges:
33+
net.add_link(Link(source=src, target=dst, capacity=cap, cost=cost))
34+
return net
35+
36+
37+
# ── 1. Low-level: AnalysisContext.sensitivity() ──────────────────────────
38+
39+
print("=" * 72)
40+
print("1. LOW-LEVEL: AnalysisContext.sensitivity()")
41+
print("=" * 72)
42+
43+
# Build a small 4-node diamond network:
44+
# A
45+
# / \
46+
# 10 5 (capacity)
47+
# / \
48+
# B C
49+
# \ /
50+
# 8 3
51+
# \ /
52+
# D
53+
net = build_network(
54+
["A", "B", "C", "D"],
55+
[("A", "B", 10.0, 1), ("A", "C", 5.0, 1),
56+
("B", "D", 8.0, 1), ("C", "D", 3.0, 1)],
57+
)
58+
59+
print(f"\nNetwork: diamond (4 nodes)")
60+
print(f" Nodes: {list(net.nodes.keys())}")
61+
print(f" Links: {len(net.links)} links")
62+
for lid, link in net.links.items():
63+
print(f" {lid}: {link.source} -> {link.target} "
64+
f"(cap={link.capacity}, cost={link.cost})")
65+
66+
# Run max-flow from A to D
67+
ctx = analyze(net, source="^A$", sink="^D$", mode=Mode.COMBINE)
68+
flow = ctx.max_flow()
69+
print(f"\n Max flow A->D: {flow}")
70+
71+
# Run sensitivity analysis (combine mode)
72+
sensitivity = ctx.sensitivity()
73+
print(f"\n Sensitivity (critical edges):")
74+
for (src, dst), edge_impacts in sensitivity.items():
75+
print(f" Flow {src} -> {dst}:")
76+
if not edge_impacts:
77+
print(" (no critical edges found)")
78+
for edge_key, reduction in sorted(edge_impacts.items(), key=lambda x: -x[1]):
79+
print(f" {edge_key}: flow reduction = {reduction:.1f}")
80+
81+
# Also try pairwise mode
82+
print("\n --- Pairwise mode ---")
83+
ctx2 = analyze(net, source="^[AB]$", sink="^[CD]$", mode=Mode.PAIRWISE)
84+
flow2 = ctx2.max_flow()
85+
print(f" Max flow (pairwise): {flow2}")
86+
sens2 = ctx2.sensitivity()
87+
for (src, dst), edge_impacts in sens2.items():
88+
print(f" Flow {src} -> {dst}:")
89+
if not edge_impacts:
90+
print(" (no critical edges)")
91+
continue
92+
for edge_key, reduction in sorted(edge_impacts.items(), key=lambda x: -x[1]):
93+
print(f" {edge_key}: -{reduction:.1f}")
94+
95+
96+
# ── 2. Mid-level: FailureManager.run_sensitivity_monte_carlo() ──────────
97+
98+
print("\n" + "=" * 72)
99+
print("2. MID-LEVEL: FailureManager.run_sensitivity_monte_carlo()")
100+
print("=" * 72)
101+
102+
# Build a 6-node ring network for richer failure analysis
103+
# N1 -- N2 -- N3
104+
# | |
105+
# N6 -- N5 -- N4
106+
ring = build_network(
107+
[f"N{i}" for i in range(1, 7)],
108+
[(f"N{i}", f"N{i%6+1}", 10.0, 1) for i in range(1, 7)],
109+
)
110+
111+
print(f"\nNetwork: 6-node ring")
112+
113+
# Build a failure policy: fail 1 random link
114+
failure_config = {
115+
"single_link": {
116+
"modes": [{
117+
"weight": 1.0,
118+
"rules": [{"scope": "link", "mode": "choice", "count": 1}]
119+
}]
120+
}
121+
}
122+
seed_mgr = SeedManager(42)
123+
fps = build_failure_policy_set(
124+
failure_config,
125+
derive_seed=lambda n: seed_mgr.derive_seed("failure_policy", n),
126+
)
127+
128+
# Run Monte Carlo sensitivity analysis
129+
fm = FailureManager(
130+
network=ring,
131+
failure_policy_set=fps,
132+
policy_name="single_link",
133+
)
134+
135+
t0 = time.perf_counter()
136+
results = fm.run_sensitivity_monte_carlo(
137+
source="^N1$",
138+
target="^N4$",
139+
mode="combine",
140+
iterations=50,
141+
parallelism=1,
142+
shortest_path=False,
143+
flow_placement=FlowPlacement.PROPORTIONAL,
144+
seed=42,
145+
)
146+
elapsed = time.perf_counter() - t0
147+
148+
print(f" Iterations: {results['metadata']['iterations']}")
149+
print(f" Unique failure patterns: {results['metadata']['unique_patterns']}")
150+
print(f" Execution time: {elapsed:.3f}s")
151+
152+
# Print baseline
153+
baseline = results["baseline"]
154+
print(f"\n Baseline (no failures):")
155+
for flow_entry in baseline.flows:
156+
print(f" {flow_entry.source} -> {flow_entry.destination}: "
157+
f"flow={flow_entry.placed:.1f}")
158+
sens_data = flow_entry.data.get("sensitivity", {})
159+
for edge, reduction in sorted(sens_data.items(), key=lambda x: -x[1]):
160+
print(f" {edge}: -{reduction:.1f}")
161+
162+
# Print component scores (aggregated statistics)
163+
print(f"\n Component Scores (aggregated across failure iterations):")
164+
comp_scores = results["component_scores"]
165+
for flow_key, components in comp_scores.items():
166+
print(f" Flow: {flow_key}")
167+
sorted_components = sorted(
168+
components.items(), key=lambda x: -x[1].get("mean", 0)
169+
)
170+
for comp_name, stats in sorted_components[:10]:
171+
print(f" {comp_name}: mean={stats['mean']:.2f}, "
172+
f"max={stats['max']:.2f}, min={stats['min']:.2f}, "
173+
f"count={stats['count']}")
174+
175+
176+
# ── 3. High-level: YAML Scenario with Sensitivity workflow step ──────────
177+
178+
print("\n" + "=" * 72)
179+
print("3. HIGH-LEVEL: YAML Scenario with Sensitivity workflow step")
180+
print("=" * 72)
181+
182+
yaml_str = textwrap.dedent("""\
183+
seed: 42
184+
network:
185+
nodes:
186+
DC1:
187+
attrs:
188+
site_type: datacenter
189+
DC2:
190+
attrs:
191+
site_type: datacenter
192+
Core1:
193+
attrs:
194+
site_type: core
195+
Core2:
196+
attrs:
197+
site_type: core
198+
Edge1:
199+
attrs:
200+
site_type: edge
201+
Edge2:
202+
attrs:
203+
site_type: edge
204+
links:
205+
- source: DC1
206+
target: Core1
207+
capacity: 100.0
208+
cost: 1
209+
- source: DC1
210+
target: Core2
211+
capacity: 80.0
212+
cost: 2
213+
- source: DC2
214+
target: Core1
215+
capacity: 60.0
216+
cost: 2
217+
- source: DC2
218+
target: Core2
219+
capacity: 100.0
220+
cost: 1
221+
- source: Core1
222+
target: Edge1
223+
capacity: 50.0
224+
cost: 1
225+
- source: Core1
226+
target: Edge2
227+
capacity: 40.0
228+
cost: 2
229+
- source: Core2
230+
target: Edge1
231+
capacity: 30.0
232+
cost: 2
233+
- source: Core2
234+
target: Edge2
235+
capacity: 70.0
236+
cost: 1
237+
failures:
238+
random_link:
239+
modes:
240+
- weight: 1.0
241+
rules:
242+
- scope: link
243+
mode: choice
244+
count: 1
245+
workflow:
246+
- type: Sensitivity
247+
name: bottleneck_analysis
248+
source: "^DC.*"
249+
target: "^Edge.*"
250+
mode: combine
251+
failure_policy: random_link
252+
iterations: 100
253+
parallelism: 1
254+
shortest_path: false
255+
flow_placement: PROPORTIONAL
256+
seed: 42
257+
store_failure_patterns: false
258+
""")
259+
260+
scenario = Scenario.from_yaml(yaml_str)
261+
print(f"\nScenario loaded:")
262+
print(f" Nodes: {list(scenario.network.nodes.keys())}")
263+
print(f" Links: {len(scenario.network.links)}")
264+
print(f" Workflow steps: {len(scenario.workflow)}")
265+
print(f" Failure policies: {list(scenario.failure_policy_set.policies.keys())}")
266+
267+
t0 = time.perf_counter()
268+
scenario.run()
269+
elapsed = time.perf_counter() - t0
270+
print(f"\n Scenario completed in {elapsed:.3f}s")
271+
272+
# Inspect results (use get_step for post-run access)
273+
step_results = scenario.results.get_step("bottleneck_analysis")
274+
data = step_results.get("data", {})
275+
metadata = step_results.get("metadata", {})
276+
277+
print(f"\n Metadata:")
278+
print(f" Iterations: {metadata.get('iterations')}")
279+
print(f" Unique patterns: {metadata.get('unique_patterns')}")
280+
281+
print(f"\n Baseline:")
282+
baseline_data = data.get("baseline", {})
283+
if baseline_data:
284+
for flow in baseline_data.get("flows", []):
285+
src = flow.get("source", "?")
286+
dst = flow.get("destination", "?")
287+
placed = flow.get("placed", 0)
288+
sens = flow.get("data", {}).get("sensitivity", {})
289+
print(f" {src} -> {dst}: flow={placed:.1f}")
290+
for edge, reduction in sorted(sens.items(), key=lambda x: -x[1])[:5]:
291+
print(f" {edge}: -{reduction:.1f}")
292+
293+
print(f"\n Component Scores (top bottlenecks):")
294+
comp_scores = data.get("component_scores", {})
295+
for flow_key, components in comp_scores.items():
296+
print(f" Flow: {flow_key}")
297+
sorted_comps = sorted(
298+
components.items(), key=lambda x: -x[1].get("mean", 0)
299+
)
300+
for comp_name, stats in sorted_comps[:8]:
301+
print(f" {comp_name}: mean={stats['mean']:.2f}, "
302+
f"max={stats['max']:.2f}, count={stats['count']}")
303+
304+
print(f"\n Flow results (unique failure patterns): {len(data.get('flow_results', []))}")
305+
306+
307+
# ── 4. NSFNET: Real-world topology ──────────────────────────────────────
308+
309+
print("\n" + "=" * 72)
310+
print("4. NSFNET: Sensitivity on a real-world topology")
311+
print("=" * 72)
312+
313+
from pathlib import Path
314+
315+
nsfnet_path = Path("scenarios/nsfnet.yaml")
316+
nsfnet_yaml = nsfnet_path.read_text()
317+
318+
# Replace the workflow section with a Sensitivity step
319+
parts = nsfnet_yaml.split("workflow:")
320+
nsfnet_sensitivity_yaml = parts[0] + textwrap.dedent("""\
321+
workflow:
322+
- type: Sensitivity
323+
name: nsfnet_sensitivity
324+
source: "^NewYork$"
325+
target: "^PaloAlto$"
326+
mode: combine
327+
failure_policy: single_link_failure
328+
iterations: 20
329+
parallelism: 1
330+
shortest_path: false
331+
flow_placement: PROPORTIONAL
332+
seed: 42
333+
""")
334+
335+
nsfnet_scenario = Scenario.from_yaml(nsfnet_sensitivity_yaml)
336+
print(f"\nNSFNET Scenario:")
337+
print(f" Nodes: {len(nsfnet_scenario.network.nodes)}")
338+
print(f" Links: {len(nsfnet_scenario.network.links)}")
339+
340+
t0 = time.perf_counter()
341+
nsfnet_scenario.run()
342+
elapsed = time.perf_counter() - t0
343+
print(f" Completed in {elapsed:.3f}s")
344+
345+
nsfnet_step = nsfnet_scenario.results.get_step("nsfnet_sensitivity")
346+
nsfnet_data = nsfnet_step.get("data", {})
347+
nsfnet_meta = nsfnet_step.get("metadata", {})
348+
349+
print(f"\n Metadata:")
350+
print(f" Iterations: {nsfnet_meta.get('iterations')}")
351+
print(f" Unique patterns: {nsfnet_meta.get('unique_patterns')}")
352+
353+
print(f"\n Baseline (NewYork -> PaloAlto):")
354+
nsfnet_baseline = nsfnet_data.get("baseline", {})
355+
if nsfnet_baseline:
356+
for flow in nsfnet_baseline.get("flows", []):
357+
placed = flow.get("placed", 0)
358+
print(f" Max flow: {placed:.1f}")
359+
sens = flow.get("data", {}).get("sensitivity", {})
360+
print(f" Critical edges ({len(sens)} total):")
361+
for edge, reduction in sorted(sens.items(), key=lambda x: -x[1])[:10]:
362+
print(f" {edge}: -{reduction:.1f}")
363+
364+
print(f"\n Top bottleneck components across failure scenarios:")
365+
nsfnet_comp = nsfnet_data.get("component_scores", {})
366+
for flow_key, components in nsfnet_comp.items():
367+
print(f" Flow: {flow_key}")
368+
sorted_comps = sorted(
369+
components.items(), key=lambda x: -x[1].get("mean", 0)
370+
)
371+
for comp_name, stats in sorted_comps[:15]:
372+
print(f" {comp_name}: mean={stats['mean']:.2f}, "
373+
f"max={stats['max']:.2f}, min={stats['min']:.2f}")
374+
375+
376+
# ── Summary ──────────────────────────────────────────────────────────────
377+
378+
print("\n" + "=" * 72)
379+
print("SUMMARY")
380+
print("=" * 72)
381+
print("""
382+
Sensitivity analysis in NetGraph identifies network bottlenecks by:
383+
384+
1. Computing max-flow between source/target node groups
385+
2. Identifying saturated (critical) edges in the flow solution
386+
3. Measuring flow reduction when each critical edge is removed
387+
4. Under Monte Carlo failure scenarios, aggregating component impact
388+
statistics (mean, max, min) across iterations
389+
390+
Key findings:
391+
- Three API levels: AnalysisContext (low), FailureManager (mid), Scenario (high)
392+
- Supports both shortest-path (IP/IGP) and full max-flow (SDN/TE) modes
393+
- Parallel execution via C++ backend with GIL release
394+
- Deduplicates identical failure patterns to save computation
395+
- Results include per-component scores ranked by criticality
396+
""")

0 commit comments

Comments
 (0)