From 6128af9a84ead0fe99039e2fc47e2d9a609a2398 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Fri, 2 Oct 2026 03:26:23 +0100 Subject: [PATCH] Remove obsolete knobs and compat paths; strict input validation; docs cleanup - Remove placement_rounds, acceptance_rule, integer/blank flow_policy, ngraph.dsl.selectors re-exports, edge_select on shortest_path_cost and k_shortest_paths, include_flow_summary, CapacityEnvelope flow summaries, active_seed, inspect --output and the pandas dependency (CHANGELOG [Unreleased] lists everything) - Raise on inputs that were silently ignored: unknown keys in any scenario section, mode on a bound context, alpha with alpha_from_step, non-integer parallelism, invalid hardware counts, duplicate risk group names - Round exclusive link-end BOM counts up; show enum step parameters by name in inspect; send inspect errors to stderr - Docs: check every reference page against the code, drop history wording and filler, rewrite the integration test README, regenerate the API reference and pipeline diagram - Fix the scenario_4 fixture (leaf-spine links were never created and every MaxFlow returned 0) and integration tests that could not fail - Dev tooling: emphasis and fenced-code handling in the API doc generator, per-profile complexity fits in dev/perf, tests under tests/dev Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 2 +- .pre-commit-config.yaml | 4 +- CHANGELOG.md | 48 ++ Makefile | 35 +- README.md | 22 +- dev/check_api_docs.py | 5 - dev/dev.md | 19 +- dev/generate_api_docs.py | 175 ++-- dev/perf/__init__.py | 25 +- dev/perf/analysis.py | 92 +-- dev/perf/core.py | 36 +- dev/perf/main.py | 31 +- dev/perf/profiles.py | 10 +- dev/perf/runner.py | 62 +- dev/perf/topology.py | 75 +- dev/perf/visualization.py | 99 +-- dev/run-checks.sh | 60 +- docs/assets/diagrams/system_pipeline.dot | 2 +- docs/assets/diagrams/system_pipeline.dot.svg | 96 +-- docs/examples/basic.md | 20 +- docs/examples/bundled-scenarios.md | 12 +- docs/examples/clos-fabric.md | 49 +- docs/getting-started/installation.md | 9 +- docs/getting-started/tutorial.md | 2 +- docs/index.md | 28 +- docs/reference/api-full.md | 782 ++++++++---------- docs/reference/api.md | 89 +- docs/reference/cli.md | 68 +- docs/reference/design.md | 170 ++-- docs/reference/dsl.md | 282 ++----- docs/reference/schemas.md | 39 +- docs/reference/workflow.md | 207 ++--- ngraph/__init__.py | 4 +- ngraph/analysis/__init__.py | 1 - ngraph/analysis/context.py | 276 +++---- ngraph/analysis/demand.py | 49 +- ngraph/analysis/failure_manager.py | 302 ++----- ngraph/analysis/functions.py | 183 ++-- ngraph/analysis/placement.py | 30 +- ngraph/analysis/static_paths.py | 22 +- ngraph/cli.py | 236 +++--- ngraph/dsl/blueprints/__init__.py | 2 +- ngraph/dsl/blueprints/expand.py | 156 ++-- ngraph/dsl/blueprints/parser.py | 51 +- ngraph/dsl/expansion/__init__.py | 4 +- ngraph/dsl/expansion/brackets.py | 5 - ngraph/dsl/expansion/variables.py | 9 +- ngraph/dsl/loader.py | 75 +- ngraph/dsl/selectors/__init__.py | 56 +- ngraph/dsl/selectors/normalize.py | 24 +- ngraph/explorer.py | 310 ++++--- ngraph/lib/__init__.py | 2 +- ngraph/lib/nx.py | 70 +- ngraph/logging.py | 20 +- ngraph/model/components.py | 170 ++-- ngraph/model/demand/builder.py | 76 +- ngraph/model/demand/matrix.py | 26 - ngraph/model/demand/spec.py | 36 +- ngraph/model/failure/__init__.py | 5 +- ngraph/model/failure/generate.py | 21 +- ngraph/model/failure/membership.py | 22 +- ngraph/model/failure/parser.py | 41 +- ngraph/model/failure/policy.py | 114 ++- ngraph/model/failure/policy_set.py | 6 +- ngraph/model/failure/validation.py | 21 +- ngraph/model/flow/__init__.py | 3 - ngraph/model/flow/policy_config.py | 61 +- ngraph/model/network.py | 36 +- ngraph/model/path.py | 33 +- ngraph/model/selectors/conditions.py | 8 +- ngraph/model/selectors/parse.py | 13 +- ngraph/model/selectors/schema.py | 9 +- ngraph/model/selectors/select.py | 15 +- ngraph/profiling/profiler.py | 236 +++--- ngraph/results/artifacts.py | 106 +-- ngraph/results/flow.py | 54 +- ngraph/results/snapshot.py | 9 +- ngraph/results/store.py | 34 +- ngraph/scenario.py | 58 +- ngraph/schemas/scenario.json | 98 ++- ngraph/types/__init__.py | 6 +- ngraph/types/base.py | 4 +- ngraph/types/dto.py | 7 +- ngraph/utils/__init__.py | 5 +- ngraph/utils/ids.py | 2 +- ngraph/utils/output_paths.py | 21 +- ngraph/utils/seed_manager.py | 17 +- ngraph/utils/yaml_utils.py | 39 +- ngraph/workflow/base.py | 108 +-- ngraph/workflow/build_graph.py | 37 +- ngraph/workflow/cost_power.py | 12 +- ngraph/workflow/max_flow_step.py | 6 +- .../workflow/maximum_supported_demand_step.py | 36 +- ngraph/workflow/network_stats.py | 25 +- ngraph/workflow/parse.py | 28 +- .../workflow/traffic_matrix_placement_step.py | 73 +- pyproject.toml | 24 +- scenarios/backbone_clos.yml | 3 +- scenarios/square_mesh.yaml | 3 +- tests/analysis/test_context.py | 6 +- tests/analysis/test_context_review_fixes.py | 12 +- tests/analysis/test_demand.py | 14 +- .../test_demand_expansion_semantics.py | 11 +- tests/analysis/test_failure_manager.py | 54 +- tests/analysis/test_failure_manager_fixes.py | 8 +- .../test_failure_manager_integration.py | 30 +- .../analysis/test_flow_placement_semantics.py | 18 +- tests/analysis/test_functions.py | 46 +- tests/analysis/test_functions_details.py | 7 +- tests/analysis/test_maxflow_api.py | 18 +- tests/analysis/test_maxflow_cache.py | 18 +- .../test_maxflow_cost_distribution.py | 9 +- tests/analysis/test_paths.py | 45 +- tests/analysis/test_placement.py | 60 +- tests/analysis/test_risk_group_failure_e2e.py | 6 +- tests/cli/test_cli.py | 27 +- tests/cli/test_cli_helpers.py | 3 +- tests/cli/test_cli_inspect_fixes.py | 8 +- tests/cli/test_cli_profile_hook.py | 6 +- tests/cli/test_module_entrypoint.py | 3 +- tests/cli/test_package_layering.py | 2 +- tests/conftest.py | 13 - tests/dev/__init__.py | 0 tests/dev/test_api_docs_generator.py | 39 + tests/dev/test_perf_analysis.py | 42 + tests/dsl/test_dot_notation_conditions.py | 14 +- tests/dsl/test_dsl_features_validation.py | 15 +- tests/dsl/test_examples.py | 19 +- tests/dsl/test_expand_review_fixes.py | 35 +- tests/dsl/test_expansion.py | 36 +- tests/dsl/test_native_substitution_guards.py | 2 +- tests/dsl/test_parse_helpers.py | 9 +- tests/dsl/test_risk_group_expansion.py | 14 +- tests/dsl/test_rule_selectors.py | 39 +- tests/dsl/test_selectors.py | 63 +- tests/dsl/test_skill_examples_validation.py | 98 +-- tests/explorer/test_explorer.py | 15 +- tests/explorer/test_explorer_review_fixes.py | 29 +- tests/integration/README.md | 437 +--------- tests/integration/expectations.py | 66 +- tests/integration/helpers.py | 251 +----- tests/integration/scenario_3.yaml | 12 +- tests/integration/scenario_4.yaml | 29 +- tests/integration/test_data_templates.py | 740 +---------------- tests/integration/test_error_cases.py | 28 +- tests/integration/test_scenario_1.py | 49 +- tests/integration/test_scenario_2.py | 60 +- tests/integration/test_scenario_3.py | 151 ++-- tests/integration/test_scenario_4.py | 214 ++--- tests/integration/test_template_examples.py | 56 +- tests/lib/test_nx.py | 19 +- tests/lib/test_nx_regressions.py | 6 +- tests/logging/test_library_logging_pattern.py | 7 +- tests/logging/test_logging.py | 10 +- tests/model/components/test_components.py | 56 +- .../test_components_yaml_edge_cases.py | 26 +- tests/model/demand/test_builder.py | 63 +- tests/model/demand/test_spec.py | 22 +- tests/model/failure/test_failure_trace.py | 36 +- tests/model/failure/test_policy.py | 21 +- .../model/failure/test_policy_parser_keys.py | 57 ++ .../test_policy_serialization_roundtrip.py | 1 - tests/model/failure/test_policy_set.py | 3 - tests/model/failure/test_risk_group_parser.py | 4 +- tests/model/flow/test_policy_config.py | 8 +- tests/model/test_flow.py | 14 +- tests/model/test_layering.py | 19 +- tests/model/test_network_basics.py | 17 +- tests/model/test_network_integration.py | 15 +- tests/model/test_risk_group_generation.py | 4 +- tests/model/test_risk_group_membership.py | 11 +- tests/model/test_risk_groups.py | 12 +- tests/model/test_selection.py | 22 +- tests/model/test_types_base.py | 11 +- tests/profiling/test_profiling.py | 20 +- tests/profiling/test_worker_profile_merge.py | 6 +- .../test_capacity_envelope_flow_summary.py | 63 -- tests/results/test_capacity_envelope_unit.py | 25 +- tests/results/test_result.py | 3 +- tests/results/test_serialisation.py | 3 - tests/results/test_store_deep_convert.py | 4 +- tests/results/test_store_unit.py | 1 - tests/scenario/test_scenario.py | 66 +- .../test_scenario_disabled_risk_groups.py | 2 +- tests/scenario/test_scenario_modes.py | 2 - tests/scenario/test_schema_validation.py | 17 +- tests/utils/test_boolean_keys.py | 6 +- tests/utils/test_seed_manager.py | 9 +- .../workflow/test_alpha_resolution_errors.py | 12 +- tests/workflow/test_base.py | 4 +- tests/workflow/test_build_graph_attrs.py | 12 +- tests/workflow/test_cost_power.py | 11 +- ...lope_analysis.py => test_max_flow_step.py} | 206 ++--- .../workflow/test_maximum_supported_demand.py | 52 +- tests/workflow/test_msd_perf_safety.py | 2 - .../test_placement_rounds_deprecated.py | 101 --- tests/workflow/test_seed_provenance.py | 5 +- tests/workflow/test_step_name_collision.py | 4 +- .../workflow/test_tm_analysis_perf_safety.py | 3 +- .../workflow/test_traffic_matrix_placement.py | 229 ++--- 200 files changed, 3649 insertions(+), 6978 deletions(-) create mode 100644 tests/dev/__init__.py create mode 100644 tests/dev/test_api_docs_generator.py create mode 100644 tests/dev/test_perf_analysis.py create mode 100644 tests/model/failure/test_policy_parser_keys.py delete mode 100644 tests/results/test_capacity_envelope_flow_summary.py rename tests/workflow/{test_capacity_envelope_analysis.py => test_max_flow_step.py} (69%) delete mode 100644 tests/workflow/test_placement_rounds_deprecated.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7d4f550..fe473fd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,7 +14,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install '.[dev]' - - name: Generate auto-generated API documentation + - name: Generate the API reference run: python dev/generate_api_docs.py --write-file - name: Deploy documentation run: mkdocs gh-deploy --force --clean diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1cf412e..afb0317 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: hooks: - id: pyright args: [--project, pyproject.toml] - additional_dependencies: ['networkx', 'pyyaml', 'pandas', 'pandas-stubs', 'netgraph-core'] + additional_dependencies: ['networkx', 'numpy', 'pyyaml', 'jsonschema', 'netgraph-core'] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 @@ -31,5 +31,5 @@ repos: name: Validate YAML schemas entry: make validate language: system - files: ^scenarios/.*\.yaml$ + files: ^(scenarios/.*|tests/integration/.*)\.ya?ml$|^ngraph/schemas/scenario\.json$ pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index a34fd13..8960bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ 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). +## [Unreleased] + +### Fixed + +- Bill-of-materials counts for `exclusive: true` link ends round up as documented (a fractional count was truncated, so an exclusive half optic counted as none) +- `ngraph inspect --detail` shows enum step parameters by name (`PROPORTIONAL`, not `1`) +- `ngraph inspect` reports errors on stderr, as `run` does + +### Removed + +- **BREAKING**: `placement_rounds` (`MaximumSupportedDemand`, `TrafficMatrixPlacement`) and `acceptance_rule` (`MaximumSupportedDemand`) +- **BREAKING**: integer, numeric-string and blank `flow_policy` values; use the preset name +- **BREAKING**: `ngraph.dsl.selectors` re-exports; import selector types and evaluation from `ngraph.model.selectors` +- **BREAKING**: `edge_select` on `shortest_path_cost` and `k_shortest_paths`, where it had no effect +- **BREAKING**: `include_flow_summary` on `FailureManager.run_max_flow_monte_carlo`; use `include_flow_details` +- **BREAKING**: `CapacityEnvelope` flow-summary aggregation (`from_values(flow_summaries=...)`, `flow_summary_stats`), which nothing produced +- `serialize_policy_preset`, `DemandSet.get_default_set` and `FailureManager.run_single_failure_scenario` +- `enable_debug_logging`/`disable_debug_logging`; use `set_global_log_level` +- `expand_demands(default_policy_preset=...)`; unset presets use `DEFAULT_PRESET` +- Re-exports `ngraph.analysis.LARGE_CAPACITY` and `ngraph.dsl.blueprints.parser.expand_name_patterns`; `check_no_extra_keys` moved to `ngraph.utils.yaml_utils` +- The always-true third element of `resolve_link_end_components`, and `ngraph inspect --output` +- Result fields without information: workflow `active_seed` (equal to `step_seed`), MSD context `acceptance_rule`, failure-trace `expansion.risk_groups` +- The implicit `type: node` attribute on YAML-defined nodes + +### Changed + +Inputs that were silently ignored or replaced now raise, `ValueError` unless noted. + +- Unknown keys in any scenario builder, from demands and failure policies to selectors, components and workflow steps +- `mode` on a bound `AnalysisContext` call, or on `analyze()`/`from_network()` without `source` and `sink` +- `alpha` together with `alpha_from_step` on `TrafficMatrixPlacement`; `alpha` defaults to 1.0 +- A `parallelism` that is not an integer or `"auto"` (floats were truncated) +- A hardware `count` that is not a finite positive number (it became 1) +- Duplicate top-level risk-group names (the last one silently won) +- An unbound `context` passed to `max_flow_analysis` or `sensitivity_analysis` +- A `run_demand_placement_monte_carlo` input that is not a list of demand configs or a `DemandSet` (`TypeError`) +- A non-`FlowPolicyPreset` `TrafficDemand.flow_policy`, and a `to_networkx` node map missing an index (`KeyError`) +- CLI: an unknown `--keys` step, `--profile-memory` without `--profile`, and non-JSON results (were stringified) + +Other changes. + +- Selector contexts follow the DSL sections: `link` and `rule` replace `adjacency` and `override` +- Monte Carlo keeps `None` results from custom analysis functions in `results` +- `PerformanceProfiler.save_detailed_profile` requires `step_name` +- Dependencies: `pandas` dropped, `numpy` declared; dev extras drop pytest-benchmark, pytest-mock, pdoc and pandas-stubs +- Bundled `TrafficMatrixPlacement` scenarios use `parallelism: auto` +- Internal: removed impossible-state fallbacks, required-import guards, test-only helpers and unused test scaffolding + ## [0.23.1] - 2026-09-13 ### Changed diff --git a/Makefile b/Makefile index 4392c7a..a59dea1 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,16 @@ -# NetGraph Development Makefile -# This Makefile provides convenient shortcuts for common development tasks +# NetGraph development tasks. Run `make help` for the list. .PHONY: help venv clean-venv dev install check check-ci lint format test qt build clean check-dist publish-test publish info hooks check-python docs docs-serve docs-diagrams validate perf -# Default target - show help .DEFAULT_GOAL := help # -------------------------------------------------------------------------- # Python interpreter detection # -------------------------------------------------------------------------- -# VENV_BIN: path to local virtualenv bin directory VENV_BIN := $(PWD)/venv/bin -# PY_BEST: scan for newest supported Python (used when creating new venvs) -# Supports 3.11-3.14 to match requires-python >=3.11 +# PY_BEST: newest of python3.14..python3.11 on PATH, else python3 or python; +# used to create venvs (requires-python >=3.11) PY_BEST := $(shell for v in 3.14 3.13 3.12 3.11; do command -v python$$v >/dev/null 2>&1 && { echo python$$v; exit 0; }; done; command -v python3 2>/dev/null || command -v python 2>/dev/null) # PY_PATH: active python3/python on PATH (respects CI setup-python and activated venvs) @@ -26,7 +23,7 @@ PY_PATH := $(shell command -v python3 2>/dev/null || command -v python 2>/dev/nu # 4. Final fallback to 'python3' literal for clear error messages PYTHON ?= $(if $(wildcard $(VENV_BIN)/python),$(VENV_BIN)/python,$(if $(PY_PATH),$(PY_PATH),$(if $(PY_BEST),$(PY_BEST),python3))) -# Derived tool commands (always use -m to ensure correct environment) +# Run tools with -m so they come from $(PYTHON)'s environment PIP := $(PYTHON) -m pip PYTEST := $(PYTHON) -m pytest RUFF := $(PYTHON) -m ruff @@ -46,9 +43,9 @@ help: @echo " make check-ci - Run non-mutating checks and tests (CI entrypoint)" @echo " make lint - Run only linting (non-mutating: ruff + pyright)" @echo " make format - Auto-format code with ruff" - @echo " make test - Run tests with coverage (includes slow and benchmark)" - @echo " make qt - Run quick tests only (excludes slow and benchmark)" - @echo " make perf - Run performance analysis with comprehensive reports and plots" + @echo " make test - Run tests with coverage (includes slow tests)" + @echo " make qt - Run quick tests only (excludes slow tests)" + @echo " make perf - Run the performance benchmarks and write reports and plots" @echo " make validate - Validate YAML schemas" @echo "" @echo "Documentation:" @@ -83,7 +80,7 @@ dev: echo "โŒ Error: venv creation failed - $(VENV_BIN)/python not found"; \ exit 1; \ fi; \ - $(VENV_BIN)/python -m pip install -U pip setuptools wheel; \ + $(VENV_BIN)/python -m pip install -U pip setuptools; \ fi @echo "๐Ÿ“ฆ Installing dev dependencies..." @$(VENV_BIN)/python -m pip install -e .'[dev]' @@ -103,7 +100,7 @@ venv: echo "โŒ Error: venv creation failed - $(VENV_BIN)/python not found"; \ exit 1; \ fi - @$(VENV_BIN)/python -m pip install -U pip setuptools wheel + @$(VENV_BIN)/python -m pip install -U pip setuptools @echo "โœ… venv ready. Activate with: source venv/bin/activate" clean-venv: @@ -136,12 +133,12 @@ format: @$(RUFF) format . test: - @echo "๐Ÿงช Running tests with coverage (includes slow and benchmark)..." + @echo "๐Ÿงช Running tests with coverage (includes slow tests)..." @$(PYTEST) qt: - @echo "โšก Running quick tests only (excludes slow and benchmark)..." - @$(PYTEST) --no-cov -m "not slow and not benchmark" + @echo "โšก Running quick tests only (excludes slow tests)..." + @$(PYTEST) --no-cov -m "not slow" perf: @echo "๐Ÿ“Š Running performance analysis with tables and graphs..." @@ -149,11 +146,7 @@ perf: validate: @echo "๐Ÿ“‹ Validating YAML schemas..." - @if $(PYTHON) -c "import jsonschema" >/dev/null 2>&1; then \ - $(PYTHON) -c "import json, yaml, jsonschema, pathlib; from importlib import resources as res; f=res.files('ngraph.schemas').joinpath('scenario.json').open('r', encoding='utf-8'); schema=json.load(f); f.close(); scenario_files=sorted(set(pathlib.Path('scenarios').rglob('*.yaml')) | set(pathlib.Path('scenarios').rglob('*.yml'))); integration_files=sorted(set(pathlib.Path('tests/integration').glob('*.yaml')) | set(pathlib.Path('tests/integration').glob('*.yml'))); all_files=scenario_files+integration_files; [jsonschema.validate(yaml.safe_load(open(fp)), schema) for fp in all_files]; print(f'โœ… Validated {len(all_files)} YAML files against schema ({len(scenario_files)} scenarios, {len(integration_files)} integration tests)')"; \ - else \ - echo "โš ๏ธ jsonschema not installed. Skipping schema validation"; \ - fi + @$(PYTHON) -c "import json, yaml, jsonschema, pathlib; from importlib import resources as res; f=res.files('ngraph.schemas').joinpath('scenario.json').open('r', encoding='utf-8'); schema=json.load(f); f.close(); scenario_files=sorted(set(pathlib.Path('scenarios').rglob('*.yaml')) | set(pathlib.Path('scenarios').rglob('*.yml'))); integration_files=sorted(set(pathlib.Path('tests/integration').glob('*.yaml')) | set(pathlib.Path('tests/integration').glob('*.yml'))); all_files=scenario_files+integration_files; [jsonschema.validate(yaml.safe_load(open(fp)), schema) for fp in all_files]; print(f'โœ… Validated {len(all_files)} YAML files against schema ({len(scenario_files)} scenarios, {len(integration_files)} integration tests)')" # Documentation docs: @@ -197,7 +190,7 @@ build: clean: @echo "๐Ÿงน Cleaning build artifacts and cache files..." @rm -rf build/ dist/ *.egg-info/ - @rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov .coverage coverage.xml coverage-*.xml .benchmarks .pytest-benchmark || true + @rm -rf .pytest_cache .ruff_cache htmlcov .coverage coverage.xml coverage-*.xml || true @find . -path "./venv" -prune -o -type f -name "*.pyc" -delete 2>/dev/null || true @find . -path "./venv" -prune -o -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -path "./venv" -prune -o -type f -name "*.pyo" -delete 2>/dev/null || true diff --git a/README.md b/README.md index b1fb30a..29a388b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Network modeling and analysis framework: Python front end, C++ graph algorithms. ## What It Does -NetGraph lets you model network topologies, traffic demands, and failure scenarios - then analyze capacity and resilience. Define networks in Python or declarative YAML, run max-flow and failure simulations, and export reproducible JSON results. Compute-intensive algorithms run in C++ with the GIL released. +NetGraph models network topologies, traffic demands and failure scenarios, and analyzes capacity and resilience. Networks are defined in Python or in YAML; max-flow and failure simulations export reproducible JSON. ## Install @@ -19,7 +19,7 @@ pip install ngraph ```python from ngraph import Network, Node, Link, analyze, Mode -# Build a simple network +# Three nodes in a line network = Network() network.add_node(Node("A")) network.add_node(Node("B")) @@ -108,25 +108,25 @@ 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. +The scenario builds two Clos sites from one blueprint, finds the largest demand multiplier the network carries, then places that demand under 100 random single-link failures and writes the results to JSON. See [DSL Reference](https://networmix.github.io/NetGraph/reference/dsl/) and [Examples](https://networmix.github.io/NetGraph/examples/clos-fabric/) for more. ## Capabilities -- **Declarative scenarios** with schema validation, reusable blueprints, and strict multigraph representation -- **Failure analysis** via policy engine with weighted modes, risk groups, and non-destructive runtime exclusions -- **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 +- **Declarative scenarios**: schema-validated YAML, reusable blueprints, a strict multigraph model +- **Failure analysis**: weighted failure modes, risk groups, and analysis-time exclusions that leave the base topology untouched +- **Routing models**: cost-only IP routing and capacity-aware traffic engineering +- **Flow placement**: ECMP and WCMP splits, max-flow and demand placement +- **Reproducible results**: seeded randomness and stable link ids - **C++ algorithms** with the GIL released, via [NetGraph-Core](https://github.com/networmix/NetGraph-Core) ## Documentation -- [**Tutorial**](https://networmix.github.io/NetGraph/getting-started/tutorial/) - Getting started guide -- [**Examples**](https://networmix.github.io/NetGraph/examples/clos-fabric/) - Clos fabric, failure analysis, and more +- [**Tutorial**](https://networmix.github.io/NetGraph/getting-started/tutorial/) - Running a scenario from the CLI and from Python +- [**Examples**](https://networmix.github.io/NetGraph/examples/clos-fabric/) - Clos fabric capacity and failure analysis - [**DSL Reference**](https://networmix.github.io/NetGraph/reference/dsl/) - YAML scenario syntax -- [**API Reference**](https://networmix.github.io/NetGraph/reference/api/) - Python API docs +- [**API Reference**](https://networmix.github.io/NetGraph/reference/api/) - Python API ## License diff --git a/dev/check_api_docs.py b/dev/check_api_docs.py index a0dc45f..3631f2b 100644 --- a/dev/check_api_docs.py +++ b/dev/check_api_docs.py @@ -1,7 +1,6 @@ """Verify the committed API reference without modifying it.""" import difflib -import re import sys from pathlib import Path @@ -9,10 +8,6 @@ expected = Path("docs/reference/api-full.md").read_text(encoding="utf-8") generated = generate_api_documentation(output_to_file=False) -# The generator includes wall-clock time; ignore only that metadata line. -timestamp = r"(?m)^Generated from source code on: .*$" -expected = re.sub(timestamp, "Generated from source code on: ", expected) -generated = re.sub(timestamp, "Generated from source code on: ", generated) if generated != expected: sys.stdout.writelines( difflib.unified_diff( diff --git a/dev/dev.md b/dev/dev.md index 046bc29..e98e717 100644 --- a/dev/dev.md +++ b/dev/dev.md @@ -3,13 +3,18 @@ ## Essential Commands ```bash -make dev # Complete dev environment setup -make check # Run all quality checks + tests +make dev # Create venv, install package with dev deps and hooks +make check # Pre-commit with auto-fix, schema check, tests, then lint +make check-ci # Non-mutating lint, schema check, and tests (CI) make test # Run tests with coverage -make docs # Generate API documentation +make docs # Generate API documentation and diagram SVGs make docs-serve # Serve docs locally ``` +`make docs` renders `docs/assets/diagrams/*.dot` with Graphviz; install it +with `brew install graphviz` (macOS) or `apt-get install graphviz` (Debian/Ubuntu). +Without it the diagram step is skipped and the committed SVGs stay as they are. + ## Publishing **Manual**: `make clean && make build && make publish-test && make publish` @@ -24,14 +29,14 @@ make docs-serve # Serve docs locally pyproject.toml # Package config, dependencies, tool settings Makefile # Development commands .pre-commit-config.yaml # Code quality hooks -dev/run-checks.sh # Manual code quality checks +dev/run-checks.sh # Script behind make check ``` -## Git Workflows +## GitHub Workflows ```text .github/workflows/ โ”œโ”€โ”€ python-test.yml # CI: tests, linting, type checking -โ”œโ”€โ”€ docs.yml # Auto-deploy documentation -โ””โ”€โ”€ publish.yml # Auto-publish to PyPI on releases +โ”œโ”€โ”€ docs.yml # Build and deploy docs on push to main +โ””โ”€โ”€ publish.yml # Publish to PyPI on release; Test PyPI on manual run ``` diff --git a/dev/generate_api_docs.py b/dev/generate_api_docs.py index 24e5832..53499f2 100755 --- a/dev/generate_api_docs.py +++ b/dev/generate_api_docs.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 -""" -Generate API documentation for NetGraph -This script should be run from the project root directory. +"""Generate the NetGraph API reference from docstrings. -By default, outputs documentation to stdout. -Use --write-file to write to docs/reference/api-full.md instead. +Run from the project root. Prints to stdout by default; ``--write-file`` +writes docs/reference/api-full.md instead. """ import argparse @@ -13,8 +11,8 @@ import importlib import inspect import os +import re import sys -from datetime import datetime from pathlib import Path @@ -22,12 +20,13 @@ def _normalize_markdown_lists(markdown: str) -> str: """Normalize common Markdown issues in free-form docstrings. Handles: - - MD032: Ensure blank lines before/after lists + - MD032: Insert blank lines before/after lists - MD004: Enforce dash-style bullets ("- ") over "* " or "+ " - MD007: Reduce excessive indentation for list items (aim for 0 or 2 spaces) - MD012: Collapse multiple blank lines into a single blank line + - MD037: Remove spaces inside emphasis markers on lines without backticks - Skips transformations inside fenced code blocks. + Lines inside fenced code blocks are left unchanged. Args: markdown: Raw markdown text, possibly taken from docstrings. @@ -56,6 +55,18 @@ def is_list_item(candidate: str) -> tuple[bool, str]: return True, "ol" return False, "" + def fenced_flags(block: list[str]) -> list[bool]: + # True for fence delimiters and every line between them. + flags: list[bool] = [] + inside = False + for text in block: + if text.lstrip().startswith(("```", "~~~")): + flags.append(True) + inside = not inside + else: + flags.append(inside) + return flags + def previous_nonblank_index(out: list[str]) -> int | None: for idx in range(len(out) - 1, -1, -1): if out[idx].strip() != "": @@ -92,10 +103,9 @@ def previous_list_indent(out: list[str]) -> int | None: line = (" " * indent_len) + "- " + stripped[2:] stripped = line.lstrip() - # Detect list items is_list, list_kind = is_list_item(line) - # Ensure blank line before a list (MD032) + # Insert a blank line before a list (MD032) if is_list: prev_idx = previous_nonblank_index(normalized_lines) if prev_idx is not None: @@ -106,11 +116,10 @@ def previous_list_indent(out: list[str]) -> int | None: # Reduce excessive indentation for list items (MD007, MD005 consistency) if is_list: indent_len = len(line) - len(stripped) - # Use previous list item's indent if present for consistency; else top-level 0 + # Match the previous list item's indent; otherwise top level (0). prev_indent = previous_list_indent(normalized_lines) desired_indent = prev_indent if prev_indent is not None else 0 if indent_len != desired_indent: - # Normalize to desired indent while preserving marker and text if ( stripped.startswith("- ") or stripped.startswith("* ") @@ -124,19 +133,23 @@ def previous_list_indent(out: list[str]) -> int | None: normalized_lines.append(line) - # Ensure blank line after list blocks (MD032) and collapse multiple blanks (MD012) + # Insert a blank line after each list block (MD032) + fenced = fenced_flags(normalized_lines) post: list[str] = [] i = 0 while i < len(normalized_lines): current = normalized_lines[i] post.append(current) - # Add blank line after a list block if next non-list, non-blank starts - if is_list_item(current)[0]: + if is_list_item(current)[0] and not fenced[i]: j = i + 1 - # Collect contiguous list block - while j < len(normalized_lines) and ( - normalized_lines[j].strip() == "" - or is_list_item(normalized_lines[j])[0] + # A list block spans list items and the blank lines between them. + while ( + j < len(normalized_lines) + and not fenced[j] + and ( + normalized_lines[j].strip() == "" + or is_list_item(normalized_lines[j])[0] + ) ): post.append(normalized_lines[j]) i = j @@ -150,77 +163,72 @@ def previous_list_indent(out: list[str]) -> int | None: # Collapse multiple blank lines to a single blank line (MD012) collapsed: list[str] = [] - for ln in post: - if ln.strip() == "" and collapsed and collapsed[-1].strip() == "": + for ln, in_fence in zip(post, fenced_flags(post), strict=True): + if ( + not in_fence + and ln.strip() == "" + and collapsed + and collapsed[-1].strip() == "" + ): continue collapsed.append(ln) - # Normalize emphasis spacing (MD037) outside code and inline code spans - import re - + # Remove spaces inside emphasis markers (MD037) def fix_emphasis(line: str) -> str: - # Skip lines containing backticks to avoid touching code spans + # Lines with backticks may hold code spans; leave them alone. if "`" in line: return line - # Bold asterisks - line = re.sub(r"\*\*\s+([^*][^*]*?)\s+\*\*", r"**\\1**", line) - # Italic asterisks (avoid interfering with bold by requiring not '**') - line = re.sub(r"(?= 3 and parts[1] in order: return (1 + order.index(parts[1]), ".".join(parts[2:])) @@ -231,7 +239,7 @@ def module_sort_key(module_name): def get_class_info(cls): - """Extract comprehensive information about a class.""" + """Collect a class's docstring, public methods, and dataclass fields.""" info = { "name": cls.__name__, "doc": inspect.getdoc(cls) or "No documentation available.", @@ -239,7 +247,7 @@ def get_class_info(cls): "attributes": [], } - # Get methods (including static and class methods) + # Static methods appear as functions and class methods as bound methods. for name, method in inspect.getmembers(cls): if not name.startswith("_") and ( inspect.ismethod(method) or inspect.isfunction(method) @@ -262,17 +270,15 @@ def get_class_info(cls): } ) - # Get dataclass fields if applicable if hasattr(cls, "__dataclass_fields__"): for field_name, field in cls.__dataclass_fields__.items(): field_type = getattr(field.type, "__name__", str(field.type)) - # Handle dataclass field defaults properly if field.default is not dataclasses.MISSING: default_val = field.default elif field.default_factory is not dataclasses.MISSING: try: - # Try to call the factory to get a representative value + # Show the factory's output, e.g. [] or {}. default_val = field.default_factory() except Exception: default_val = f"{field.default_factory.__name__}()" @@ -299,7 +305,7 @@ def get_class_info(cls): def get_function_info(func): - """Extract information about a function.""" + """Collect a function's name, signature, and docstring.""" try: sig = str(inspect.signature(func)) except (ValueError, TypeError): @@ -313,7 +319,10 @@ def get_function_info(func): def document_module(module_name): - """Generate documentation for a single module.""" + """Render one module's section: docstring, public classes, and functions. + + An import failure yields a section that reports the error. + """ try: module = importlib.import_module(module_name) except ImportError as e: @@ -321,12 +330,11 @@ def document_module(module_name): doc = f"## {module_name}\n\n" - # Module docstring if module.__doc__: docstring = _normalize_markdown_lists(module.__doc__.strip()) doc += f"{docstring}\n\n" - # Get classes and functions defined in this module + # Skip names imported from other modules. classes = [] functions = [] @@ -337,7 +345,6 @@ def document_module(module_name): elif inspect.isfunction(obj) and obj.__module__ == module_name: functions.append(get_function_info(obj)) - # Document classes for cls_info in classes: doc += f"### {cls_info['name']}\n\n" doc += f"{_normalize_markdown_lists(cls_info['doc'])}\n\n" @@ -363,7 +370,6 @@ def document_module(module_name): doc += f"- `{method['name']}{method['signature']}`\n" doc += "\n" - # Document functions for func_info in functions: doc += f"### {func_info['name']}{func_info['signature']}\n\n" doc += f"{_normalize_markdown_lists(func_info['doc'])}\n\n" @@ -373,39 +379,33 @@ def document_module(module_name): def generate_api_documentation(output_to_file=False): - """Generate the complete API documentation. + """Render the API reference for every discovered module. Args: output_to_file (bool): If True, write to docs/reference/api-full.md. - If False, return the documentation string. + If False, return the documentation string. Returns: - str: The generated documentation (when output_to_file=False) + str: The generated documentation when ``output_to_file`` is False; + otherwise None. """ - - # Automatically discover all documentable modules modules = discover_modules() print(f"๐Ÿ” Auto-discovered {len(modules)} modules to document...") - # Generate header - timestamp = datetime.now().strftime("%B %d, %Y at %H:%M UTC") header = f""" # NetGraph API Reference (Auto-Generated) -This is the complete auto-generated API documentation for NetGraph. -For a curated, example-driven API guide, see [api.md](api.md). +Every public module, class and function, generated from the docstrings. +The [API guide](api.md) covers the same API with examples. Quick links: -- [Main API Guide (api.md)](api.md) -- [This Document (api-full.md)](api-full.md) +- [API Guide](api.md) - [CLI Reference](cli.md) - [DSL Reference](dsl.md) -Generated from source code on: {timestamp} - Modules auto-discovered: {len(modules)} --- @@ -415,7 +415,6 @@ def generate_api_documentation(output_to_file=False): print("๐Ÿ“ Generating API documentation...") doc = header - # Generate documentation for each module for module_name in modules: print(f" ๐Ÿ“ Documenting {module_name}") try: @@ -425,36 +424,18 @@ def generate_api_documentation(output_to_file=False): print(f" โš ๏ธ Error documenting {module_name}: {e}") doc += f"## {module_name}\n\n**Error:** Could not generate documentation for this module: {e}\n\n---\n\n" - # Add footer footer = """ -## Error Handling - -NetGraph uses standard Python exceptions: - -- `ValueError` - For validation errors -- `KeyError` - For missing required fields -- `RuntimeError` - For runtime errors - -For complete method signatures and detailed documentation, use Python's help system: - -```python -help(ngraph.scenario.Scenario) -help(ngraph.network.Network.max_flow) -``` - ---- +## Errors -*This documentation was auto-generated from the NetGraph source code.* +Invalid input mostly raises `ValueError`; a scenario that fails schema validation raises `jsonschema.ValidationError`. Each entry's Raises section lists its cases. """ doc += footer if output_to_file: - # Ensure output directory exists output_path = Path("docs/reference/api-full.md") output_path.parent.mkdir(parents=True, exist_ok=True) - # Write to file with open(output_path, "w", encoding="utf-8") as f: f.write(doc) @@ -463,7 +444,6 @@ def generate_api_documentation(output_to_file=False): print(f"๐Ÿ“Š Size: {len(doc):,} characters") print(f"๐Ÿ“š Modules documented: {len(modules)}") else: - # Return the documentation string return doc @@ -488,6 +468,5 @@ def generate_api_documentation(output_to_file=False): if args.write_file: generate_api_documentation(output_to_file=True) else: - # Output to stdout doc = generate_api_documentation(output_to_file=False) print(doc) diff --git a/dev/perf/__init__.py b/dev/perf/__init__.py index 02d7fd4..a67848b 100644 --- a/dev/perf/__init__.py +++ b/dev/perf/__init__.py @@ -1,32 +1,29 @@ #!/usr/bin/env python3 -"""NetGraph Performance Analysis Module. +"""Benchmarks for NetGraph operations, with complexity fits and plots. -This module benchmarks and processes NetGraph network modeling operations. - -Core Components: -- BenchmarkProfile: Direct topology configuration -- BenchmarkSample: Single benchmark measurement -- BenchmarkResult: Collection of samples from one profile -- PerformanceAnalyzer: Analysis and reporting engine -- BenchmarkRunner: Execution engine -- PerformanceVisualizer: Chart and plot generation +Main components: +- BenchmarkProfile: Named set of cases analyzed together +- BenchmarkSample: Timing statistics for one case +- BenchmarkResult: Samples from one profile run +- PerformanceAnalyzer: Prints summaries, complexity fits, and regressions +- BenchmarkRunner: Runs profiles and collects samples +- PerformanceVisualizer: Writes plots and the results JSON Usage: from dev.perf import BenchmarkRunner, BENCHMARK_PROFILES runner = BenchmarkRunner() - profile = BENCHMARK_PROFILES[0] # Get first profile + profile = BENCHMARK_PROFILES[0] result = runner.run_profile(profile) - # Analyze performance from dev.perf import PerformanceAnalyzer analyzer = PerformanceAnalyzer() analyzer.add_run(result) + analyzer.print_analysis_report() - # Generate plots from dev.perf import PerformanceVisualizer viz = PerformanceVisualizer() - viz.plot_complexity_analysis(analyzer, "shortest_path") + viz.create_summary_report(analyzer, timestamp="manual") """ from __future__ import annotations diff --git a/dev/perf/analysis.py b/dev/perf/analysis.py index f9ebe2a..0af88c1 100644 --- a/dev/perf/analysis.py +++ b/dev/perf/analysis.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Analysis engine for NetGraph performance benchmarks.""" +"""Complexity fitting and regression reporting for benchmark results.""" from __future__ import annotations @@ -7,14 +7,11 @@ from pathlib import Path from typing import Any -from .core import BenchmarkResult, BenchmarkSample, BenchmarkTask +from .core import BenchmarkResult, BenchmarkSample def _fit_power_law(samples: list[BenchmarkSample]) -> tuple[float, float]: - """Fit power law to benchmark samples using least squares regression. - - Performs linear regression in log space to fit y = a * x^b model. - Calculates R^2 goodness of fit metric. + """Fit y = a * x^b to mean times by least squares in log space. Args: samples: List of benchmark samples with problem sizes and timings. @@ -29,11 +26,9 @@ def _fit_power_law(samples: list[BenchmarkSample]) -> tuple[float, float]: if len(samples) < 2: raise ValueError("Need at least 2 samples for power law fitting") - # Convert to log space for linear regression log_sizes = [math.log(s.numeric_problem_size()) for s in samples] log_times = [math.log(s.mean_time) for s in samples] - # Least squares regression in log space n = len(samples) sum_x = sum(log_sizes) sum_y = sum(log_times) @@ -42,7 +37,7 @@ def _fit_power_law(samples: list[BenchmarkSample]) -> tuple[float, float]: slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x) - # Calculate R^2 + # R^2 of the fit, in log space y_mean = sum_y / n ss_tot = sum((y - y_mean) ** 2 for y in log_times) ss_res = sum( @@ -55,7 +50,7 @@ def _fit_power_law(samples: list[BenchmarkSample]) -> tuple[float, float]: class PerformanceAnalyzer: - """Processes benchmark results and detects performance regressions.""" + """Report timing, complexity fit, and regressions for benchmark runs.""" def __init__(self, results_dir: Path | None = None): self.results_dir = results_dir or Path("dev/perf_results") @@ -78,7 +73,7 @@ def add_runs(self, results: list[BenchmarkResult]) -> None: self.runs.extend(results) def print_analysis_report(self) -> None: - """Print analysis report to stdout.""" + """Print a summary per run, plus a complexity fit if plots are enabled.""" if not self.runs: print("No benchmark results to analyze") return @@ -100,7 +95,6 @@ def _print_run_summary(self, run: BenchmarkResult) -> None: print(f" Iterations per case: {run.profile.iterations}") print(f" Expected complexity: {run.profile.analysis.expected.display_name}") - # Calculate statistics across all samples SECONDS_TO_MS = 1000 all_times = [s.mean_time * SECONDS_TO_MS for s in samples] size_ratio = ( @@ -133,7 +127,6 @@ def _print_run_summary(self, run: BenchmarkResult) -> None: f"{cv_pct:>7.1f}% {status:>12}" ) - # Add interpretation note for high CV values high_cv_samples = [ s for s in samples @@ -154,13 +147,11 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: print("\nโœ— Insufficient samples for complexity analysis") return - # Fit power law try: empirical_exponent, r_squared = _fit_power_law(samples) print("\nComplexity Analysis:") - # Model comparison expected_exp = run.profile.analysis.expected.expected_exponent deviation_pct = abs(empirical_exponent - expected_exp) / expected_exp * 100 interpreted = run.profile.analysis.expected.interpret_exponent( @@ -174,7 +165,6 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: print( f" Measured: {interpreted} (exponent = {empirical_exponent:.3f})" ) - # R^2 quality assessment thresholds EXCELLENT_R2_THRESHOLD = 0.99 GOOD_R2_THRESHOLD = 0.95 @@ -187,7 +177,6 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: print(f" Fit quality: R^2 = {r_squared:.4f} {quality}") - # Pass/fail assessment if deviation_pct <= run.profile.analysis.fit_tol_pct: print("\n โœ“ Performance matches expected complexity") print( @@ -199,7 +188,6 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: f" Deviation: {deviation_pct:.1f}% (exceeds {run.profile.analysis.fit_tol_pct:.0f}% tolerance)" ) - # Regression check with size mapping if run.profile.analysis.should_scan_regressions(): regressions = self._find_performance_regressions(run, samples) if regressions: @@ -209,7 +197,7 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: ) print(" Violations:") - # Map numeric sizes back to expressions for clarity + # Label violations with the original size expressions. size_to_expr = { int(s.numeric_problem_size()): s.problem_size for s in samples } @@ -232,10 +220,9 @@ def _print_complexity_analysis(self, run: BenchmarkResult) -> None: def _find_performance_regressions( self, run: BenchmarkResult, samples: list[BenchmarkSample] ) -> list[tuple[int, float]]: - """Find performance regressions against expected model. + """Return samples slower than the model predicts by more than the tolerance. - Compares actual performance against expected complexity model. - Identifies samples that exceed regression tolerance threshold. + Predictions scale the smallest sample's time by the expected model. Args: run: Benchmark result containing profile and analysis configuration. @@ -251,12 +238,10 @@ def _find_performance_regressions( for sample in samples[1:]: sample_size = int(sample.numeric_problem_size()) - # Calculate expected time based on model expected_time = run.profile.analysis.expected.calculate_expected_time( baseline.mean_time, baseline_size, sample_size ) - # Check if actual time exceeds expected by tolerance performance_ratio = sample.mean_time / expected_time if performance_ratio > 1 + run.profile.analysis.regression_tol_pct / 100: deviation_pct = (performance_ratio - 1) * 100 @@ -264,48 +249,31 @@ def _find_performance_regressions( return regressions - def get_samples_by_task(self, task: BenchmarkTask) -> list[BenchmarkSample]: - """Get all samples for a specific task across all runs.""" - samples = [] - for run in self.runs: - if task in run.profile.tasks: - samples.extend(run.samples) - return samples + def get_complexity_summary(self, run: BenchmarkResult) -> dict[str, Any]: + """Return the power-law fit for one run's samples. - def get_complexity_summary(self, task: BenchmarkTask) -> dict[str, Any]: - """Get complexity analysis summary for a task.""" - samples = self.get_samples_by_task(task) - if len(samples) < 2: - return {} + Each run is fitted on its own: profiles that share a task (the Clos and + grid SPF profiles) scale differently and must not be pooled. - sorted_samples = sorted(samples, key=lambda s: s.numeric_problem_size()) + Empty when the run has fewer than two samples or the fit fails. + """ + sorted_samples = sorted(run.samples, key=lambda s: s.numeric_problem_size()) + if len(sorted_samples) < 2: + return {} try: empirical_exponent, r_squared = _fit_power_law(sorted_samples) - - first_size = sorted_samples[0].numeric_problem_size() - last_size = sorted_samples[-1].numeric_problem_size() - - # Get the expected complexity model from the first run containing this task - expected_model = None - for run in self.runs: - if task in run.profile.tasks: - expected_model = run.profile.analysis.expected - break - - result = { - "empirical_exponent": empirical_exponent, - "r_squared": r_squared, - "size_range": f"{first_size:.0f}-{last_size:.0f}", - "samples": len(sorted_samples), - } - - # Add interpreted complexity if we have a model - if expected_model: - result["interpreted_complexity"] = expected_model.interpret_exponent( - empirical_exponent - ) - - return result except (ValueError, ZeroDivisionError, OverflowError): return {} + + first_size = sorted_samples[0].numeric_problem_size() + last_size = sorted_samples[-1].numeric_problem_size() + return { + "empirical_exponent": empirical_exponent, + "r_squared": r_squared, + "size_range": f"{first_size:.0f}-{last_size:.0f}", + "samples": len(sorted_samples), + "interpreted_complexity": run.profile.analysis.expected.interpret_exponent( + empirical_exponent + ), + } diff --git a/dev/perf/core.py b/dev/perf/core.py index 136141c..3fabd56 100644 --- a/dev/perf/core.py +++ b/dev/perf/core.py @@ -15,12 +15,11 @@ class BenchmarkTask(Enum): SHORTEST_PATH = auto() SHORTEST_PATH_NETWORKX = auto() MAX_FLOW = auto() - # Add more tasks as they are implemented @dataclass class ComplexityModel: - """Lightweight complexity model for performance analysis.""" + """Named scaling model with an expected power-law exponent.""" name: str expected_exponent: float @@ -36,7 +35,11 @@ def calculate_expected_time( baseline_size: int, target_size: int, ) -> float: - """Calculate expected runtime for target_size given this complexity model.""" + """Scale ``baseline_time`` from ``baseline_size`` to ``target_size``. + + Raises: + ValueError: If either size is not positive. + """ if baseline_size <= 0 or target_size <= 0: raise ValueError("sizes must be positive") @@ -57,9 +60,9 @@ def calculate_expected_time( return baseline_time * (ratio**self.expected_exponent) def interpret_exponent(self, empirical_exponent: float) -> str: - """Interpret empirical exponent into human-readable complexity description. + """Map a fitted exponent to a complexity label. - Thresholds based on common algorithmic complexity classes: + Thresholds: - < 1.2: near-linear (close to O(n)) - 1.2-1.8: sub-quadratic (between O(n) and O(n^2)) - 1.8-2.5: quadratic (close to O(n^2)) @@ -71,7 +74,6 @@ def interpret_exponent(self, empirical_exponent: float) -> str: Returns: Human-readable complexity description. """ - # Complexity interpretation thresholds LINEAR_THRESHOLD = 1.2 QUADRATIC_THRESHOLD = 1.8 SUPER_QUADRATIC_THRESHOLD = 2.5 @@ -86,7 +88,6 @@ def interpret_exponent(self, empirical_exponent: float) -> str: return "super-quadratic" -# Predefined complexity models LINEAR = ComplexityModel("linear", 1.0, "Linear O(n)") N_LOG_N = ComplexityModel("n_log_n", 1.1, "n log n") QUADRATIC = ComplexityModel("quadratic", 2.0, "Quadratic O(n^2)") @@ -124,9 +125,8 @@ def __post_init__(self) -> None: raise ValueError("case.name must not be empty") def numeric_problem_size(self) -> float: - """Get the numeric value of problem_size. + """Evaluate ``problem_size`` as a math expression. - Supports simple math expressions using standard functions. Examples: "100", "10 * log(10)", "2 ** 8", "sqrt(100)" Returns: @@ -138,12 +138,11 @@ def numeric_problem_size(self) -> float: if not isinstance(self.problem_size, str): raise ValueError(f"problem_size must be str, got {type(self.problem_size)}") - # Validate expression contains only allowed characters allowed_chars = set("0123456789+-*/.() abcdefghijklmnopqrstuvwxyz_") if not all(c in allowed_chars for c in self.problem_size.lower()): raise ValueError(f"Invalid characters in problem_size: {self.problem_size}") - # Create restricted namespace - only math functions, no builtins + # eval sees only math names; builtins are removed. safe_globals = { "__builtins__": {}, "math": math, @@ -174,7 +173,7 @@ def numeric_problem_size(self) -> float: @dataclass class BenchmarkProfile: - """A logical benchmark suite (scaling series or batch).""" + """Named set of benchmark cases analyzed together, usually a scaling series.""" name: str cases: list[BenchmarkCase] @@ -194,7 +193,7 @@ def tasks(self) -> list[BenchmarkTask]: @dataclass class BenchmarkSample: - """Concrete measurement produced by executing a case.""" + """Timing statistics, in seconds, from running one case.""" case: BenchmarkCase problem_size: str @@ -218,12 +217,12 @@ def name(self) -> str: @property def time_ms(self) -> float: - """Convert mean time from seconds to milliseconds.""" + """Mean time in milliseconds.""" SECONDS_TO_MS = 1000 return self.mean_time * SECONDS_TO_MS def numeric_problem_size(self) -> float: - """Get the numeric value of problem_size.""" + """Evaluate the case's ``problem_size`` expression.""" return self.case.numeric_problem_size() @@ -258,7 +257,10 @@ def total_rounds(self) -> int: return sum(s.rounds for s in self.samples) def total_execution_time(self) -> float: - """Calculate total benchmark execution time in seconds.""" + """Estimate measured time in seconds as mean time times rounds. + + Excludes warm-up calls and setup. + """ return sum(s.mean_time * s.rounds for s in self.samples) @@ -268,7 +270,7 @@ def calculate_expected_time( target_size: int, complexity: str, ) -> float: - """Calculate expected runtime for target_size given baseline performance. + """Predict runtime at target_size from a baseline measurement. Args: baseline_time: Measured time at baseline_size diff --git a/dev/perf/main.py b/dev/perf/main.py index 6a5bbc6..8d34002 100644 --- a/dev/perf/main.py +++ b/dev/perf/main.py @@ -21,12 +21,14 @@ def cmd_run(args: argparse.Namespace) -> int: - """Run command implementation.""" + """Run profiles, print the analysis, and write plots and a results JSON. + + Returns 0 on success and 1 on error. + """ try: print("Initializing performance analysis...\n") PERF_RESULTS_DIR.mkdir(parents=True, exist_ok=True) - # Get profiles to run if args.profile: try: profile = get_profile_by_name(args.profile) @@ -42,7 +44,6 @@ def cmd_run(args: argparse.Namespace) -> int: print(f"Selected {len(profiles)} profile(s) for benchmarking") - # Run benchmarks print("\n[ BENCHMARKING ]") print("-" * 60) runner = BenchmarkRunner() @@ -58,18 +59,15 @@ def cmd_run(args: argparse.Namespace) -> int: results.append((profile.name, result)) print(f" โœ“ Completed in {result.total_execution_time():.2f}s") - # Analyze results print("\n\n[ ANALYSIS ]") print("-" * 60) analyzer = PerformanceAnalyzer(results_dir=PERF_RESULTS_DIR) analyzer.add_runs([result for _, result in results]) analyzer.print_analysis_report() - # Save results to disk timestamp = time.strftime("%Y%m%d_%H%M%S") results_file = PERF_RESULTS_DIR / f"benchmark_results_{timestamp}.json" - # Generate plots and export data print("\n[ RESULTS & ARTIFACTS ]") print("-" * 60) if any(result.profile.analysis.generates_plots() for _, result in results): @@ -79,7 +77,7 @@ def cmd_run(args: argparse.Namespace) -> int: viz.create_summary_report(analyzer, timestamp) viz.export_results_json(analyzer, results, results_file) else: - # Even if no plots are generated, still export the raw data + # Export the raw data even without plots. viz = PerformanceVisualizer(plots_dir=PERF_PLOTS_DIR) print("Generated:") viz.export_results_json(analyzer, results, results_file) @@ -93,9 +91,8 @@ def cmd_run(args: argparse.Namespace) -> int: def cmd_show_profile(args: argparse.Namespace) -> int: - """Show profile configuration.""" + """Print one profile's settings and cases, or list profiles if none is named.""" try: - # If no profile name provided, list available profiles if not args.profile_name: print("Available benchmark profiles:") print("-" * 60) @@ -124,7 +121,6 @@ def cmd_show_profile(args: argparse.Namespace) -> int: print(f" {i}. {case.name}") print(f" Problem size: {case.problem_size}") - # Show topology information generically topology = case.inputs.get("topology") if topology: print(f" Topology: {topology.__class__.__name__}") @@ -151,9 +147,8 @@ def cmd_show_profile(args: argparse.Namespace) -> int: def cmd_show_topology(args: argparse.Namespace) -> int: - """Show topology configuration and expected dimensions.""" + """Print a topology's parameters and expected size, or list topology types.""" try: - # If no topology type provided, list available topologies if not args.topology_type: print("Available topology types:") print("-" * 60) @@ -161,7 +156,6 @@ def cmd_show_topology(args: argparse.Namespace) -> int: for i, topology_class in enumerate(ALL_TOPOLOGIES, 1): print(f"{i}. {topology_class.__name__}") - # Get parameter information from dataclass fields if dataclasses.is_dataclass(topology_class): fields = dataclasses.fields(topology_class) param_fields = [ @@ -174,10 +168,8 @@ def cmd_show_topology(args: argparse.Namespace) -> int: print() return 0 - # Parse topology type and parameters topology_type = args.topology_type - # Find the topology class by name topology_class = None for topo_class in ALL_TOPOLOGIES: if topo_class.__name__ == topology_type: @@ -190,7 +182,6 @@ def cmd_show_topology(args: argparse.Namespace) -> int: print(f"Available types: {', '.join(available_types)}") return 1 - # Parse parameter key=value pairs params = {} for param in args.parameters: if "=" not in param: @@ -200,7 +191,7 @@ def cmd_show_topology(args: argparse.Namespace) -> int: key, value = param.split("=", 1) - # Try to parse value as appropriate type + # Coerce to bool, then int, then float; otherwise keep the string. if value.lower() in ("true", "false"): params[key] = value.lower() == "true" elif value.isdigit(): @@ -211,7 +202,6 @@ def cmd_show_topology(args: argparse.Namespace) -> int: except ValueError: params[key] = value - # Create topology by direct instantiation topology = topology_class(**params) print(f"Topology: {topology.__class__.__name__}") @@ -241,7 +231,7 @@ def cmd_show_topology(args: argparse.Namespace) -> int: def main() -> int: - """Main entry point.""" + """Parse arguments, dispatch to a subcommand, and return the exit code.""" parser = argparse.ArgumentParser( prog="perf", description="NetGraph performance benchmarking & analysis", @@ -251,17 +241,14 @@ def main() -> int: run_p = sub.add_parser("run", help="Run benchmarks then analyze") run_p.add_argument("--profile", help="Run a single profile") - # Add show command with subcommands show_p = sub.add_parser("show", help="Show configuration details") show_sub = show_p.add_subparsers(dest="show_command") - # Show profile subcommand profile_p = show_sub.add_parser("profile", help="Show profile configuration") profile_p.add_argument( "profile_name", nargs="?", help="Name of the profile to show" ) - # Show topology subcommand topology_p = show_sub.add_parser("topology", help="Show topology dimensions") topology_p.add_argument( "topology_type", nargs="?", help="Type of topology (e.g., Grid2DTopology)" diff --git a/dev/perf/profiles.py b/dev/perf/profiles.py index 13b0f8e..d7947a3 100644 --- a/dev/perf/profiles.py +++ b/dev/perf/profiles.py @@ -20,7 +20,7 @@ name="spf_clos2tier_10_10", task=BenchmarkTask.SHORTEST_PATH, inputs={"topology": Clos2TierTopology(leaf_count=10, spine_count=10)}, - problem_size="100 * log(20)", # This is Dijkstra, so E log V + problem_size="100 * log(20)", # Dijkstra: O(E log V) ), BenchmarkCase( name="spf_clos2tier_100_100", @@ -56,7 +56,7 @@ name="spf_grid2d_10_10", task=BenchmarkTask.SHORTEST_PATH, inputs={"topology": Grid2DTopology(rows=10, cols=10)}, - problem_size="180 * log(100)", # This is Dijkstra, so E log V + problem_size="180 * log(100)", # Dijkstra: O(E log V) ), BenchmarkCase( name="spf_grid2d_100_100", @@ -86,7 +86,7 @@ name="spf_clos2tier_10_10", task=BenchmarkTask.SHORTEST_PATH_NETWORKX, inputs={"topology": Clos2TierTopology(leaf_count=10, spine_count=10)}, - problem_size="100 * log(20)", # This is Dijkstra, so E log V + problem_size="100 * log(20)", # Dijkstra: O(E log V) ), BenchmarkCase( name="spf_clos2tier_100_100", @@ -122,7 +122,7 @@ name="spf_grid2d_10_10_networkx", task=BenchmarkTask.SHORTEST_PATH_NETWORKX, inputs={"topology": Grid2DTopology(rows=10, cols=10)}, - problem_size="180 * log(100)", # This is Dijkstra, so E log V + problem_size="180 * log(100)", # Dijkstra: O(E log V) ), BenchmarkCase( name="spf_grid2d_100_100_networkx", @@ -209,7 +209,7 @@ def get_profile_by_name(name: str) -> BenchmarkProfile: - """Get benchmark profile by name.""" + """Return the profile named ``name``; raise KeyError if none matches.""" for profile in BENCHMARK_PROFILES: if profile.name == name: return profile diff --git a/dev/perf/runner.py b/dev/perf/runner.py index c14ea49..1a7fb9e 100644 --- a/dev/perf/runner.py +++ b/dev/perf/runner.py @@ -24,36 +24,31 @@ def _time_func(func: Callable[[], Any], runs: int) -> dict[str, float]: - """Time function execution over multiple runs. + """Time ``func`` over ``runs`` calls after up to 10 untimed warm-up calls. - Includes GC control to reduce variance from garbage collection. - Performs warm-up runs before timing to reduce JIT compilation effects. + Automatic GC is off while timing, to reduce variance. Args: - func: Function to time (should take no arguments). + func: Zero-argument callable to time. runs: Number of timing runs to perform. Returns: Dictionary with timing statistics: mean, median, std, min, max, rounds. """ - # Disable GC during timing to reduce variance gc_was_enabled = gc.isenabled() gc.disable() try: - # Force collection before timing gc.collect() - # Warm-up runs to reduce JIT compilation and cache effects WARMUP_RUNS = 10 for _ in range(min(WARMUP_RUNS, runs)): func() - # Actual timing runs samples = [] NANOSECONDS_TO_SECONDS = 1e9 for _ in range(runs): - # Force minor collection between runs to prevent buildup + # Collect gen 0 between runs so garbage does not pile up with GC off. gc.collect(0) start = time.perf_counter_ns() @@ -69,16 +64,14 @@ def _time_func(func: Callable[[], Any], runs: int) -> dict[str, float]: "rounds": len(samples), } finally: - # Re-enable GC if it was enabled before if gc_was_enabled: gc.enable() def _execute_spf_benchmark(case: BenchmarkCase, iterations: int) -> BenchmarkSample: - """Execute SPF benchmark for a given case using NetGraph-Core. + """Time NetGraph-Core SPF from node 0 over all min-cost edges. - Creates network and Core graph once outside timing loop to reduce variance. - Uses the first node as the source for shortest path calculation. + The network and Core graph are built once, outside the timed loop. Args: case: Benchmark case containing topology and configuration. @@ -88,29 +81,21 @@ def _execute_spf_benchmark(case: BenchmarkCase, iterations: int) -> BenchmarkSam BenchmarkSample with timing statistics and metadata. """ topology: Topology = case.inputs["topology"] - - # Create network and Core graph once outside timing loop network = topology.create_network() ctx = AnalysisContext.from_network(network) - - # Use context's algorithms instance algs = ctx.algorithms - - # Use first node (ID 0) as source for SPF source_id = 0 - # Create edge selection for all min-cost edges + # All parallel min-cost edges; capacity is ignored. edge_selection = netgraph_core.EdgeSelection( multi_edge=True, require_capacity=False, tie_break=netgraph_core.EdgeTieBreak.DETERMINISTIC, ) - # Create a closure that captures the context and source def run_spf(): return algs.spf(ctx.handle, source_id, selection=edge_selection) - # Time the SPF execution timing_stats = _time_func(run_spf, iterations) return BenchmarkSample( @@ -129,13 +114,10 @@ def run_spf(): def _execute_spf_networkx_benchmark( case: BenchmarkCase, iterations: int ) -> BenchmarkSample: - """Execute SPF benchmark using NetworkX for comparison. - - Creates network and NetworkX MultiDiGraph once outside timing loop to reduce - variance. Uses the first node as the source for shortest path calculation. + """Time NetworkX ``dijkstra_predecessor_and_distance`` as a baseline for Core SPF. - Note: This benchmarks NetworkX's dijkstra_predecessor_and_distance for - direct comparison with NetGraph-Core's SPF implementation. + The network and NetworkX MultiDiGraph are built once, outside the timed + loop. The source is the first node. Args: case: Benchmark case containing topology and configuration. @@ -145,38 +127,28 @@ def _execute_spf_networkx_benchmark( BenchmarkSample with timing statistics and metadata. """ topology: Topology = case.inputs["topology"] - - # Create network once outside timing loop network = topology.create_network() - # Build NetworkX MultiDiGraph manually for NetworkX algorithms nx_graph = nx.MultiDiGraph() - - # Add nodes for node_name, node in network.nodes.items(): if not node.disabled: nx_graph.add_node(node_name) - # Add edges (with reverse edges for bidirectional connectivity) + # Links are bidirectional; add one edge per direction. for _, link in network.links.items(): if not link.disabled: - # Forward edge nx_graph.add_edge( link.source, link.target, capacity=link.capacity, cost=link.cost ) - # Reverse edge nx_graph.add_edge( link.target, link.source, capacity=link.capacity, cost=link.cost ) - # Use first node as source for SPF source = next(iter(nx_graph.nodes)) - # Create a closure that captures the graph and source def run_spf(): return nx.dijkstra_predecessor_and_distance(nx_graph, source, weight="cost") - # Time the SPF execution timing_stats = _time_func(run_spf, iterations) return BenchmarkSample( @@ -195,10 +167,10 @@ def run_spf(): def _execute_max_flow_benchmark( case: BenchmarkCase, iterations: int ) -> BenchmarkSample: - """Execute max flow benchmark using NetGraph-Core. + """Time NetGraph-Core max flow from the first node ID to the last. - Creates network and Core graph once outside timing loop to reduce variance. - Uses first node as source and last node as sink for maximum path length. + Uses proportional placement over all paths (``shortest_path=False``). The + network and Core graph are built once, outside the timed loop. Args: case: Benchmark case containing topology and configuration. @@ -210,15 +182,10 @@ def _execute_max_flow_benchmark( topology: Topology = case.inputs["topology"] network = topology.create_network() ctx = AnalysisContext.from_network(network) - - # Use context's algorithms instance algs = ctx.algorithms - - # Use first node as source and last node as sink for maximum path length source_id = 0 sink_id = ctx.multidigraph.num_nodes() - 1 - # Create a closure that captures the context handle and node IDs def run_max_flow(): flow_value, _ = algs.max_flow( ctx.handle, @@ -229,7 +196,6 @@ def run_max_flow(): ) return flow_value - # Time the max flow execution timing_stats = _time_func(run_max_flow, iterations) return BenchmarkSample( diff --git a/dev/perf/topology.py b/dev/perf/topology.py index 6b2f8a1..d789ea9 100644 --- a/dev/perf/topology.py +++ b/dev/perf/topology.py @@ -1,14 +1,10 @@ #!/usr/bin/env python3 """Topology generators for performance benchmarking. -This module provides topology generators that create Network instances with -predefined structures and known node/link counts. Each topology validates -that the generated network matches expected dimensions to ensure benchmark -consistency across runs. - -The base Topology class defines the interface for all generators, requiring -subclasses to implement _build() and declare expected node/link counts. -Concrete implementations include Clos fabrics and 2D grid topologies. +Each generator builds a Network with known node and link counts. +``create_network`` rejects a network whose counts differ, so a benchmark size +always refers to the same graph. Subclasses implement ``_build()`` and set the +expected counts. Included: a 2-tier Clos fabric and a 2D grid or torus. """ from __future__ import annotations @@ -35,21 +31,18 @@ class Topology(ABC): @abstractmethod def _build(self, seed: int) -> Network: - """Build network topology. + """Build the network without checking counts. Args: seed: Random seed for deterministic generation. Returns: - Network instance with topology-specific structure. + The generated network. """ ... def create_network(self, *, seed: int = 42) -> Network: - """Create network from topology configuration. - - This method builds the network and validates that it matches the - expected node and link counts to ensure benchmark consistency. + """Build the network and check it against the expected counts. Args: seed: Random seed for network generation. @@ -77,31 +70,26 @@ def create_network(self, *, seed: int = 42) -> Network: class Clos2TierTopology(Topology): """2-tier Clos (leaf-spine) fabric topology. - Creates a standard leaf-spine network with full mesh connectivity - between leaf and spine tiers. + Every leaf connects to every spine. """ leaf_count: int = 4 spine_count: int = 4 link_capacity: float = 100.0 - # Computed fields set during initialization to avoid repeated calculations + # Set by __post_init__ from the parameters above. name: str = "" expected_nodes: int = 0 expected_links: int = 0 def __post_init__(self) -> None: - """Calculate topology dimensions and naming. - - Sets name, expected_nodes, and expected_links based on leaf/spine - counts to enable validation during network creation. - """ + """Derive ``name`` and the expected node and link counts.""" self.name = f"clos_{self.leaf_count}x{self.spine_count}" self.expected_nodes = self.leaf_count + self.spine_count self.expected_links = self.leaf_count * self.spine_count def _build(self, seed: int) -> Network: - """Build Clos fabric using scenario YAML generation. + """Build the fabric from a generated scenario YAML. Args: seed: Random seed for deterministic generation. @@ -114,20 +102,21 @@ def _build(self, seed: int) -> Network: seed: {seed} network: name: "{self.name}" - groups: + nodes: leaf: - node_count: {self.leaf_count} - name_template: "leaf/leaf{{node_num:02d}}" + count: {self.leaf_count} + template: "leaf{{n:02d}}" attrs: {{layer: leaf, site_type: core}} spine: - node_count: {self.spine_count} - name_template: "spine/spine{{node_num:02d}}" + count: {self.spine_count} + template: "spine{{n:02d}}" attrs: {{layer: spine, site_type: core}} - adjacency: + links: - source: /leaf target: /spine pattern: mesh - link_params: {{capacity: {self.link_capacity}, cost: 1}} + capacity: {self.link_capacity} + cost: 1 """ ).strip() return Scenario.from_yaml(yaml).network @@ -153,16 +142,13 @@ class Grid2DTopology(Topology): link_capacity: float = 100.0 link_cost: float = 1.0 - # Computed fields set during initialization to avoid repeated calculations + # Set by __post_init__ from the parameters above. name: str = "" expected_nodes: int = 0 expected_links: int = 0 def __post_init__(self) -> None: - """Calculate grid dimensions and link counts. - - Validates grid parameters and computes expected node/link counts - based on grid dimensions and connectivity options. + """Validate the grid size and derive ``name`` and expected counts. Raises: ValueError: If rows or cols are less than 2. @@ -172,12 +158,12 @@ def __post_init__(self) -> None: self.name = f"{'torus' if self.wrap else 'grid'}_{self.rows}x{self.cols}" self.expected_nodes = self.rows * self.cols - # Calculate expected links by simulating the generation logic - # This ensures the count matches what _build() actually creates + # Replay _build()'s edge rules so wrap-around duplicates are dropped + # the same way. expected_edges: set[tuple[str, str]] = set() for r, c in product(range(self.rows), range(self.cols)): - # Add orthogonal connections (right and down) + # Right and down neighbors c_next = self._idx(c + 1, self.cols) r_next = self._idx(r + 1, self.rows) @@ -191,7 +177,6 @@ def __post_init__(self) -> None: v = f"n{r_next:03d}_{c:03d}" expected_edges.add((u, v)) - # Add diagonal connections if enabled if self.diag: # Diagonal down-right if r_next is not None and c_next is not None: @@ -213,7 +198,7 @@ def _idx(self, i: int, limit: int) -> int | None: Args: i: Grid coordinate to convert. - limit: Maximum coordinate value. + limit: Dimension size; valid coordinates are 0 to limit - 1. Returns: Wrapped coordinate if wrap is enabled, or None if out of bounds. @@ -231,22 +216,19 @@ def _build(self, seed: int) -> Network: """ net = Network() - # Create nodes with grid coordinates as attributes for r, c in product(range(self.rows), range(self.cols)): name = f"n{r:03d}_{c:03d}" net.add_node(Node(name, attrs={"row": r, "col": c})) - # Track added edges to prevent duplicates + # With wrap, diag, and rows == 2, both diagonals yield the same edge. added_edges: set[tuple[str, str]] = set() - # Helper to add bidirectional links with bounds checking def add_edge(r1: int, c1: int, r2: int | None, c2: int | None) -> None: if r2 is None or c2 is None: return # Skip out-of-bounds connections when wrap is disabled u = f"n{r1:03d}_{c1:03d}" v = f"n{r2:03d}_{c2:03d}" - # Prevent duplicate edges if (u, v) in added_edges: return added_edges.add((u, v)) @@ -261,18 +243,17 @@ def add_edge(r1: int, c1: int, r2: int | None, c2: int | None) -> None: ) for r, c in product(range(self.rows), range(self.cols)): - # Add orthogonal connections (right and down) + # Right and down neighbors, then the two right-hand diagonals add_edge(r, c, r, self._idx(c + 1, self.cols)) add_edge(r, c, self._idx(r + 1, self.rows), c) - # Add diagonal connections if enabled if self.diag: add_edge(r, c, self._idx(r + 1, self.rows), self._idx(c + 1, self.cols)) add_edge(r, c, self._idx(r - 1, self.rows), self._idx(c + 1, self.cols)) return net -# Export all available topology classes +# Topology classes listed by `perf show topology` ALL_TOPOLOGIES = [ Clos2TierTopology, Grid2DTopology, diff --git a/dev/perf/visualization.py b/dev/perf/visualization.py index a0e0e1b..9ac3bbc 100644 --- a/dev/perf/visualization.py +++ b/dev/perf/visualization.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Visualization utilities for NetGraph performance analysis.""" +"""Complexity plots and JSON export for benchmark results.""" from __future__ import annotations @@ -7,25 +7,19 @@ import math from pathlib import Path -try: - import matplotlib +import matplotlib - matplotlib.use("Agg") # Use non-interactive backend for plot generation - import matplotlib.pyplot as plt - import numpy as np - import seaborn as sns -except ImportError as e: - raise ImportError( - "Visualization requires matplotlib, numpy, and seaborn. " - "Install with: pip install matplotlib numpy seaborn" - ) from e +matplotlib.use("Agg") # Headless backend; must be set before importing pyplot +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import seaborn as sns # noqa: E402 from .analysis import PerformanceAnalyzer, _fit_power_law -from .core import BenchmarkResult, BenchmarkTask +from .core import BenchmarkResult class PerformanceVisualizer: - """Generates performance analysis charts and reports.""" + """Write complexity plots and the results JSON for benchmark runs.""" def __init__(self, plots_dir: Path = Path("dev/perf_plots")): self.plots_dir = plots_dir @@ -37,7 +31,6 @@ def _configure_style(self) -> None: sns.set_theme(style="whitegrid", palette="deep") sns.set_context("paper", font_scale=1.2) - # Set matplotlib parameters for output plt.rcParams.update( { "figure.dpi": 300, @@ -58,57 +51,46 @@ def _configure_style(self) -> None: def create_summary_report( self, analyzer: PerformanceAnalyzer, timestamp: str ) -> None: - """Generate plots for benchmark results that require visualization. + """Write a complexity plot for each run whose profile enables plots. Args: analyzer: Performance analyzer with benchmark results. - timestamp: Timestamp string for consistent file naming. + timestamp: Suffix shared by the plot file names of one run. """ if not analyzer.runs: print("No benchmark results to visualize") return - # Generate plots for each task that requires them for run in analyzer.runs: - task = run.profile.tasks[0] if run.profile.analysis.generates_plots(): - self.plot_complexity_analysis( - analyzer, task, run.profile.name, timestamp - ) + self.plot_complexity_analysis(run, timestamp) - def plot_complexity_analysis( - self, - analyzer: PerformanceAnalyzer, - task: BenchmarkTask, - profile_name: str, - timestamp: str, - ) -> None: - """Create complexity analysis plot for a specific task. + def plot_complexity_analysis(self, run: BenchmarkResult, timestamp: str) -> None: + """Plot measured times, the expected model, and a power-law fit. + + Writes ``{task}_{profile_name}_{timestamp}_complexity.png`` under + ``plots_dir``. Only this run's samples are plotted and fitted. Args: - analyzer: Performance analyzer with benchmark results. - task: The benchmark task to plot. - profile_name: Name of the benchmark profile. - timestamp: Timestamp string for consistent file naming. + run: Benchmark result of one profile. + timestamp: Suffix shared by the plot file names of one run. """ - samples = analyzer.get_samples_by_task(task) + task = run.task + profile_name = run.profile.name + samples = run.samples if len(samples) < 2: print(f"Insufficient samples for {task.name} complexity plot") return - # Sort samples by problem size sorted_samples = sorted(samples, key=lambda s: s.numeric_problem_size()) - # Extract data for plotting SECONDS_TO_MS = 1000 sizes = np.array([s.numeric_problem_size() for s in sorted_samples]) times = np.array([s.mean_time * SECONDS_TO_MS for s in sorted_samples]) errors = np.array([s.std_dev * SECONDS_TO_MS for s in sorted_samples]) - # Create figure with seaborn styling fig, ax = plt.subplots(figsize=(10, 6)) - # Plot measured data with error bars ax.errorbar( sizes, times, @@ -122,16 +104,13 @@ def plot_complexity_analysis( color=sns.color_palette("deep")[0], ) - # Get theoretical complexity curve - run = next(run for run in analyzer.runs if task in run.profile.tasks) model = run.profile.analysis.expected - # Generate smooth theoretical curve + # Anchor the expected curve at the smallest measured size. baseline_size = sizes[0] baseline_time = times[0] - # Generate smooth curve for theoretical model - CURVE_SAMPLES = 200 # Number of points for smooth curve + CURVE_SAMPLES = 200 curve_sizes = np.linspace(min(sizes), max(sizes), CURVE_SAMPLES) theory_times = np.array( [ @@ -143,7 +122,6 @@ def plot_complexity_analysis( ] ) - # Plot theoretical curve ax.plot( curve_sizes, theory_times, @@ -154,19 +132,15 @@ def plot_complexity_analysis( color=sns.color_palette("deep")[1], ) - # Add empirical fit line try: empirical_exponent, r_squared = _fit_power_law(sorted_samples) - # Calculate empirical fit curve: y = a * x^b - # Using first data point as baseline for the constant 'a' + # y = a * x^b with a chosen so the curve passes through the + # smallest sample; the fit's own intercept is not used. baseline_log_size = math.log(baseline_size) baseline_log_time = math.log(baseline_time / SECONDS_TO_MS) - - # Calculate the constant 'a' from the fitted line log_constant = baseline_log_time - empirical_exponent * baseline_log_size - # Generate empirical fit curve empirical_times = np.array( [ math.exp(log_constant + empirical_exponent * math.log(size)) @@ -189,7 +163,6 @@ def plot_complexity_analysis( except Exception as e: print(f" Warning: Unexpected error generating empirical fit: {e}") - # Configure plot ax.set_xlabel("Problem Size", fontweight="bold") ax.set_ylabel("Runtime (ms)", fontweight="bold") ax.set_title( @@ -198,11 +171,8 @@ def plot_complexity_analysis( pad=20, ) - # Add grid and legend ax.grid(True, alpha=0.3) ax.legend(frameon=True, fancybox=True, shadow=True) - - # Improve layout and save plt.tight_layout() plot_path = ( @@ -219,15 +189,16 @@ def export_results_json( profile_results: list[tuple[str, BenchmarkResult]], filepath: Path, ) -> None: - """Export benchmark results to JSON format.""" + """Write per-profile samples, settings, and fits to ``filepath``. + + Also prints a summary table of all profiles. + """ data = {"profiles": []} for _, result in profile_results: - # Get analysis results for this profile - task = result.profile.tasks[0] - complexity_summary = analyzer.get_complexity_summary(task) + task = result.task + complexity_summary = analyzer.get_complexity_summary(result) - # Build profile data with embedded analysis profile_data = { "name": result.profile.name, "task": task.name, @@ -245,7 +216,6 @@ def export_results_json( }, } - # Add sample data for sample in result.samples: profile_data["samples"].append( { @@ -262,7 +232,6 @@ def export_results_json( } ) - # Add analysis results if available if complexity_summary: profile_data["analysis_results"] = { "complexity_analysis": complexity_summary, @@ -279,18 +248,15 @@ def export_results_json( json.dump(data, f, indent=2) print(f" โ€ข Results JSON: {filepath}") - # Show file size in KB for user feedback KB_BYTES = 1024 print(f" Size: {filepath.stat().st_size / KB_BYTES:.1f} KB") - # Print a quick summary table self._print_results_summary(profile_results) def _print_results_summary( self, profile_results: list[tuple[str, BenchmarkResult]] ) -> None: """Print a summary table of all benchmark results.""" - # Calculate dynamic column width for profile names profile_names = [name for name, _ in profile_results] profile_width = max(len(name) for name in profile_names + ["Profile", "Total"]) @@ -308,7 +274,6 @@ def _print_results_summary( wall_time = result.total_execution_time() total_wall_time += wall_time - # Calculate aggregate statistics SECONDS_TO_MS = 1000 all_times = [s.mean_time * SECONDS_TO_MS for s in result.samples] min_time = min(all_times) @@ -324,4 +289,4 @@ def _print_results_summary( f" {'-' * profile_width} {'-' * 10} {'-' * 12} {'-' * 10} {'-' * 10} {'-' * 10}" ) print(f" {'Total':>{profile_width}} {' ':>10} {total_wall_time:>10.2f}s") - print("\n Note: Wall time includes warm-up runs and measurement overhead") + print("\n Wall time is mean time x rounds; warm-up calls are excluded") diff --git a/dev/run-checks.sh b/dev/run-checks.sh index 4f6b09d..e370ba6 100755 --- a/dev/run-checks.sh +++ b/dev/run-checks.sh @@ -1,32 +1,29 @@ #!/bin/bash -# Run all code quality checks and tests -# This script runs the complete validation suite: pre-commit hooks + schema validation + tests +# Run pre-commit (fix pass, then verify pass), schema validation, and all tests. -set -e # Exit on any error +set -e -# Determine python interpreter (prefer venv if active) +# make check passes PYTHON; default to python3 on PATH. PYTHON=${PYTHON:-python3} -# Check if pre-commit is installed if ! "$PYTHON" -m pre_commit --version &> /dev/null; then echo "โŒ pre-commit is not installed. Please run 'make dev' first." exit 1 fi -# Check if pytest is installed if ! "$PYTHON" -m pytest --version &> /dev/null; then echo "โŒ pytest is not installed. Please run 'make dev' first." exit 1 fi -# Check if pre-commit hooks are installed -if [ ! -f .git/hooks/pre-commit ]; then +# Hooks live in the common git dir, shared by linked worktrees. +if [ ! -f "$(git rev-parse --git-path hooks/pre-commit)" ]; then echo "โš ๏ธ Pre-commit hooks not installed. Installing now..." "$PYTHON" -m pre_commit install echo "" fi -# Run pre-commit with fixers (first pass), do not fail if files were modified +# First pass applies auto-fixes; a failure here is not fatal. echo "๐Ÿƒ Running pre-commit (first pass: apply auto-fixes if needed)..." set +e "$PYTHON" -m pre_commit run --all-files @@ -37,7 +34,7 @@ if [ $first_pass_status -ne 0 ]; then echo "โ„น๏ธ Some hooks modified files or reported issues. Re-running checks..." fi -# Re-run to verify all checks pass after fixes; fail on any remaining issues +# Second pass must be clean. echo "๐Ÿƒ Running pre-commit (second pass: verify all checks)..." if ! "$PYTHON" -m pre_commit run --all-files; then echo "" @@ -45,7 +42,6 @@ if ! "$PYTHON" -m pre_commit run --all-files; then exit 1 fi -# Track whether auto-fixes were applied and resolved issues autofixed=0 if [ $first_pass_status -ne 0 ]; then autofixed=1 @@ -55,46 +51,16 @@ echo "" echo "โœ… Pre-commit checks passed!" echo "" -# Run schema validation -echo "๐Ÿ“‹ Validating YAML schemas..." -if "$PYTHON" -c "import jsonschema" >/dev/null 2>&1; then - "$PYTHON" -c "import json, yaml, jsonschema, pathlib, importlib.resources as res; \ - f = res.files('ngraph.schemas').joinpath('scenario.json').open('r', encoding='utf-8'); \ - schema = json.load(f); f.close(); \ - scenario_files = list(pathlib.Path('scenarios').rglob('*.yaml')); \ - integration_files = list(pathlib.Path('tests/integration').glob('*.yaml')); \ - all_files = scenario_files + integration_files; \ - [jsonschema.validate(yaml.safe_load(open(f)), schema) for f in all_files]; \ - print(f'โœ… Validated {len(all_files)} YAML files against schema ({len(scenario_files)} scenarios, {len(integration_files)} integration tests)')" - - if [ $? -ne 0 ]; then - echo "" - echo "โŒ Schema validation failed. Please fix the YAML files above." - exit 1 - fi -else - echo "โš ๏ธ jsonschema not installed. Skipping schema validation" -fi - +make validate PYTHON="$PYTHON" echo "" -# Run tests with coverage (includes slow and benchmark tests for regression detection) +# Run tests with coverage (includes slow tests); set -e aborts on failure echo "๐Ÿงช Running tests with coverage..." "$PYTHON" -m pytest -if [ $? -eq 0 ]; then - echo "" - if [ $autofixed -eq 1 ]; then - echo "๐ŸŽ‰ All checks and tests passed. Auto-fixes were applied by pre-commit." - else - echo "๐ŸŽ‰ All checks and tests passed." - fi +echo "" +if [ $autofixed -eq 1 ]; then + echo "๐ŸŽ‰ All checks and tests passed. Auto-fixes were applied by pre-commit." else - echo "" - if [ $autofixed -eq 1 ]; then - echo "โŒ Some tests failed. Note: auto-fixes were applied earlier by pre-commit." - else - echo "โŒ Some tests failed." - fi - exit 1 + echo "๐ŸŽ‰ All checks and tests passed." fi diff --git a/docs/assets/diagrams/system_pipeline.dot b/docs/assets/diagrams/system_pipeline.dot index 0837d1d..08906e8 100644 --- a/docs/assets/diagrams/system_pipeline.dot +++ b/docs/assets/diagrams/system_pipeline.dot @@ -26,7 +26,7 @@ digraph SystemPipeline { label=""; style=invis; engine [label="Scenario.run()"]; steps [label="WorkflowSteps"]; - exec [label="Execution\nยทFailureManager\nยทDemandBuilder\nยทFlow Solvers"]; + exec [label="Execution\nยทFailureManager\nยทDemand Expansion\nยทFlow Solvers"]; engine -> steps -> exec; } diff --git a/docs/assets/diagrams/system_pipeline.dot.svg b/docs/assets/diagrams/system_pipeline.dot.svg index 9027fc9..852dd67 100644 --- a/docs/assets/diagrams/system_pipeline.dot.svg +++ b/docs/assets/diagrams/system_pipeline.dot.svg @@ -1,14 +1,14 @@ - - + SystemPipeline - + cluster_input @@ -16,7 +16,7 @@ cluster_python - + Python Layer @@ -27,8 +27,8 @@ cluster_core - -NetGraph-Core (C++) + +NetGraph-Core (C++) cluster_graph @@ -38,8 +38,8 @@ cluster_results - -Results + +Results @@ -114,110 +114,110 @@ exec - -Execution -ยทFailureManager -ยทDemandBuilder -ยทFlow Solvers + +Execution +ยทFailureManager +ยทDemand Expansion +ยทFlow Solvers steps->exec - - + + adapter - -Core Adapter -(build_graph, mappers) + +Core Adapter +(build_graph, mappers) exec->adapter - - + + core_backend - -Backend + +Backend adapter->core_backend - - -GIL released + + +GIL released core_algs - -Algorithms + +Algorithms core_backend->core_algs - - + + core_graph - -StrictMultiDiGraph + +StrictMultiDiGraph core_spf - -SPF + +SPF core_algs->core_spf - - + + core_ksp - -K-SP + +K-SP core_algs->core_ksp - - + + core_maxf - -Max-Flow + +Max-Flow core_algs->core_maxf - - + + results - -Results Store + +Results Store core_maxf->results - - + + diff --git a/docs/examples/basic.md b/docs/examples/basic.md index d8da404..2d586db 100644 --- a/docs/examples/basic.md +++ b/docs/examples/basic.md @@ -1,8 +1,6 @@ # Basic Example -A tiny topology defined inline, used here to walk through the analysis APIs. For real analysis, run a bundled scenario through the CLI and generate metrics from that. - -See [Tutorial](../getting-started/tutorial.md) for CLI usage and bundled scenarios. +A four-node topology defined inline, used to walk through the analysis API. The [Tutorial](../getting-started/tutorial.md) covers the CLI and the bundled scenarios. ## Creating a Simple Network @@ -27,7 +25,7 @@ from ngraph import analyze, Mode, FlowPlacement # Define network topology with parallel paths scenario_yaml = """ -seed: 1234 # Optional: ensures reproducible results +seed: 1234 # Optional; makes randomized steps reproducible network: name: "fundamentals_example" @@ -81,7 +79,7 @@ This spells out every node and link individually. The optional `seed` makes rand ### Flow Analysis Variants -Now let's run MaxFlow using the `analyze()` API: +Run MaxFlow using the `analyze()` API: ```python # 1. "True" maximum flow (uses all available paths) @@ -117,7 +115,7 @@ print(f"Equal-balanced flow: {max_flow_shortest_balanced}") - **Shortest Path**: Only uses paths with the minimum cost - **EQUAL_BALANCED Flow Placement**: Distributes flows equally across all parallel paths. The total flow can be limited by the smallest capacity path. -`EQUAL_BALANCED` flow placement is typically used with `shortest_path=True` to simulate traditional ECMP behavior, where flows are split equally across equal-cost paths. +Paired with `shortest_path=True`, `EQUAL_BALANCED` models ECMP: an equal split across the equal-cost paths. ## Cost Distribution @@ -165,9 +163,9 @@ max_latency = max(cost_dist.keys()) print(f"Latency range: {min_latency} - {max_latency}") # 2.0 - 4.0 ``` -## Efficient Repeated Analysis +## Repeated Analysis with a Bound Context -For scenarios requiring multiple analyses with different exclusions (e.g., failure testing), use a bound context: +To analyze the same source and sink under many exclusion sets, bind them once: ```python # Create bound context - graph built once @@ -177,7 +175,7 @@ ctx = analyze(network, source="^A$", sink="^C$", mode=Mode.COMBINE) baseline = ctx.max_flow() print(f"Baseline: {baseline}") -# Test various failure scenarios +# Exclude one node at a time for node in ["B", "D"]: degraded = ctx.max_flow(excluded_nodes={node}) print(f"Without {node}: {degraded}") @@ -190,7 +188,7 @@ for node in ["B", "D"]: ## Sensitivity Analysis -Identify which edges are critical for the flow: +Which edges limit the flow, and by how much: ```python # Get sensitivity analysis @@ -209,7 +207,7 @@ for pair, edge_impacts in sensitivity.items(): ## Shortest Paths -Get actual path objects for routing analysis: +Path objects rather than costs: ```python from ngraph import EdgeSelect diff --git a/docs/examples/bundled-scenarios.md b/docs/examples/bundled-scenarios.md index 11b1525..4d20b36 100644 --- a/docs/examples/bundled-scenarios.md +++ b/docs/examples/bundled-scenarios.md @@ -1,6 +1,6 @@ # Bundled Scenarios -NetGraph ships with ready-to-run scenarios that demonstrate the DSL, workflow steps, and results export. Use these to validate your environment and as starting points for your own models. +Three scenarios under `scenarios/` exercise the DSL, the workflow steps and the results export. They double as a check of a fresh installation and as starting points for your own models. ## How to run @@ -19,7 +19,7 @@ You can filter output by workflow step names with `--keys` (see each scenario se ## `scenarios/square_mesh.yaml` - **Purpose**: Toy 4-node full mesh to exercise MSD search, TM placement, and pairwise MaxFlow. -- **Highlights**: +- **Contents**: - Failure policy: single link choice (`failures.single_link_failure`) - Demand set: pairwise demands across all nodes (`baseline_traffic_matrix`) @@ -38,7 +38,7 @@ ngraph run scenarios/square_mesh.yaml --keys msd_baseline --stdout ## `scenarios/backbone_clos.yml` - **Purpose**: Small Clos/metro fabric with components, SRLG-like risk groups, and multi-step workflow. -- **Highlights**: +- **Contents**: - Uses `blueprints`, attribute-based link selectors, and hardware component attrs - Failure policy: weighted multi-mode (`failures.weighted_modes`) @@ -58,9 +58,9 @@ ngraph run scenarios/backbone_clos.yml --keys network_statistics tm_placement -- ## `scenarios/nsfnet.yaml` - **Purpose**: Historic NSFNET T3 (1992) backbone with parallel circuits and SRLG-style risk groups. -- **Highlights**: +- **Contents**: - - Explicit nodes/links with capacities and costs; rich `risk_groups` + - Explicit nodes and links with capacities and costs; one risk group per circuit pair - Failure policies: single-link and availability-based random failures - Workflow steps: `node_to_node_capacity_matrix_1`, `node_to_node_capacity_matrix_2` @@ -111,4 +111,4 @@ The `square_mesh` placement output looks like this (1000 iterations, six single- ## 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. +Every run writes the same JSON shape: `workflow`, `steps` and `scenario` sections. `MaxFlow` and `TrafficMatrixPlacement` store the no-failure run under `data.baseline` and one entry per distinct failure pattern under `data.flow_results`. Patterns are deduplicated across iterations, so the list is usually much shorter than `iterations`; without a `failure_policy` it is empty. Each entry carries a `summary` and per-flow `flows`. A flow's `cost_distribution` is filled when `include_flow_details` is set and is `{}` otherwise; with `include_min_cut`, the min-cut edges appear under the flow's `data` as `edges` with `edges_kind: "min_cut"`. The [Workflow Reference](../reference/workflow.md) has the full schema. diff --git a/docs/examples/clos-fabric.md b/docs/examples/clos-fabric.md index 6b4ef02..89d708d 100644 --- a/docs/examples/clos-fabric.md +++ b/docs/examples/clos-fabric.md @@ -1,12 +1,10 @@ # Clos Fabric Analysis -Analysis of a 3-tier Clos fabric. For production use, run the bundled scenario and generate metrics via CLI, then iterate in Python if needed. +Maximum flow between two 3-tier Clos fabrics, comparing ECMP and WCMP placement with and without failures. The [Tutorial](../getting-started/tutorial.md) covers running the bundled scenarios from the CLI. -Refer to [Tutorial](../getting-started/tutorial.md) for running bundled scenarios via CLI. +## Scenario -## Scenario Overview - -Two separate 3-tier Clos networks, with maximum flow capacity measured between them. The scenario nests blueprints inside blueprints, wires the tiers with `mesh` and `one_to_one` link patterns, and compares flow placement policies. +Two 3-tier Clos networks joined spine to spine. The scenario nests blueprints inside blueprints and wires the tiers with `mesh` and `one_to_one` link patterns. ## Programmatic scenario @@ -74,11 +72,11 @@ network: cost: 1 """ -# Create and analyze the scenario +# Build the network scenario = Scenario.from_yaml(scenario_yaml) network = scenario.network -# Calculate maximum flow with ECMP (Equal Cost Multi-Path) +# Maximum flow with ECMP max_flow_ecmp = analyze(network).max_flow( r"my_clos1.*(b[0-9]*)/t1", r"my_clos2.*(b[0-9]*)/t1", @@ -91,7 +89,7 @@ print(f"Maximum flow with ECMP: {max_flow_ecmp}") # Result: {('b1|b2', 'b1|b2'): 256.0} ``` -## Understanding the Results +## Reading the result The result `{('b1|b2', 'b1|b2'): 256.0}` means: @@ -99,24 +97,16 @@ The result `{('b1|b2', 'b1|b2'): 256.0}` means: - **Target**: All t1 nodes in both b1 and b2 segments of my_clos2 - **Capacity**: Maximum flow of 256.0 units -## ECMP vs WCMP: Impact of Link Failures - -NetGraph supports different flow placement policies: +## ECMP versus WCMP with uneven links -- `FlowPlacement.EQUAL_BALANCED`: Equal split across equal-cost paths -- `FlowPlacement.PROPORTIONAL`: Capacity-weighted split across equal-cost paths +Two placement policies split flow across equal-cost paths: `FlowPlacement.EQUAL_BALANCED` gives every path the same share (ECMP) and `FlowPlacement.PROPORTIONAL` weights the shares by capacity (WCMP). With `shortest_path=True` both stay on the equal-cost paths; with `shortest_path=False` placement spills onto costlier paths as capacity runs out, which is traffic engineering. -Combined with the path selection settings (shortest_path=True|False), we can achieve different flow placement policies emulating ECMP, WCMP, and TE behavior in IP/MPLS networks. +The example above pairs `EQUAL_BALANCED` with `shortest_path=True`, which is ECMP. Compare it with `PROPORTIONAL` under two conditions: -The example above pairs `FlowPlacement.EQUAL_BALANCED` with `shortest_path=True` to emulate ECMP. Compare it against `FlowPlacement.PROPORTIONAL` (WCMP) under two conditions: +- Symmetric parallel inter-spine links: ECMP and WCMP both give 256.0. +- Uneven capacities within each equal-cost bundle: WCMP carries more, because ECMP is capped by the equal split. -- Baseline: symmetric parallel inter-spine links -> ECMP = WCMP (256.0). -- Uneven links: capacities differ within each equal-cost bundle -> WCMP - achieves higher throughput than ECMP, which is limited by equal splitting. - -Partial inter-spine degradation is emulated by making capacities uneven across the -4 parallel spine-to-spine links per pair while keeping costs equal, which isolates -the effect of the splitting policy. +The code below makes the 4 parallel spine-to-spine links of each pair uneven while keeping their costs equal, so only the splitting policy differs. ```python from ngraph import analyze, Mode, FlowPlacement @@ -205,7 +195,7 @@ Uneven ECMP: {('b1|b2', 'b1|b2'): 64.0} 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. +Uneven links drop ECMP to 64 while WCMP keeps 248. ECMP gives every member of a bundle the same share, so the smallest link caps the bundle. ## Failure Analysis @@ -256,20 +246,15 @@ 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 +## Network structure -We can also analyze the network structure using the NetworkExplorer: +`NetworkExplorer` prints the node hierarchy with node, link and capacity statistics per subtree: ```python from ngraph.explorer import NetworkExplorer -# Explore the network topology explorer = NetworkExplorer.explore_network(network) -explorer.print_tree(skip_leaves=True, detailed=False) - -# The explorer shows hierarchical structure and connectivity patterns -# For detailed path analysis, use max_flow_detailed to get flow details -# including cost distribution and path information +explorer.print_tree(skip_leaves=True, detailed=False) # skip_leaves hides individual nodes ``` ## Next Steps @@ -277,4 +262,4 @@ explorer.print_tree(skip_leaves=True, detailed=False) - **[Bundled Scenarios](bundled-scenarios.md)** - Ready-to-run examples - **[Workflow Reference](../reference/workflow.md)** - Analysis workflows and Monte Carlo simulation - **[DSL Reference](../reference/dsl.md)** - YAML syntax reference -- **[API Reference](../reference/api.md)** - Explore the Python API in detail +- **[API Reference](../reference/api.md)** - Python API diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 0412397..4612a62 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,12 +1,11 @@ # Installation -NetGraph is a hybrid Python+C++ framework. The Python package (`ngraph`) automatically installs -the C++ performance layer (`netgraph-core`) as a dependency. +The `ngraph` package depends on `netgraph-core`, the C++ engine; `pip install ngraph` installs both. ## Requirements - Python 3.11 or higher -- C++ compiler (for building netgraph-core from source if needed) +- A C++ compiler, only when no pre-built `netgraph-core` wheel exists for the platform - Linux: GCC 10+ or Clang 12+ - macOS: Xcode Command Line Tools (Apple Clang) - Windows: Visual Studio 2019+ with C++ tools @@ -30,7 +29,7 @@ This installs: 1. The Python `ngraph` package 2. `netgraph-core` (pre-built wheels for common platforms, or builds from source) -3. Dependencies (networkx, pyyaml, pandas, jsonschema) +3. Dependencies (networkx, numpy, pyyaml, jsonschema) Verify installation: @@ -40,7 +39,7 @@ ngraph --help ## From Source -For development or if you need the latest changes: +For development, or to install from the repository: ```bash # Clone both repositories diff --git a/docs/getting-started/tutorial.md b/docs/getting-started/tutorial.md index f081740..65b5e82 100644 --- a/docs/getting-started/tutorial.md +++ b/docs/getting-started/tutorial.md @@ -1,6 +1,6 @@ # Tutorial -The fastest way to run a scenario from the CLI, plus a minimal programmatic example. See the Examples section for fuller scenarios and for flow placement and failure policies. +Running a scenario from the CLI, and a small workflow in Python. The Examples section has larger scenarios and covers flow placement and failure policies. ## CLI: run and inspect diff --git a/docs/index.md b/docs/index.md index 24955f2..e86905d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,20 +2,18 @@ [![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) -Scenario-driven network modeling and analysis framework combining Python with C++ graph algorithms. +Scenario-driven network modeling and analysis: a Python front end over C++ graph algorithms. -## Overview - -Model network topologies, traffic matrices, and failure scenarios declaratively. Graph algorithms are delegated to [NetGraph-Core](https://github.com/networmix/NetGraph-Core); NetGraph itself provides the Python API and CLI that orchestrate them. +Model network topologies, traffic matrices and failure scenarios declaratively. The graph algorithms live in [NetGraph-Core](https://github.com/networmix/NetGraph-Core); NetGraph provides the Python API and CLI that drive them. ## Architecture -NetGraph uses a hybrid Python+C++ architecture, split across two layers: +Two layers: -- **Python layer (NetGraph)**: Scenario DSL parsing, workflow orchestration, result aggregation, and high-level APIs. -- **C++ layer (NetGraph-Core)**: The compute-intensive graph algorithms (SPF, KSP, Max-Flow), executed in C++ with the GIL released. +- **Python layer (NetGraph)**: scenario DSL, workflow execution, results, the Python API and CLI. +- **C++ layer (NetGraph-Core)**: shortest paths, k-shortest paths and max-flow, run with the GIL released. -## Key Features +## Features ### Modeling & DSL @@ -25,8 +23,8 @@ NetGraph uses a hybrid Python+C++ architecture, split across two layers: ### Failure Analysis -- **Policy Engine**: Weighted failure modes with multiple policy rules per mode. -- **Non-Destructive**: Runtime exclusions simulate failures without modifying the base topology. +- **Failure policies**: Weighted modes, each a set of selection rules. +- **Exclusions**: Failures are simulated at analysis time; the base topology is never modified. - **Risk Groups**: Model shared fate (e.g., fiber cuts, power zones). ### Traffic Engineering @@ -39,17 +37,17 @@ NetGraph uses a hybrid Python+C++ architecture, split across two layers: - **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. +- **Python API**: The same modeling and analysis entry points from Python. ## Getting Started -- **[Installation Guide](getting-started/installation.md)** - Python package installation -- **[Tutorial](getting-started/tutorial.md)** - Run scenarios (CLI) and code examples +- **[Installation Guide](getting-started/installation.md)** - Install from PyPI or from source +- **[Tutorial](getting-started/tutorial.md)** - Run a scenario from the CLI and from Python ## Examples - **[Bundled Scenarios](examples/bundled-scenarios.md)** - Ready-to-run scenarios (`square_mesh`, `backbone_clos`, `nsfnet`) -- **[Basic Example](examples/basic.md)** - Simple graph example +- **[Basic Example](examples/basic.md)** - The analysis API on a four-node network - **[Clos Fabric Analysis](examples/clos-fabric.md)** - Analyze a 3-tier Clos network ## Reference Documentation @@ -60,4 +58,4 @@ NetGraph uses a hybrid Python+C++ architecture, split across two layers: - **[CLI Reference](reference/cli.md)** - Command-line interface - **[Schema Reference](reference/schemas.md)** - JSON Schema and validation - **[API Reference](reference/api.md)** - Python API documentation -- **[Auto-Generated API Reference](reference/api-full.md)** - Complete API docs +- **[Auto-Generated API Reference](reference/api-full.md)** - Every public module, generated from docstrings diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index c665db0..986bd6e 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -2,18 +2,15 @@ # NetGraph API Reference (Auto-Generated) -This is the complete auto-generated API documentation for NetGraph. -For a curated, example-driven API guide, see [api.md](api.md). +Every public module, class and function, generated from the docstrings. +The [API guide](api.md) covers the same API with examples. Quick links: -- [Main API Guide (api.md)](api.md) -- [This Document (api-full.md)](api-full.md) +- [API Guide](api.md) - [CLI Reference](cli.md) - [DSL Reference](dsl.md) -Generated from source code on: September 12, 2026 at 22:35 UTC - Modules auto-discovered: 54 --- @@ -26,9 +23,11 @@ Command-line interface for NetGraph. Entry point for the ``ngraph`` command. +Prints help and exits with status 0 when no arguments are given. + Args: - argv: Optional list of command-line arguments. If ``None``, ``sys.argv`` - is used. + argv: Arguments without the program name. If ``None``, + ``sys.argv[1:]`` is used. --- @@ -37,8 +36,8 @@ Args: Hierarchical exploration of a Network. Builds a tree of the node-name hierarchy and aggregates per-subtree -statistics โ€” node and link counts, capacity, capex/power, and hardware -bills of materials โ€” in two modes: all nodes, and enabled nodes only. +statistics in two modes (all nodes, and enabled nodes only): node and link +counts, capacity, capex/power, and hardware bills of materials (BOM). ### ExternalLinkBreakdown @@ -62,7 +61,7 @@ Attributes: target: Target node name. capacity: Configured link capacity. limit: Effective capacity limit from per-end hardware (min of ends). - reason: Brief reason tag. + reason: Machine-readable tag, e.g. ``link_capacity_exceeds_end_hw``. **Attributes:** @@ -76,16 +75,16 @@ Attributes: Hierarchical view of a Network with per-subtree statistics. -Statistics are computed in two modes: 'all' (ignores disabled) and -'active' (only enabled). +Statistics are computed in two modes: 'all' (disabled nodes and links +included) and 'active' (enabled only). **Methods:** -- `explore_network(network: 'Network', components_library: 'Optional[ComponentsLibrary]' = None, strict_validation: 'bool' = True) -> 'NetworkExplorer'` - Build a NetworkExplorer, constructing a tree plus 'all' and 'active' stats. +- `explore_network(network: 'Network', components_library: 'Optional[ComponentsLibrary]' = None, strict_validation: 'bool' = True) -> 'NetworkExplorer'` - Build the hierarchy tree and compute 'all' and 'active' statistics. - `get_bom(self, include_disabled: 'bool' = True) -> 'Dict[str, float]'` - Return aggregated hardware BOM for the whole network. - `get_bom_by_path(self, path: 'str', include_disabled: 'bool' = True) -> 'Dict[str, float]'` - Return the hardware BOM for a specific hierarchy path. - `get_bom_map(self, include_disabled: 'bool' = True, include_root: 'bool' = True, root_label: 'str' = '') -> 'Dict[str, Dict[str, float]]'` - Return a mapping from hierarchy path to BOM for each subtree. -- `get_link_issues(self) -> 'List[LinkCapacityIssue]'` - Return recorded link capacity issues discovered in non-strict mode. +- `get_link_issues(self) -> 'List[LinkCapacityIssue]'` - Return link capacity violations recorded in non-strict mode. - `get_node_utilization(self) -> 'List[NodeUtilization]'` - Return hardware utilization per node based on active topology. - `print_tree(self, node: 'Optional[TreeNode]' = None, indent: 'int' = 0, max_depth: 'Optional[int]' = None, skip_leaves: 'bool' = False, detailed: 'bool' = False, include_disabled: 'bool' = True, max_external_lines: 'Optional[int]' = None, line_prefix: 'str' = '') -> 'None'` - Print the hierarchy from 'node' down (default: root). @@ -127,14 +126,14 @@ Attributes: A node in the hierarchical tree. Attributes: - name (str): Name/label of this node. - parent (Optional[TreeNode]): Pointer to the parent tree node. + name (str): Hierarchy segment, e.g. "plane1" in "dc1/plane1/ssw". + parent (Optional[TreeNode]): Parent tree node; None for the root. children (Dict[str, TreeNode]): Mapping of child name -> child TreeNode. - subtree_nodes (Set[str]): Node names in the subtree (all nodes, ignoring disabled). + subtree_nodes (Set[str]): Node names in the subtree, disabled included. active_subtree_nodes (Set[str]): Node names in the subtree (only enabled). stats (TreeStats): Aggregated stats for "all" view. active_stats (TreeStats): Aggregated stats for "active" (only enabled) view. - raw_nodes (List[Node]): Direct Node objects at this hierarchy level. + raw_nodes (List[Node]): Network nodes whose full name ends at this tree node. **Attributes:** @@ -149,7 +148,7 @@ Attributes: **Methods:** -- `add_child(self, child_name: 'str') -> 'TreeNode'` - Ensure a child node named 'child_name' exists and return it. +- `add_child(self, child_name: 'str') -> 'TreeNode'` - Return the child named ``child_name``, creating it if missing. - `is_leaf(self) -> 'bool'` - Return True if this node has no children. ### TreeStats @@ -163,8 +162,10 @@ Attributes: external_link_count (int): Number of external links from this subtree to another. external_link_capacity (float): Sum of capacities for those external links. external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown by other subtree path. - total_capex (float): Cumulative capex (nodes + links). - total_power (float): Cumulative power (nodes + links). + total_capex (float): Total capex (nodes + links). + total_power (float): Total power (nodes + links). + bom (Dict[str, float]): Hardware count per component name. Shared + optics can add fractional counts. **Attributes:** @@ -182,7 +183,7 @@ Attributes: ## ngraph.logging -Centralized logging configuration for NetGraph. +Logging configuration for NetGraph. Follows the standard library pattern: importing the package attaches only a ``logging.NullHandler`` to the root ``ngraph`` logger and never installs @@ -192,14 +193,6 @@ calling ``setup_root_logger()`` explicitly, or implicitly via ``setup_root_logger()`` first, then sets the level from ``--verbose``/``--quiet``. -### disable_debug_logging() -> None - -Disable debug logging, set to INFO level. - -### enable_debug_logging() -> None - -Enable debug logging for the entire package. - ### get_logger(name: str) -> logging.Logger Get a logger under NetGraph's logging hierarchy. @@ -291,13 +284,14 @@ Component and ComponentsLibrary classes for hardware capex/power modeling. ### Component -A generic component that can represent chassis, line cards, optics, etc. -Components can have nested children, each with their own capex, power, etc. +Hardware component such as a chassis, line card, or optic. + +Components can nest children, each with its own capex, power, and count. Attributes: name (str): Name of the component (e.g., "SpineChassis" or "400G-LR4"). - component_type (str): A string label (e.g., "chassis", "linecard", "optic"). - description (str): A human-readable description of this component. + component_type (str): Free-form label (e.g., "chassis", "linecard", "optic"). + description (str): Free-form human-readable description. capex (float): Monetary capex of a single instance of this component. power_watts (float): Typical/nominal power usage (watts) for one instance. power_watts_max (float): Maximum/peak power usage (watts) for one instance. @@ -326,14 +320,15 @@ Attributes: - `as_dict(self, include_children: 'bool' = True) -> 'Dict[str, Any]'` - Returns a dictionary containing all properties of this component. - `total_capacity(self) -> 'float'` - Computes capacity for this component and all descendants. -- `total_capex(self) -> 'float'` - Computes total capex including children, multiplied by count. +- `total_capex(self) -> 'float'` - Return capex of this component and all descendants, times ``count``. - `total_power(self) -> 'float'` - Computes *typical* power for this component and all descendants. - `total_power_max(self) -> 'float'` - Computes *peak* power for this component and all descendants. ### ComponentsLibrary -Holds a collection of named Components. Each entry is a top-level "template" -that can be referenced for cost/power/capacity lookups, possibly with nested children. +Named Component templates for capex, power, and capacity lookups. + +Each entry is a top-level template and may have nested children. Example (YAML-like): components: @@ -363,11 +358,11 @@ Example (YAML-like): - `clone(self) -> 'ComponentsLibrary'` - Creates a deep copy of this ComponentsLibrary. - `from_dict(data: 'Dict[str, Any]') -> 'ComponentsLibrary'` - Constructs a ComponentsLibrary from raw component definitions. -- `from_yaml(yaml_str: 'str') -> 'ComponentsLibrary'` - Constructs a ComponentsLibrary from a YAML string. If the YAML contains +- `from_yaml(yaml_str: 'str') -> 'ComponentsLibrary'` - Constructs a ComponentsLibrary from a YAML string. - `get(self, name: 'str') -> 'Optional[Component]'` - Retrieves a Component by its name from the library. - `merge(self, other: 'ComponentsLibrary', override: 'bool' = True) -> 'ComponentsLibrary'` - Merges another ComponentsLibrary into this one. -### resolve_link_end_components(attrs: 'Dict[str, Any]', library: 'ComponentsLibrary') -> 'tuple[tuple[Optional[Component], float, bool], tuple[Optional[Component], float, bool], bool]' +### resolve_link_end_components(attrs: 'Dict[str, Any]', library: 'ComponentsLibrary') -> 'tuple[tuple[Optional[Component], float, bool], tuple[Optional[Component], float, bool]]' Resolve per-end hardware components for a link. @@ -375,25 +370,28 @@ Input format inside ``link.attrs`` is a structured mapping under the ``hardware`` key only: ``{"hardware": {"source": {"component": NAME, "count": N}, "target": {"component": NAME, "count": N}}}`` -An optional ``exclusive: true`` per end indicates unsharable usage; for -exclusive ends, validation and BOM counting round counts up to integers. +An optional ``exclusive: true`` per end indicates unsharable usage; BOM +counting rounds the count of an exclusive end up to an integer. Args: attrs: Link attributes mapping. library: Components library for lookups. Returns: - ((src_comp, src_count, src_exclusive), (dst_comp, dst_count, dst_exclusive), per_end_specified) - where components may be ``None`` if name is absent/unknown. ``per_end_specified`` - is True when a structured per-end mapping is present. + ((src_comp, src_count, src_exclusive), (dst_comp, dst_count, dst_exclusive)) + where a component is ``None`` when the end or its name is absent or + unknown. + +Raises: + ValueError: If an end's ``count`` is not a finite positive number. ### resolve_node_hardware(attrs: 'Dict[str, Any]', library: 'ComponentsLibrary') -> 'Tuple[Optional[Component], float]' Resolve node hardware from ``attrs['hardware']``. Expects the mapping: ``{"hardware": {"component": NAME, "count": N}}``. -``count`` defaults to 1 if missing or invalid. If ``component`` is missing -or unknown, returns ``(None, 1.0)``. +``count`` defaults to 1. If ``component`` is missing or unknown, the +component is None. Args: attrs: Node attributes mapping. @@ -402,6 +400,9 @@ Args: Returns: Tuple of (component or None, positive multiplier). +Raises: + ValueError: If ``count`` is not a finite positive number. + ### totals_with_multiplier(comp: 'Component', hw_count: 'float') -> 'Tuple[float, float, float]' Return (capex, power_watts, capacity) totals multiplied by ``hw_count``. @@ -430,29 +431,23 @@ Args: dictionaries with `TrafficDemand` constructor fields. Returns: - Initialized `DemandSet` with constructed `TrafficDemand` objects. + `DemandSet` with one entry per input set. An entry with an `expand` + block contributes one demand per expansion. Raises: - ValueError: If ``raw`` is not a mapping of name -> list[dict], - or if required fields are missing. + ValueError: If ``raw`` is not a mapping of name -> list[dict], a demand + is missing ``source``/``target`` or carries an unrecognized key, + ``flow_policy`` is not a preset name, or ``static_paths`` is + malformed. ### coerce_flow_policy(value: 'Any') -> 'Optional[FlowPolicyPreset]' -Return a FlowPolicyPreset from various user-friendly forms. - -Accepts: - -- None: returns None -- FlowPolicyPreset: returned as-is -- int: mapped by value (e.g., 1 -> SHORTEST_PATHS_ECMP); bools are - - rejected (True/False are not presets 1/0) +Return the FlowPolicyPreset a demand's `flow_policy` value names. -- str: name of enum (case-insensitive); numeric strings are allowed +Accepts None, a FlowPolicyPreset, or a preset name (case-insensitive). Raises: - ValueError: If the value is not one of the accepted forms (including - bool and dict/object configs, which are not supported). + ValueError: For any other value, including integers and unknown names. --- @@ -478,7 +473,6 @@ Attributes: - `add(self, name: 'str', demands: 'list[TrafficDemand]') -> 'None'` - Add a demand list, replacing any set already stored under `name`. - `get_all_demands(self) -> 'list[TrafficDemand]'` - Get all traffic demands from all sets combined. -- `get_default_set(self) -> 'list[TrafficDemand]'` - Get default demand set. - `get_set(self, name: 'str') -> 'list[TrafficDemand]'` - Get a specific demand set by name. --- @@ -518,7 +512,7 @@ Attributes: ### TrafficDemand -Traffic demand specification using unified selectors. +Traffic demand between selector-matched source and target nodes. Attributes: source: Source node selector (string path or selector dict). @@ -528,7 +522,8 @@ Attributes: mode: Node pairing mode ("combine" or "pairwise"). group_mode: How grouped nodes produce demands ("flatten", "per_group", "group_pairwise"). - flow_policy: Policy preset for routing. + flow_policy: Routing preset; None uses ``DEFAULT_PRESET`` + (SHORTEST_PATHS_ECMP). 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. @@ -551,7 +546,7 @@ Attributes: **Methods:** -- `to_dict(self) -> Dict[str, Any]` - Return the canonical serialized form (results output, snapshots). +- `to_dict(self) -> Dict[str, Any]` - Return the canonical serialized form. --- @@ -586,7 +581,8 @@ Attributes: Generate risk groups from unique attribute values. For each unique value of the specified attribute, creates a new risk -group and adds all matching entities to it. +group and adds its name to the `risk_groups` set of every matching +entity, in place. The caller registers the returned groups. Args: network: Network with nodes and links populated. @@ -599,9 +595,6 @@ Raises: ValueError: If `group_by` resolves to an unhashable value, or if the name template renders the same group name for two distinct values. -Note: - Modifies entity risk_groups sets in place. - ### parse_generate_spec(raw: 'Dict[str, Any]') -> 'GenerateSpec' Parse raw generate dict into a GenerateSpec. @@ -613,9 +606,9 @@ Returns: Parsed GenerateSpec. Raises: - ValueError: If 'scope' is missing or is neither 'node' nor 'link', if - 'group_by' or 'name' is missing, or if 'name' omits the '${value}' - placeholder. + ValueError: If the block carries an unrecognized key, 'scope' is + missing or is neither 'node' nor 'link', 'group_by' or 'name' is + missing, or 'name' omits the '${value}' placeholder. --- @@ -657,13 +650,12 @@ For each risk group with a `_membership_raw` specification: of this risk group (hierarchical membership). +Modifies entities in place. Call after all risk groups are registered +but before validation. + Args: network: Network with risk_groups, nodes, and links populated. -Note: - Modifies entities in place. Call after all risk groups are registered - but before validation. - --- ## ngraph.model.failure.parser @@ -684,8 +676,9 @@ Returns: FailurePolicy: Configured policy with parsed modes and rules. Raises: - ValueError: If modes is empty or malformed, if rules are invalid, or - if no mode has positive weight. + ValueError: If modes is empty or malformed, if rules are invalid, if + no mode has positive weight, or if the policy, a mode or a rule + carries an unrecognized key. ### build_failure_policy_set(raw: 'Dict[str, Any]', *, derive_seed: 'Callable[[str], Optional[int]]') -> 'FailurePolicySet' @@ -726,6 +719,11 @@ Returns: - explicit_risk_groups: List of RiskGroup objects with names expanded. - generate_specs_raw: List of raw generate block dicts for deferred processing. +Raises: + ValueError: If an entry is neither a string nor a dict, lacks a name, + carries an unrecognized key, or is a child entry with + 'membership', 'disabled' or 'generate'. + --- ## ngraph.model.failure.policy @@ -742,7 +740,7 @@ inherent), so no policy flag controls that behavior. ### FailureMode -A weighted mode that encapsulates a set of rules applied together. +Weighted set of rules applied together. Exactly one mode is selected per failure iteration according to the mode weights. Within a mode, all contained rules are applied and their @@ -762,7 +760,7 @@ Attributes: ### FailurePolicy -A container for failure modes plus optional metadata in `attrs`. +Weighted failure modes plus optional metadata in `attrs`. The main entry point is `apply_failures_typed`, which: 1) Builds a single RNG for the entire call (from `seed` or `self.seed`). @@ -842,9 +840,9 @@ Attributes: Failure policy containers. -Provides `FailurePolicySet`, a named collection of `FailurePolicy` objects -used as input to failure analysis workflows. This module contains input -containers, not analysis results. +`FailurePolicySet` is a named collection of `FailurePolicy` objects used as +input to failure analysis workflows. These are input containers, not analysis +results. ### FailurePolicySet @@ -871,9 +869,8 @@ Attributes: Risk group reference validation. Validates that all risk group references in nodes and links resolve to -defined risk groups. Catches typos and missing definitions early. - -Also provides cycle detection for risk group hierarchies. +defined risk groups, catching typos and missing definitions early, and +detects cycles in risk group hierarchies. ### validate_risk_group_hierarchy(network: "'Network'") -> 'None' @@ -890,7 +887,7 @@ Raises: ### validate_risk_group_references(network: "'Network'") -> 'None' -Ensure every risk group named by a node or link is defined. +Check that every risk group named by a node or link is defined. Names are checked against network.risk_groups; typos and missing definitions would otherwise cause silent failures in simulations. @@ -917,10 +914,10 @@ and placement mode from ``preset_config`` so the two cannot drift. ### FlowPolicyPreset -Enumerates common flow policy presets for traffic routing. +Named 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. +Each preset maps to a combination of path algorithm, flow placement +strategy, and edge selection mode in NetGraph-Core. The ``SHORTEST_PATHS_*`` presets model hop-by-hop IP/IGP forwarding: routes follow link costs alone and each demand is placed in one pass on the @@ -934,8 +931,8 @@ Create a FlowPolicy instance from a preset configuration. Args: algorithms: NetGraph-Core Algorithms instance. graph: NetGraph-Core Graph handle. - preset: Preset whose path algorithm, placement, edge selection, and - flow-count bounds to apply (see ``preset_config``). + preset: Preset whose placement, edge selection, and flow-count bounds + to apply (see ``preset_config``). 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 @@ -976,19 +973,6 @@ Returns: Raises: ValueError: If an unknown FlowPolicyPreset value is provided. -### serialize_policy_preset(cfg: 'Any') -> 'Optional[str]' - -Serialize a FlowPolicyPreset to its string name for JSON storage. - -Args: - cfg: FlowPolicyPreset enum, an integer coercible to one, or any other - value. - -Returns: - Preset name (e.g. "SHORTEST_PATHS_ECMP"); None when ``cfg`` is None. - Values that do not map to a preset are logged at debug level and - returned as ``str(cfg)``. - --- ## ngraph.model.network @@ -999,7 +983,7 @@ These classes carry no analysis machinery and can be used on their own. ### Link -Represents one directed link between two nodes. +One directed link between two nodes. The model stores a single direction (``source`` -> ``target``). When the analysis graph is built (via ``AnalysisContext`` / netgraph-core), a reverse @@ -1032,12 +1016,12 @@ Attributes: ### Network -A container for network nodes and links. +Scenario-level topology: nodes, links, and risk groups. -Network represents the scenario-level topology with persistent state (nodes/links -that are disabled in the scenario configuration). For temporary exclusion of -nodes/links during analysis (e.g., failure simulation), use node_mask and edge_mask -parameters when calling NetGraph-Core algorithms. +Holds persistent state only (nodes and links disabled in the scenario +configuration). For temporary exclusion of nodes or links during analysis +(e.g., failure simulation), pass node_mask and edge_mask to NetGraph-Core +algorithms. Attributes: nodes (Dict[str, Node]): Mapping from node name -> Node object. @@ -1072,10 +1056,7 @@ Attributes: ### Node -Represents a node in the network. - -Each node is uniquely identified by its name, which is used as -the key in the Network's node dictionary. +Network node, keyed by its unique name in ``Network.nodes``. Attributes: name (str): Unique identifier for the node. @@ -1092,7 +1073,7 @@ Attributes: ### RiskGroup -Represents a shared-risk or failure domain, which may have nested children. +Shared-risk or failure domain, optionally with nested children. Risk groups model correlated failures: when a risk group fails, all entities (nodes, links) in that group fail together. Hierarchical children enable @@ -1124,7 +1105,7 @@ Attributes: ## ngraph.model.path -Lightweight representation of a single routing path. +Representation of a single routing path. ``Path`` stores a sequence of (node, parallel edges) elements plus a numeric cost. Paths sort by cost, compare by structure and cost, and support sub-path @@ -1132,7 +1113,7 @@ extraction, which leaves the cost for the caller to recompute. ### Path -Represents a single path in the network. +Routing path as a sequence of nodes and the parallel edges leaving each. Attributes: path: Sequence of (node_name, (edge_refs...)) tuples representing the path. @@ -1233,7 +1214,7 @@ it without a runtime dependency on the DSL package. Parse a match specification from raw dict. -Shared by adjacency, demands, membership rules, and failure policies. +Shared by link definitions, demands, membership rules, and failure policies. Args: raw: Dict with 'conditions' list and optional 'logic'. Both keys are @@ -1246,17 +1227,18 @@ Returns: Parsed MatchSpec. Raises: - ValueError: If 'logic' is not 'and'/'or', 'conditions' is not a list, - a condition is not a dict or lacks 'attr'/'op', 'in'/'not_in' is + ValueError: If the spec or a condition carries an unrecognized key, + 'logic' is not 'and'/'or', 'conditions' is not a list, a + condition is not a dict or lacks 'attr'/'op', 'in'/'not_in' is given a non-list value, or conditions are required but empty. --- ## ngraph.model.selectors.schema -Schema definitions for unified node selection. +Selector schema dataclasses. -Dataclasses shared by network rules, demands, and workflow steps. +Shared by network rules, demands, failure policies, and workflow steps. ### Condition @@ -1278,7 +1260,7 @@ Attributes: ### MatchSpec -Specification for filtering nodes by attribute conditions. +Attribute conditions combined with "and" or "or" logic. Attributes: conditions: List of conditions to evaluate. @@ -1291,7 +1273,7 @@ Attributes: ### NodeSelector -Unified node selection specification. +Node selection by name regex, attribute conditions, and grouping. Evaluation order: @@ -1389,7 +1371,7 @@ Returns: ### select_nodes(network: "'Network'", selector: 'NodeSelector', default_active_only: 'bool') -> "Dict[str, List['Node']]" -Unified entry point for node selection. +Select and group the nodes a `NodeSelector` matches. Evaluation order: @@ -1424,15 +1406,13 @@ re-raised. Base class for all workflow steps. -Every step is logged with execution timing, supports seeding for -reproducible random operations, and has its metadata stored in -scenario.results for analysis. +`execute()` logs each step with its duration and records step metadata in +scenario.results. A step seed makes random operations reproducible. YAML Configuration: ```yaml workflow: - type: - name: "optional_step_name" # Optional: Custom name for this step instance seed: 42 # Optional: Seed for reproducible random operations # ... step-specific parameters ... @@ -1443,17 +1423,17 @@ Attributes: used for logging and result storage. When empty, the class name is used instead. seed: Optional seed for reproducible random operations. If None, - random operations will be non-deterministic. + random operations are non-deterministic. **Attributes:** - `name` (str) - `seed` (Optional[int]) -- `_seed_source` (str) +- `_seed_source` (str) = explicit-step **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step logic. ### register_workflow_step(step_type: 'str') @@ -1478,22 +1458,18 @@ Returns: Positive integer worker count (minimum 1). Raises: - ValueError: If parallelism is a string other than "auto", or an - integer < 1. + ValueError: If parallelism is neither "auto" nor an integer >= 1. -### serialize_monte_carlo_results(raw: 'Dict[str, Any]') -> 'tuple[Any, list[dict]]' +### serialize_monte_carlo_results(raw: 'Dict[str, Any]') -> 'tuple[dict, list[dict]]' Convert FailureManager Monte Carlo output into JSON-safe dicts. Args: - raw: Dict with optional "baseline" entry and "results" list, whose - items expose to_dict() (e.g. FlowIterationResult) or are already - plain dicts. + raw: ``run_monte_carlo_analysis`` output whose "baseline" and + "results" items are FlowIterationResult objects. Returns: - Tuple of (baseline_dict, flow_results): the baseline iteration (or - None) and the failure iterations, converted via to_dict() when - available. + Tuple of (baseline_dict, flow_results). ### validate_unique_step_names(workflow: "'list[WorkflowStep]'") -> 'None' @@ -1514,17 +1490,16 @@ Raises: ## ngraph.workflow.build_graph -Graph building workflow component. +BuildGraph workflow step. -Validates the network topology and exports it as a NetworkX node-link -representation for inspection. Graph building for analysis happens in the -analysis functions, not here. +Exports the network topology as a NetworkX node-link representation for +inspection. Analysis functions build their own graphs and do not read this +one. YAML Configuration Example: ```yaml workflow: - type: BuildGraph - name: "build_network_graph" # Optional: Custom name for this step add_reverse: true # Optional: Add reverse edges (default: true) ``` @@ -1535,31 +1510,28 @@ With `add_reverse: true` (the default), each Link(Aโ†’B) gets both a forward Results stored in `scenario.results` under the step name as two keys: -- metadata: Step-level execution metadata (node/link counts) +- metadata: node_count and link_count (graph edges, including reverse edges) - data: { graph: node-link JSON dict, context: { add_reverse: bool } } ### BuildGraph -Validates network topology and stores node-link representation. - -The stored representation is JSON-serializable NetworkX node-link data. -Core graph building for analysis happens in analysis functions as needed. +Stores the network as JSON-serializable NetworkX node-link data. Attributes: - add_reverse: If True, adds reverse edges for bidirectional connectivity. - Defaults to True. + add_reverse: If True, adds a reverse edge (id "_reverse") for + every link. Defaults to True. **Attributes:** - `name` (str) - `seed` (Optional[int]) -- `_seed_source` (str) +- `_seed_source` (str) = explicit-step - `add_reverse` (bool) = True **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. -- `run(self, scenario: 'Scenario') -> 'None'` - Validate network and store node-link representation. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. +- `run(self, scenario: 'Scenario') -> 'None'` - Store the network's node-link representation. --- @@ -1583,14 +1555,16 @@ root (path ""), and higher levels correspond to prefixes of node names split by Disabled handling: -- When include_disabled is False, only enabled nodes and links are considered. +- When include_disabled is False, only enabled nodes and links are considered, + + and a link's optics count only when both endpoint nodes are enabled. + - Optics are counted only when the endpoint node has platform hardware. YAML Configuration Example: ```yaml workflow: - type: CostPower - name: "cost_power" # Optional custom name include_disabled: false # Default: only enabled nodes/links aggregation_level: 2 # Produce levels: 0, 1, 2 @@ -1628,13 +1602,13 @@ Attributes: - `name` (str) - `seed` (Optional[int]) -- `_seed_source` (str) +- `_seed_source` (str) = explicit-step - `include_disabled` (bool) = False - `aggregation_level` (int) = 2 **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: 'Any') -> 'None'` - Aggregate capex and power by hierarchy levels 0..N. --- @@ -1644,7 +1618,8 @@ Attributes: MaxFlow workflow step. Monte Carlo analysis of maximum flow capacity between node groups using FailureManager. -Produces unified `flow_results` per iteration under `data.flow_results`. +Writes one `FlowIterationResult` dict per unique failure pattern under +`data.flow_results` and the no-failure result under `data.baseline`. Baseline (no failures) always runs first as a separate reference; `iterations` counts failure scenarios only. @@ -1700,8 +1675,8 @@ Attributes: **Attributes:** - `name` (str) -- `seed` (int | None) -- `_seed_source` (str) +- `seed` (Optional[int]) +- `_seed_source` (str) = explicit-step - `source` (Union[str, Dict[str, Any]]) - `target` (Union[str, Dict[str, Any]]) - `mode` (str) = combine @@ -1717,7 +1692,7 @@ Attributes: **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step logic. --- @@ -1741,7 +1716,6 @@ YAML Configuration Example: ```yaml workflow: - type: MaximumSupportedDemand - name: "msd_search" demand_set: "default" resolution: 0.01 # Convergence threshold @@ -1754,30 +1728,27 @@ YAML Configuration Example: Finds the maximum uniform traffic multiplier that is fully placeable. -Binary search yields alpha_star: the largest multiplier at which every -demand in the set still places fully on the network. +Brackets alpha_star by scaling alpha_start up or down by growth_factor, +then bisects. alpha_star is the largest multiplier at which every demand +in the set still places fully on the network. Attributes: demand_set: Name of the demand set to analyze. - acceptance_rule: Currently only "hard" is implemented; anything else - raises ValueError at run time. alpha_start: Starting multiplier for binary search. growth_factor: Factor for bracket expansion; must be > 1.0. - alpha_min: Minimum allowed alpha value. - alpha_max: Maximum allowed alpha value. - resolution: Convergence threshold for binary search; must be positive. + alpha_min: Lowest alpha probed; the step raises if it is infeasible. + alpha_max: Highest alpha probed; returned when it is feasible. + resolution: Bisection stops once the bracket is no wider than this; + must be positive. max_bracket_iters: Maximum iterations for bracketing phase. max_bisect_iters: Maximum iterations for bisection phase. - placement_rounds: Deprecated; accepted for backward compatibility but - has no effect (each demand is placed in one deterministic pass). **Attributes:** - `name` (str) - `seed` (Optional[int]) -- `_seed_source` (str) +- `_seed_source` (str) = explicit-step - `demand_set` (str) = default -- `acceptance_rule` (str) = hard - `alpha_start` (float) = 1.0 - `growth_factor` (float) = 2.0 - `alpha_min` (float) = 1e-06 @@ -1785,11 +1756,10 @@ Attributes: - `resolution` (float) = 0.01 - `max_bracket_iters` (int) = 32 - `max_bisect_iters` (int) = 32 -- `placement_rounds` (int | str) = auto **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: "'Any'") -> 'None'` - Execute the workflow step logic. --- @@ -1798,23 +1768,23 @@ Attributes: Workflow step for basic node and link statistics. -Computes and stores network statistics including node/link counts, -capacity distributions, cost distributions, and degree distributions. Excluded -entities are filtered out without modifying the base network; disabled nodes -and links are excluded too unless `include_disabled` is set. +Computes node and link counts plus mean, median, min, and max of link +capacity, link cost, and node degree. Excluded entities are filtered out +without modifying the base network; disabled nodes and links are excluded too +unless `include_disabled` is set. A link counts only when both endpoints +remain. YAML Configuration Example: ```yaml workflow: - type: NetworkStats - name: "network_statistics" # Optional: Custom name for this step include_disabled: false # Include disabled nodes/links in stats excluded_nodes: ["node1", "node2"] # Optional: Temporary node exclusions excluded_links: ["link1", "link3"] # Optional: Temporary link exclusions ``` -Results stored in `scenario.results`: +Results stored in `scenario.results` under `data`: - Node statistics: node_count - Link statistics: link_count, total_capacity, mean_capacity, median_capacity, @@ -1827,7 +1797,7 @@ Results stored in `scenario.results`: Compute basic node and link statistics for the network. -Supports optional exclusion simulation without modifying the base network. +Exclusions apply to this computation only; the network is not modified. Attributes: include_disabled: If True, include disabled nodes and links in statistics. @@ -1839,14 +1809,14 @@ Attributes: - `name` (str) - `seed` (Optional[int]) -- `_seed_source` (str) +- `_seed_source` (str) = explicit-step - `include_disabled` (bool) = False - `excluded_nodes` (Iterable[str]) = () - `excluded_links` (Iterable[str]) = () **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: 'Scenario') -> 'None'` - Compute and store network statistics. --- @@ -1867,7 +1837,13 @@ Args: derive_seed: Callable that takes a step name and returns a seed or None. Returns: - A list of WorkflowStep instances with unique names and optional seeds. + WorkflowStep instances. An unnamed step is named "{type}_{index}"; a + step without a seed gets ``derive_seed(name)`` when that is not None. + +Raises: + ValueError: If ``workflow_data`` is not a list, a step lacks ``type`` + or names an unregistered type, two steps resolve to the same + name, or a step carries a key its step class does not define. --- @@ -1875,8 +1851,9 @@ Returns: TrafficMatrixPlacement workflow step. -Runs Monte Carlo demand placement using a named demand set and produces -unified `flow_results` per iteration under `data.flow_results`. +Runs Monte Carlo demand placement using a named demand set. Writes one +`FlowIterationResult` dict per unique failure pattern under +`data.flow_results` and the no-failure result under `data.baseline`. Baseline (no failures) always runs first as a separate reference; `iterations` counts failure scenarios only. @@ -1885,7 +1862,6 @@ YAML Configuration Example: ```yaml workflow: - type: TrafficMatrixPlacement - name: "tm_analysis" demand_set: "default" failure_policy: "single_link" # Optional: failure policy name @@ -1914,16 +1890,14 @@ Attributes: set, or a free-threaded interpreter) and 1 otherwise, because cacheable presets are Python-bound under the GIL and threads only slow them down. See ``resolve_placement_parallelism``. - placement_rounds: Deprecated; accepted for backward compatibility but - has no effect (each demand is placed in one deterministic pass). seed: Optional seed for reproducibility. store_failure_patterns: Record the failure trace on each result. Iterations are deduplicated, so a trace describes the first iteration of its pattern, not every matching iteration. include_flow_details: When True, include cost_distribution per flow. include_used_edges: When True, include set of used edges per demand in entry data. - alpha: Numeric scale for demands in the set; must be > 0.0. Ignored - when alpha_from_step is set. + alpha: Numeric scale for demands in the set; must be > 0.0. Defaults + to 1.0; cannot be combined with alpha_from_step. alpha_from_step: Optional producer step name to read alpha from; it must run before this step. alpha_from_field: Dotted field path in producer step (default: "data.alpha_star"). @@ -1931,23 +1905,22 @@ Attributes: **Attributes:** - `name` (str) -- `seed` (int | None) -- `_seed_source` (str) +- `seed` (Optional[int]) +- `_seed_source` (str) = explicit-step - `demand_set` (str) - `failure_policy` (str | None) - `iterations` (int) = 1 - `parallelism` (int | str) = auto -- `placement_rounds` (int | str) = auto - `store_failure_patterns` (bool) = False - `include_flow_details` (bool) = False - `include_used_edges` (bool) = False -- `alpha` (float) = 1.0 +- `alpha` (float | None) - `alpha_from_step` (str | None) - `alpha_from_field` (str) = data.alpha_star **Methods:** -- `execute(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step with logging and metadata storage. +- `execute(self, scenario: "'Scenario'") -> 'None'` - Run the step inside its results namespace and record metadata. - `run(self, scenario: "'Scenario'") -> 'None'` - Execute the workflow step logic. ### resolve_placement_parallelism(parallelism: 'int | str', demands: 'Iterable[TrafficDemand]') -> 'int' @@ -2005,13 +1978,13 @@ Attributes: ### DSLExpansionContext -Carries the blueprint definitions and the final Network instance -to be populated during DSL expansion. +Blueprint definitions and the Network populated during DSL expansion. Attributes: blueprints: Dictionary of blueprint-name -> Blueprint. network: The Network into which expanded nodes/links are inserted. - pending_bp_links: Deferred blueprint link expansions. + pending_bp_links: (link definition, parent path) pairs for blueprint + links, expanded after node rules run. **Attributes:** @@ -2021,7 +1994,7 @@ Attributes: ### expand_network_dsl(data: 'Dict[str, Any]') -> 'Network' -Expands a combined blueprint + network DSL into a complete Network object. +Expand a combined blueprint + network DSL into a Network. Overall flow: 1) Parse "blueprints" into Blueprint objects. @@ -2042,11 +2015,11 @@ Overall flow: Field validation rules: -- Only certain top-level fields are permitted in each structure. +- Each structure rejects unrecognized keys. - Link properties are flat (capacity, cost, etc. at link level). -- For node definitions: count, template, attrs, disabled, risk_groups, +- Node definitions accept count, template, attrs, disabled, risk_groups, - or blueprint for blueprint-based nodes. + nodes (nested groups), or blueprint and params (blueprint instances). Args: data: The YAML-parsed dictionary containing optional "blueprints" + "network". @@ -2065,32 +2038,18 @@ they can be tested independently and reused. ### check_link_keys(link_def: 'Dict[str, Any]', context: 'str') -> 'None' -Ensure link definitions only contain recognized keys. - -### check_no_extra_keys(data_dict: 'Dict[str, Any]', allowed: 'set[str]', context: 'str') -> 'None' - -Raise if ``data_dict`` contains keys outside ``allowed``. - -Args: - data_dict: The dict to check. - allowed: Set of recognized keys. - context: Short description used in error messages. +Reject unrecognized link keys and require 'source' and 'target'. ### join_paths(parent_path: 'str', rel_path: 'str') -> 'str' Join two path segments according to DSL conventions. -The DSL has no concept of absolute paths. All paths are relative to the -current context (parent_path). A leading "/" on rel_path is stripped and -has no functional effect - it serves only as a visual indicator that the -path starts from the current scope's root. - -Behavior: - -- Leading "/" on rel_path is stripped (not treated as filesystem root) -- Result is always: "{parent_path}/{stripped_rel_path}" if parent_path is non-empty -- Examples: +The DSL has no absolute paths. All paths are relative to the current +context (parent_path). A leading "/" on rel_path is stripped and has no +functional effect; it only marks that the path starts from the current +scope's root. +Examples: join_paths("", "/leaf") -> "leaf" join_paths("pod1", "/leaf") -> "pod1/leaf" join_paths("pod1", "leaf") -> "pod1/leaf" (same result) @@ -2100,7 +2059,7 @@ Args: rel_path: Path to join. Leading "/" is stripped if present. Returns: - Combined path string. + "{parent_path}/{rel_path}", or rel_path alone when parent_path is empty. --- @@ -2140,9 +2099,6 @@ Examples: Expand bracket patterns in a list of risk group references. -Takes a list, set, or tuple of risk group names (possibly containing -bracket expressions) and returns a set of all expanded names. - Args: rg_list: List, set, or tuple of risk group name patterns. Other iterables (including bare strings and generators) are rejected. @@ -2216,7 +2172,7 @@ Yields: ### substitute_vars(obj: 'Any', var_dict: 'Dict[str, Any]') -> 'Any' -Recursively substitute ${var} in all strings within obj. +Recursively substitute $var and ${var} in all strings within obj. A string consisting of exactly one placeholder (e.g. "${t}") is replaced by the variable's native value, preserving its type. This keeps match @@ -2229,8 +2185,8 @@ Args: var_dict: Mapping of variable names to values. Returns: - Object with variables substituted: whole-placeholder strings replaced - by the variable's native value, other strings interpolated as text. + obj with placeholders substituted. Dicts and lists are rebuilt, not + mutated. Raises: KeyError: If a placeholder names a variable absent from var_dict. @@ -2241,17 +2197,22 @@ Raises: YAML loader + schema validation for Scenario DSL. -A single entrypoint parses a YAML string, normalizes keys where needed, -validates against the packaged JSON schema, and returns a canonical -dictionary suitable for downstream expansion/parsing. +`load_scenario_yaml` parses a YAML string, validates it against the packaged +JSON schema, and returns the dictionary for expansion and parsing. ### load_scenario_yaml(yaml_str: 'str') -> 'Dict[str, Any]' -Load, normalize, and validate a Scenario YAML string. +Load and validate a Scenario YAML string. -Returns a canonical dictionary representation that downstream parsers can -consume without worrying about YAML-specific quirks (e.g., boolean-like -keys) and with schema shape already enforced. +Returns the parsed dictionary with schema shape enforced. Section builders +normalize YAML-specific quirks such as boolean-like keys. + +Raises: + ValueError: If the top level is not a mapping, or a network, link, or + risk group entry has the wrong shape (checked before the schema + for clearer messages). + jsonschema.ValidationError: If the data does not match the packaged + schema, including unrecognized top-level keys. --- @@ -2259,8 +2220,7 @@ keys) and with schema shape already enforced. Selector parsing and normalization. -Single entry point for converting raw selector values (strings or dicts) -into NodeSelector objects. +Converts raw selector values (strings or dicts) into NodeSelector objects. ### normalize_selector(raw: 'Union[str, Dict[str, Any], NodeSelector]', context: 'str') -> 'NodeSelector' @@ -2270,14 +2230,16 @@ All downstream code works with NodeSelector objects only. Args: raw: Either a regex string, selector dict, or existing NodeSelector. - context: Usage context ("adjacency", "demand", "override", "workflow"). + context: Usage context ("link", "rule", "demand", "workflow"). Determines the default for active_only. Returns: - Normalized NodeSelector instance. + NodeSelector with active_only set, from the selector or the context + default. Raises: - ValueError: If selector format is invalid or context is unknown. + ValueError: If the selector is neither a string nor a dict, a selector + dict carries an unrecognized key, or the context is unknown. --- @@ -2285,8 +2247,8 @@ Raises: Serializable result artifacts for analysis workflows. -`CapacityEnvelope` captures a frequency-based capacity distribution, plus -optional aggregated flow statistics, in JSON-serializable form. +`CapacityEnvelope` captures a frequency-based capacity distribution in +JSON-serializable form. ### CapacityEnvelope @@ -2304,10 +2266,8 @@ Attributes: min_capacity: Minimum observed capacity. max_capacity: Maximum observed capacity. mean_capacity: Mean capacity across all samples. - stdev_capacity: Standard deviation of capacity values. + stdev_capacity: Population standard deviation of capacity values. total_samples: Total number of samples represented. - flow_summary_stats: Optional dictionary with aggregated FlowSummary statistics. - Contains cost_distribution_stats and other flow analytics. **Attributes:** @@ -2320,13 +2280,12 @@ Attributes: - `mean_capacity` (float) - `stdev_capacity` (float) - `total_samples` (int) -- `flow_summary_stats` (Dict[str, Any]) = {} **Methods:** - `expand_to_values(self) -> 'List[float]'` - Expand frequency map back to individual values. - `from_dict(data: 'Dict[str, Any]') -> "'CapacityEnvelope'"` - Construct a CapacityEnvelope from a dictionary. -- `from_values(source_pattern: 'str', sink_pattern: 'str', mode: 'str', values: 'List[float]', flow_summaries: 'List[Any] | None' = None) -> "'CapacityEnvelope'"` - Create envelope from capacity values and optional flow summaries. +- `from_values(source_pattern: 'str', sink_pattern: 'str', mode: 'str', values: 'List[float]') -> "'CapacityEnvelope'"` - Create envelope from capacity values. - `get_percentile(self, percentile: 'float') -> 'float'` - Calculate percentile from frequency distribution. - `to_dict(self) -> 'Dict[str, Any]'` - Convert to dictionary for JSON serialization. @@ -2334,19 +2293,15 @@ Attributes: ## ngraph.results.flow -Unified flow result containers for failure-analysis iterations. +Flow result containers for failure-analysis iterations. -Defines small, serializable dataclasses that capture per-iteration outcomes -for capacity and demand-placement style analyses in a unit-agnostic form. +Serializable dataclasses for per-iteration outcomes of capacity and +demand-placement analyses. Values are unit-agnostic. Objects expose `to_dict()` that returns JSON-safe primitives. Float-keyed -distributions are normalized to string keys via `_fmt_float_key()`, and -arbitrary `data` payloads are sanitized. These dicts are written under -`data.flow_results` by steps. - -Utilities: - _fmt_float_key: Formats floats as stable string keys for JSON serialization, - in fixed-point notation with trailing zeros stripped. +distributions are normalized to fixed-point string keys via +`_fmt_float_key()`, and `data` payloads are checked by `_ensure_json_safe()`. +Steps write these dicts under `data.flow_results`. ### FlowEntry @@ -2462,16 +2417,17 @@ Returns: Generic results store for workflow steps and their metadata. `Results` organizes outputs by workflow step name and records -`WorkflowStepMetadata` for execution context. Storage is strictly -step-scoped: steps must write two keys under their namespace: +`WorkflowStepMetadata` for execution context. Storage is step-scoped, and a +step may write only two keys under its namespace: - ``metadata``: step-level metadata (dict) - ``data``: step-specific payload (dict) -Export with :meth:`Results.to_dict`, which returns a JSON-safe structure -with shape ``{workflow, steps, scenario}``. During export, objects with a -``to_dict()`` method are converted, dictionary keys are coerced to strings, -tuples are emitted as lists, and only JSON primitives are produced. +Export with :meth:`Results.to_dict`, which returns a structure with shape +``{workflow, steps, scenario}``. During export, objects with a ``to_dict()`` +method are converted, dictionary keys are coerced to strings, and tuples are +emitted as lists. Other values pass through unchanged, so steps must store +JSON-compatible values. ### Results @@ -2493,14 +2449,14 @@ Structure: **Methods:** - `enter_step(self, step_name: str) -> None` - Enter step scope. Subsequent put/get are scoped to this step. -- `exit_step(self) -> None` - Exit step scope. +- `exit_step(self) -> None` - Exit step scope; put/get raise until the next enter_step. - `get(self, key: str, default: Any = None) -> Any` - Get a value from the active step scope. - `get_all_step_metadata(self) -> Dict[str, ngraph.results.store.WorkflowStepMetadata]` - Get metadata for all workflow steps. - `get_step(self, step_name: str) -> Dict[str, Any]` - Return the raw dict for a given step name (for cross-step reads). - `get_step_metadata(self, step_name: str) -> ngraph.results.store.WorkflowStepMetadata | None` - Get metadata for a workflow step. - `get_steps_by_execution_order(self) -> list[str]` - Get step names ordered by their execution order. - `put(self, key: str, value: Any) -> None` - Store a value in the active step under an allowed key. -- `put_step_metadata(self, step_name: str, step_type: str, execution_order: int, *, scenario_seed: int | None = None, step_seed: int | None = None, seed_source: str = 'none', active_seed: int | None = None) -> None` - Store metadata for a workflow step. +- `put_step_metadata(self, step_name: str, step_type: str, execution_order: int, *, scenario_seed: int | None = None, step_seed: int | None = None, seed_source: str = 'none') -> None` - Store metadata for a workflow step. - `set_scenario_snapshot(self, snapshot: Dict[str, Any]) -> None` - Attach a normalized scenario snapshot for export. - `to_dict(self) -> Dict[str, Any]` - Return exported results with shape: {workflow, steps, scenario}. @@ -2520,9 +2476,8 @@ Attributes: - "explicit-step": seed was explicitly provided for the step - "none": no seed provided/active for this step - active_seed: The effective base seed used by the step, if any. For steps - that use Monte Carlo execution, per-iteration seeds are derived from - active_seed (e.g., active_seed + iteration_index). + Monte Carlo steps derive per-iteration seeds from step_seed + (step_seed + iteration_index). **Attributes:** @@ -2532,7 +2487,6 @@ Attributes: - `scenario_seed` (Union) - `step_seed` (Union) - `seed_source` (str) = none -- `active_seed` (Union) --- @@ -2540,9 +2494,9 @@ Attributes: Profiling for NetGraph workflow execution. -Provides CPU and wall-clock timing per workflow step using ``cProfile`` and -optionally peak memory via ``tracemalloc``. Aggregates results into structured -summaries and identifies time-dominant steps (bottlenecks). +Times each workflow step (CPU and wall clock) with ``cProfile`` and can record +peak memory with ``tracemalloc``. Steps that take more than 10% of total wall +time are reported as bottlenecks. ### PerformanceProfiler @@ -2553,13 +2507,13 @@ than 10% of total wall time as bottlenecks. **Methods:** -- `analyze_performance(self) -> 'None'` - Analyze profiling results and identify bottlenecks. -- `end_scenario(self) -> 'None'` - End profiling for the entire scenario execution. -- `get_top_functions(self, step_name: 'str', limit: 'int' = 10) -> 'List[Tuple[str, float, int]]'` - Get the top CPU-consuming functions for a specific step. +- `analyze_performance(self) -> 'None'` - Flag steps above 10% of total wall time and fill ``analysis_summary``. +- `end_scenario(self) -> 'None'` - Record total wall time and sum CPU time and calls across steps. +- `get_top_functions(self, step_name: 'str', limit: 'int' = 10) -> 'List[Tuple[str, float, int]]'` - Return the step's functions with the highest internal time. - `merge_child_profiles(self, profile_dir: 'Path', step_name: 'str') -> 'None'` - Merge child worker profiles into the parent step profile. -- `profile_step(self, step_name: 'str', step_type: 'str') -> 'Generator[None, None, None]'` - Context manager for profiling individual workflow steps. -- `save_detailed_profile(self, output_path: 'Path', step_name: 'Optional[str]' = None) -> 'None'` - Save detailed profiling data to a file. -- `start_scenario(self) -> 'None'` - Start profiling for the entire scenario execution. +- `profile_step(self, step_name: 'str', step_type: 'str') -> 'Generator[None, None, None]'` - Profile the enclosed block as one workflow step. +- `save_detailed_profile(self, output_path: 'Path', step_name: 'str') -> 'None'` - Save one step's cProfile data to a file. +- `start_scenario(self) -> 'None'` - Record the scenario start time. ### PerformanceReporter @@ -2569,7 +2523,7 @@ Covers per-step timing, bottleneck identification, and tuning suggestions. **Methods:** -- `generate_report(self) -> 'str'` - Generate performance report. +- `generate_report(self) -> 'str'` - Render the full report. ### ProfileResults @@ -2581,7 +2535,7 @@ Attributes: total_cpu_time: Total CPU time across all steps. total_function_calls: Total function calls across all steps. bottlenecks: List of performance bottlenecks (>10% execution time). - analysis_summary: Performance metrics and statistics. + analysis_summary: Aggregate metrics computed by ``analyze_performance``. **Attributes:** @@ -2598,12 +2552,13 @@ Performance profile data for a single workflow step. Attributes: step_name: Name of the workflow step. - step_type: Type/class name of the workflow step. + step_type: Class name of the workflow step. wall_time: Total wall-clock time in seconds. - cpu_time: CPU time spent in step execution. + cpu_time: CPU time in seconds (sum of cProfile internal times). function_calls: Number of function calls during execution. - memory_peak: Peak memory usage during step in bytes (if available). - cprofile_stats: Detailed cProfile statistics object. + memory_peak: Peak traced memory in bytes; None unless memory + tracking ran for this step. + cprofile_stats: cProfile statistics, including merged worker profiles. worker_profiles_merged: Number of worker profiles merged into this step. **Attributes:** @@ -2621,7 +2576,7 @@ Attributes: ## ngraph.types.base -Base classes and enums for network analysis algorithms. +Cost alias and enums shared by network analysis algorithms. ### EdgeSelect @@ -2644,9 +2599,7 @@ Determines how multiple source and sink nodes are combined for analysis. ## ngraph.types.dto -Types and data structures for algorithm analytics. - -Defines immutable summary containers for algorithm outputs. +Immutable edge references and result containers for algorithm outputs. ### EdgeRef @@ -2668,8 +2621,6 @@ Attributes: Result of max-flow computation between a source/sink pair. -Captures total flow, cost distribution, and optionally min-cut edges. - Attributes: total_flow: Maximum flow value achieved. cost_distribution: Mapping of cost to flow volume placed at that cost. @@ -2696,7 +2647,7 @@ Base64; the two trailing padding characters are dropped, leaving 22 ASCII characters. Returns: - A 22-character URL-safe Base64 representation of a UUID4, unpadded. + 22-character ASCII string. --- @@ -2708,15 +2659,12 @@ Every artifact path the NetGraph CLI writes is composed here, from an optional output directory, a prefix (usually derived from the scenario file or results file), and a per-artifact suffix. -### build_artifact_path(output_dir: 'Optional[Path]', prefix: 'str', suffix: 'str') -> 'Path' +### build_artifact_path(output_dir: 'Path', prefix: 'str', suffix: 'str') -> 'Path' Compose an artifact path as output_dir / (prefix + suffix). -If ``output_dir`` is None, the path is created relative to the current -working directory. - Args: - output_dir: Base directory for outputs; if None, use CWD. + output_dir: Base directory for outputs. prefix: Filename prefix; usually derived from scenario or results stem. suffix: Per-artifact suffix including the dot (e.g. ".results.json"). @@ -2725,7 +2673,7 @@ Returns: ### ensure_parent_dir(path: 'Path') -> 'None' -Ensure the parent directory exists for a file path. +Create the parent directory of ``path`` if it is missing. ### profiles_dir_for_run(scenario_path: 'Path', output_dir: 'Optional[Path]') -> 'Path' @@ -2736,7 +2684,8 @@ Args: output_dir: Optional base output directory. Returns: - Directory path where worker profiles should be stored. + ``output_dir/.profiles``, or ``worker_profiles`` in the + current directory when ``output_dir`` is None. ### resolve_override_path(override: 'Optional[Path]', output_dir: 'Optional[Path]') -> 'Optional[Path]' @@ -2777,13 +2726,13 @@ Returns: ### scenario_prefix_from_path(scenario_path: 'Path') -> 'str' -Return a safe prefix derived from a scenario file path. +Return the artifact filename prefix for a scenario file. Args: scenario_path: The scenario YAML file path. Returns: - The scenario filename stem, trimmed of extensions. + The filename without its final extension (``Path.stem``). --- @@ -2793,11 +2742,12 @@ Deterministic seed derivation to avoid global random.seed() order dependencies. ### SeedManager -Manages deterministic seed derivation for isolated component reproducibility. +Derives per-component seeds from one master seed. -Global random.seed() creates order dependencies and component interference. -SeedManager derives unique seeds per component from a master seed using SHA-256, -ensuring reproducible results regardless of execution order or parallelism. +A global random.seed() makes each component's random draws depend on what +ran before it. SeedManager hashes the master seed with component +identifiers (SHA-256), so a component's seed does not depend on execution +order or parallelism. Usage: seed_mgr = SeedManager(42) @@ -2811,11 +2761,23 @@ Usage: ## ngraph.utils.yaml_utils -Utilities for handling YAML parsing quirks and common operations. +Helpers for YAML parsing quirks and configuration key checks. + +### check_no_extra_keys(data: Mapping[Any, Any], allowed: AbstractSet[str], context: str) -> None + +Raise if ``data`` has keys outside ``allowed``; they would be ignored. + +Args: + data: Mapping parsed from configuration. + allowed: Recognized keys. + context: Short description of ``data`` used in the error message. + +Raises: + ValueError: If ``data`` contains any key not in ``allowed``. ### normalize_yaml_dict_keys(data: Dict[Any, ~V]) -> Dict[str, ~V] -Normalize dictionary keys from YAML parsing to ensure consistent string keys. +Convert YAML-parsed dictionary keys to strings. YAML 1.1 parses true/false/yes/no/on/off keys as Python booleans. Those become "True"/"False"; every other key is coerced with str(). @@ -2824,7 +2786,7 @@ Args: data: Dictionary that may contain boolean or other non-string keys from YAML parsing Returns: - Dictionary with all keys converted to strings, boolean keys converted to "True"/"False" + New dictionary with str() keys. Examples: >>> normalize_yaml_dict_keys({True: "value1", False: "value2", "normal": "value3"}) @@ -2858,7 +2820,7 @@ Prepared graph state for repeated network analysis. Wraps the Core graph infrastructure. Two usage patterns: -**Unbound** - source/sink given per call: +**Unbound** (source/sink given per call): ctx = AnalysisContext.from_network(network) cost = ctx.shortest_path_cost("A", "B") @@ -2868,7 +2830,7 @@ Every flow call on an unbound context builds a full temporary bound context, which rebuilds the graph from scratch; bind the context instead for repeated flow analysis. -**Bound** - source/sink fixed at construction, reused across calls: +**Bound** (source/sink fixed at construction, reused across calls): ctx = AnalysisContext.from_network( network, @@ -2899,27 +2861,27 @@ Attributes: - `_pseudo_context` (Optional[_PseudoNodeContext]) - `_augmentations` (Tuple[AugmentationEdge, ...]) = () - `_core_lock` (threading.Lock) +- `_static_path_cache` (Dict[tuple, List[netgraph_core.PredDAG]]) = {} **Methods:** - `build_edge_mask(self, excluded_links: 'Optional[Set[str]]' = None) -> 'np.ndarray'` - Build an edge inclusion mask for Core algorithms. - `build_node_mask(self, excluded_nodes: 'Optional[Set[str]]' = None) -> 'np.ndarray'` - Build a node inclusion mask for Core algorithms. -- `from_network(network: "'Network'", *, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, mode: 'Mode' = , augmentations: 'Optional[List[AugmentationEdge]]' = None) -> "'AnalysisContext'"` - Create analysis context from network. -- `k_shortest_paths(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , max_k: 'int' = 3, edge_select: 'EdgeSelect' = , max_path_cost: 'float' = inf, max_path_cost_factor: 'Optional[float]' = None, split_parallel_edges: 'bool' = False, excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], List[Path]]'` - Compute up to K shortest paths per group pair. -- `max_flow(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], float]'` - Compute maximum flow between node groups. -- `max_flow_detailed(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None, include_min_cut: 'bool' = False) -> 'Dict[Tuple[str, str], MaxFlowResult]'` - Compute max flow with detailed results including cost distribution. -- `sensitivity(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], Dict[str, float]]'` - Analyze sensitivity of max flow to edge failures. -- `sensitivity_with_flow(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], Tuple[float, Dict[str, float]]]'` - Compute max flow and edge sensitivity together per group pair. -- `shortest_path_cost(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , edge_select: 'EdgeSelect' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], float]'` - Compute shortest path costs between node groups. -- `shortest_paths(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Mode' = , edge_select: 'EdgeSelect' = , split_parallel_edges: 'bool' = False, excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], List[Path]]'` - Compute concrete shortest paths between node groups. +- `from_network(network: "'Network'", *, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, mode: 'Optional[Mode]' = None, augmentations: 'Optional[List[AugmentationEdge]]' = None) -> "'AnalysisContext'"` - Build a context for a network, bound when source and sink are given. +- `k_shortest_paths(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, max_k: 'int' = 3, max_path_cost: 'float' = inf, max_path_cost_factor: 'Optional[float]' = None, split_parallel_edges: 'bool' = False, excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], List[Path]]'` - Compute up to K shortest paths per group pair. +- `max_flow(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], float]'` - Compute maximum flow between node groups. +- `max_flow_detailed(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None, include_min_cut: 'bool' = False) -> 'Dict[Tuple[str, str], MaxFlowResult]'` - Compute max flow with detailed results including cost distribution. +- `sensitivity(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], Dict[str, float]]'` - Analyze sensitivity of max flow to edge failures. +- `sensitivity_with_flow(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], Tuple[float, Dict[str, float]]]'` - Compute max flow and edge sensitivity together per group pair. +- `shortest_path_cost(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], float]'` - Compute shortest path costs between node groups. +- `shortest_paths(self, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, *, mode: 'Optional[Mode]' = None, edge_select: 'EdgeSelect' = , split_parallel_edges: 'bool' = False, excluded_nodes: 'Optional[Set[str]]' = None, excluded_links: 'Optional[Set[str]]' = None) -> 'Dict[Tuple[str, str], List[Path]]'` - Compute concrete shortest paths between node groups. ### AugmentationEdge Edge specification for graph augmentation. Augmentation edges are added to the graph as-is (unidirectional). -Nodes referenced in augmentations that don't exist in the network -are automatically treated as pseudo/virtual nodes. +Endpoints that are not network nodes become pseudo nodes. Attributes: source: Source node name (real or pseudo) @@ -2927,7 +2889,7 @@ Attributes: capacity: Edge capacity cost: Edge cost (must be an integer value; Core uses int64 costs) -### analyze(network: "'Network'", *, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, mode: 'Mode' = , augmentations: 'Optional[List[AugmentationEdge]]' = None) -> 'AnalysisContext' +### analyze(network: "'Network'", *, source: 'Optional[Union[str, Dict[str, Any]]]' = None, sink: 'Optional[Union[str, Dict[str, Any]]]' = None, mode: 'Optional[Mode]' = None, augmentations: 'Optional[List[AugmentationEdge]]' = None) -> 'AnalysisContext' Create an analysis context for the network. @@ -2939,15 +2901,17 @@ Args: If provided with sink, creates a bound context whose pseudo nodes are pre-built once and reused by every flow call. sink: Optional sink node selector (string path or selector dict). - mode: Group mode (COMBINE or PAIRWISE). Only used if bound. - augmentations: Optional custom augmentation edges. + mode: Group mode of a bound context: COMBINE (default) or PAIRWISE. + Unbound calls choose the mode per method call. + augmentations: Extra edges added to the graph as-is (see + AugmentationEdge). Returns: AnalysisContext ready for analysis calls. Raises: - ValueError: If only one of source/sink is provided, or if a bound - selector matches no nodes. + ValueError: If only one of source/sink is provided, if mode is given + without them, or if a bound selector matches no nodes. ValueError: If any link capacity is at or above LARGE_CAPACITY (1e15, the internal pseudo-edge capacity), since such a link would be silently clamped by the pseudo attachment edges in combine-mode @@ -2992,7 +2956,7 @@ resolved through the shared selector layer. ### DemandExpansion -Demand expansion result. +Output of expand_demands. Attributes: demands: Concrete demands ready for placement (sorted by priority). @@ -3033,27 +2997,24 @@ Attributes: - `static_paths` (Tuple[StaticPath, ...]) = () - `src_members` (Tuple[str, ...]) = () -### expand_demands(network: 'Network', traffic_demands: 'List[TrafficDemand]', default_policy_preset: 'FlowPolicyPreset' = ) -> 'DemandExpansion' +### expand_demands(network: 'Network', traffic_demands: 'List[TrafficDemand]') -> 'DemandExpansion' Expand TrafficDemand specifications into concrete demands with augmentations. -Pure function that: +Resolves each demand's selectors to node groups, splits its volume by +mode and group_mode, and generates pseudo-node augmentation edges for +combine mode. Inputs are not modified. -1. Normalizes and evaluates selectors to get node groups -2. Distributes volume based on mode (combine/pairwise) and group_mode -3. Generates augmentation edges for combine mode (pseudo nodes) -4. Returns demands (node names) + augmentations +Demands carry node names, not IDs, so expansion runs before the graph is +built; IDs are resolved once the graph includes the augmentations. -Node names are used (not IDs) so expansion happens BEFORE graph building. -IDs are resolved after graph is built with augmentations. - -Note: Variable expansion (expand: block) is handled during YAML parsing in -build_demand_set(), so TrafficDemand objects here are already expanded. +Variable expansion (`expand:` blocks) happens earlier, during YAML parsing +in build_demand_set(), so the TrafficDemand objects here are already +expanded. Args: network: Network for node selection. traffic_demands: High-level demand specifications. - default_policy_preset: Default policy if demand doesn't specify one. Returns: DemandExpansion with demands and augmentations. @@ -3079,15 +3040,15 @@ Performance characteristics: Time complexity: O(S + I * A / P), where S is one-time graph setup cost, I is iteration count, A is per-iteration analysis cost, and P is parallelism. Graph caching amortizes graph construction across all iterations: each -iteration applies its exclusions as an O(|excluded|) mask update instead of -rebuilding the graph or re-scanning all O(V+E) nodes and edges. +iteration applies its exclusions as boolean masks (a vectorized O(V+E) fill +plus O(|excluded|) updates) instead of rebuilding the graph. Space complexity: O(V + E + I * R), where V and E are node and link counts, and R is result size per iteration. The pre-built graph is shared across all iterations. -Parallelism: The C++ Core backend releases the GIL during computation, -enabling true parallelism with Python threads. With graph caching, most +Parallelism: The C++ Core backend releases the GIL during computation, so +Python threads run Core work in parallel. With graph caching, most per-iteration work runs in GIL-free C++ code; speedup depends on workload and parallelism level. @@ -3117,33 +3078,27 @@ Attributes: **Methods:** - `compute_exclusions(self, policy: "'FailurePolicy | None'" = None, seed_offset: 'int | None' = None, failure_trace: 'Optional[Dict[str, Any]]' = None) -> 'tuple[set[str], set[str]]'` - Compute set of nodes and links to exclude for a failure iteration. -- `get_failure_policy(self) -> "'FailurePolicy | None'"` - Get failure policy for analysis. -- `run_demand_placement_monte_carlo(self, demands_config: 'list[dict[str, Any]] | Any', iterations: 'int' = 100, parallelism: 'int' = 1, seed: 'int | None' = None, store_failure_patterns: 'bool' = False, include_flow_details: 'bool' = False, include_used_edges: 'bool' = False) -> 'Any'` - Analyze traffic demand placement success under failures. -- `run_max_flow_monte_carlo(self, source: 'str | dict[str, Any]', target: 'str | dict[str, Any]', mode: 'str' = 'combine', iterations: 'int' = 100, parallelism: 'int' = 1, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement | str' = , seed: 'int | None' = None, store_failure_patterns: 'bool' = False, include_flow_summary: 'bool' = False, include_min_cut: 'bool' = False) -> 'Any'` - Compute max-flow capacity envelopes between node groups under failures. +- `get_failure_policy(self) -> "'FailurePolicy | None'"` - Return the failure policy named by policy_name, if any. +- `run_demand_placement_monte_carlo(self, demands_config: 'list[dict[str, Any]] | DemandSet', iterations: 'int' = 100, parallelism: 'int' = 1, seed: 'int | None' = None, store_failure_patterns: 'bool' = False, include_flow_details: 'bool' = False, include_used_edges: 'bool' = False) -> 'Any'` - Analyze traffic demand placement success under failures. +- `run_max_flow_monte_carlo(self, source: 'str | dict[str, Any]', target: 'str | dict[str, Any]', mode: 'str' = 'combine', iterations: 'int' = 100, parallelism: 'int' = 1, shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement | str' = , seed: 'int | None' = None, store_failure_patterns: 'bool' = False, include_flow_details: 'bool' = False, include_min_cut: 'bool' = False) -> 'Any'` - Compute max-flow capacity envelopes between node groups under failures. - `run_monte_carlo_analysis(self, analysis_func: 'AnalysisFunction', iterations: 'int' = 1, parallelism: 'int' = 1, seed: 'int | None' = None, store_failure_patterns: 'bool' = False, **analysis_kwargs) -> 'dict[str, Any]'` - Run Monte Carlo failure analysis with any analysis function. - `run_sensitivity_monte_carlo(self, source: 'str | dict[str, Any]', target: 'str | dict[str, Any]', mode: 'str' = 'combine', iterations: 'int' = 100, parallelism: 'int' = 1, shortest_path: 'bool' = False, flow_placement: 'FlowPlacement | str' = , seed: 'int | None' = None, store_failure_patterns: 'bool' = False) -> 'dict[str, Any]'` - Analyze component criticality for flow capacity under failures. -- `run_single_failure_scenario(self, analysis_func: 'AnalysisFunction', **kwargs) -> 'Any'` - Run one failure iteration, for quick analysis or debugging. --- ## ngraph.analysis.functions -Flow analysis functions for network evaluation. - -These functions are designed for use with FailureManager. Each analysis function -takes a Network, exclusion sets, and analysis-specific parameters, returning -results of type FlowIterationResult. +Flow analysis functions for FailureManager. -Parameters should ideally be hashable so FailureManager can deduplicate -identical failure patterns before dispatch; non-hashable objects are keyed -by memory address. +Each function takes a Network, exclusion sets, and analysis-specific +parameters, and returns a FlowIterationResult. -Graph caching builds the graph once and applies each exclusion set as an -O(|excluded|) mask instead of rebuilding. +Graph caching builds the graph once and applies each exclusion set as a +boolean mask (a vectorized array fill plus O(|excluded|) updates) instead of +rebuilding. -SPF caching computes shortest paths once per unique source node rather than -once per demand. For networks with many demands sharing the same sources, this -can reduce SPF computations by an order of magnitude. +SPF caching computes the base shortest-path DAG once per unique source node +rather than once per demand, so SPF runs scale with unique sources. ### build_demand_placement_inputs(network: "'Network'", demands_config: 'list[dict[str, Any]]') -> 'tuple[AnalysisContext, DemandExpansion, list[tuple[int, int]]]' @@ -3156,7 +3111,7 @@ expansion and context together guarantees that pseudo node names (derived from demand ids) match the context's graph. Args: - network: Network instance. + network: Network to analyze. demands_config: List of demand configurations (same format as demand_placement_analysis). @@ -3169,10 +3124,10 @@ Returns: Build an AnalysisContext for repeated max-flow analysis. Pre-computes the graph with pseudo source/target nodes for all source/target -pairs, enabling O(|excluded|) mask building per iteration. +pairs, so each iteration only builds exclusion masks. Args: - network: Network instance. + network: Network to analyze. source: Source node selector (string path or selector dict). target: Target node selector (string path or selector dict). mode: Flow analysis mode ("combine" or "pairwise"). @@ -3212,39 +3167,38 @@ Steps: ``include_flow_details`` a lossy demand's entry carries ``data["dropped_edges"]``, the dropped volume per ``link_id:direction``. -SPF Caching Optimization: - For cacheable policies (ECMP, WCMP, TE_WCMP_UNLIM), SPF results are - cached by source node. This reduces SPF computations from O(demands) - to O(unique_sources), typically a 5-10x reduction for workloads with - many demands sharing the same sources. +SPF Caching: + For cacheable presets (the hop-by-hop ``SHORTEST_PATHS_*`` presets and + ``TE_WCMP_UNLIM``), base SPF DAGs are cached by source node, which + cuts SPF runs from O(demands) to O(unique_sources). Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. demands_config: List of demand configurations (serializable dicts). include_flow_details: When True, include cost_distribution per flow. include_used_edges: When True, include set of used edges per demand in entry data. context: Pre-built AnalysisContext, reused across calls. Must be built - from this same demands_config - pseudo node names embed demand + from this same demands_config: pseudo node names embed demand ids, so a context built from a different config raises ValueError during endpoint resolution. See build_demand_placement_inputs. expansion: Pre-computed DemandExpansion matching demands_config. When provided, per-call demand reconstruction and expansion are skipped. Must be built together with ``context`` (pseudo node names embed - demand ids) - see build_demand_placement_inputs. + demand ids); see build_demand_placement_inputs. resolved_ids: Pre-resolved (src_id, dst_id) pairs aligned with expansion.demands. Only valid together with ``context``. Returns: - FlowIterationResult describing this iteration. + FlowIterationResult with one entry per expanded demand. ### max_flow_analysis(network: "'Network'", excluded_nodes: 'Set[str]', excluded_links: 'Set[str]', source: 'str | dict[str, Any]', target: 'str | dict[str, Any]', mode: 'str' = 'combine', shortest_path: 'bool' = False, require_capacity: 'bool' = True, flow_placement: 'FlowPlacement' = , include_flow_details: 'bool' = False, include_min_cut: 'bool' = False, context: 'Optional[AnalysisContext]' = None) -> 'FlowIterationResult' Analyze maximum flow capacity between node groups. Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. source: Source node selector (string path or selector dict). @@ -3253,15 +3207,21 @@ Args: shortest_path: If True, use single-tier shortest-path flow (IP/IGP mode) instead of full iterative max-flow. require_capacity: If True (default), path selection considers available - capacity. If False, path selection is cost-only (true IP/IGP semantics). + capacity. If False, path selection is cost-only (IP/IGP semantics). flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). - include_flow_details: Whether to collect cost distribution and similar details. + include_flow_details: Whether to fill each entry's cost_distribution. include_min_cut: Whether to include min-cut edge list in entry data. - context: Pre-built AnalysisContext reused across calls. Must be - unbound or bound to these same source/target/mode arguments. + context: Pre-built AnalysisContext reused across calls, bound to + these same source/target/mode arguments (see + ``build_maxflow_context``). Returns: - FlowIterationResult describing this iteration. + FlowIterationResult with one entry per source/sink pair; demand and + placed both equal the max flow. + +Raises: + ValueError: If ``context`` is unbound or bound to different + source/target/mode arguments. ### sensitivity_analysis(network: "'Network'", excluded_nodes: 'Set[str]', excluded_links: 'Set[str]', source: 'str | dict[str, Any]', target: 'str | dict[str, Any]', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = , context: 'Optional[AnalysisContext]' = None) -> 'FlowIterationResult' @@ -3272,11 +3232,11 @@ caused by removing each one. Returns a FlowIterationResult where each FlowEntry represents a source/target pair with: - demand/placed = max flow value (the capacity being analyzed) -- dropped = 0.0 (baseline analysis, no failures applied) +- dropped = 0.0 (the max flow is reported as fully placed) - data["sensitivity"] = {link_id:direction: flow_reduction} for critical edges Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. source: Source node selector (string path or selector dict). @@ -3286,12 +3246,17 @@ Args: Reports only edges used under ECMP routing. If False (default), use full iterative max-flow (SDN/TE mode) and report all saturated edges. flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). - context: Pre-built AnalysisContext reused across calls. Must be - unbound or bound to these same source/target/mode arguments. + context: Pre-built AnalysisContext reused across calls, bound to + these same source/target/mode arguments (see + ``build_maxflow_context``). Returns: FlowIterationResult with sensitivity data in each FlowEntry.data. +Raises: + ValueError: If ``context`` is unbound or bound to different + source/target/mode arguments. + --- ## ngraph.analysis.placement @@ -3367,8 +3332,8 @@ is revisited, so within a priority class earlier demands win contended capacity and the totals of rerouting presets depend on demand order. Hop-by-hop presets (``HOP_BY_HOP_PRESETS``) place each demand in one pass -on the cost-only shortest-path DAG of its source. A combine-mode demand is -a virtual source, a pool of the selected sources: with such a preset +on the cost-only shortest-path DAG of its source. A combine-mode demand +pools the selected sources behind a virtual source: with such a preset (``ExpandedDemand.src_members`` set) every member that can reach a target originates an even share of the volume, since hop-by-hop routing has no controller that could choose where traffic originates, and each share is @@ -3478,8 +3443,8 @@ Example: Bidirectional mapping between internal edge IDs and original edge references. When converting a NetworkX graph, each edge is assigned an internal integer ID -(ext_edge_id). This class preserves the mapping for interpreting algorithm -results and updating the original graph. +(ext_edge_id). Keep the map to interpret algorithm results and write them +back to the original graph. Attributes: to_ref: Maps internal edge ID to original (source, target, key) tuple @@ -3507,8 +3472,7 @@ Bidirectional mapping between node names and integer indices. When converting a NetworkX graph to the internal representation, node names (which can be any hashable type) are mapped to contiguous integer indices -starting from 0. This class preserves the mapping for result interpretation -and back-conversion. +starting from 0. Keep the map to interpret results and convert back. Attributes: to_index: Maps original node names to integer indices @@ -3577,13 +3541,13 @@ Example: >>> edge_map.to_ref[0] # edge refs preserve original (u, v, key) ('src', 'dst', 0) -### to_networkx(graph: 'netgraph_core.StrictMultiDiGraph', node_map: 'Optional[NodeMap]' = None, *, capacity_attr: 'str' = 'capacity', cost_attr: 'str' = 'cost') -> "'nx.MultiDiGraph'" +### to_networkx(graph: 'netgraph_core.StrictMultiDiGraph', node_map: 'Optional[NodeMap]' = None, *, capacity_attr: 'str' = 'capacity', cost_attr: 'str' = 'cost') -> 'nx.MultiDiGraph' Convert ngraph's internal graph format back to NetworkX MultiDiGraph. -Reconstructs a NetworkX graph from the internal representation. If a -NodeMap is provided, original node names are restored; otherwise, nodes -are labeled with integer indices. +Each Core edge becomes one NetworkX edge carrying only capacity and cost, +so reverse arcs added by ``from_networkx(bidirectional=True)`` come back as +separate edges. Args: graph: netgraph_core.StrictMultiDiGraph to convert @@ -3595,6 +3559,9 @@ Args: Returns: nx.MultiDiGraph with edges and attributes from the internal graph +Raises: + KeyError: If ``node_map`` lacks an entry for a node index of ``graph`` + Example: >>> graph, node_map, edge_map = from_networkx(G) >>> # ... run algorithms ... @@ -3605,21 +3572,6 @@ Example: --- -## Error Handling - -NetGraph uses standard Python exceptions: - -- `ValueError` - For validation errors -- `KeyError` - For missing required fields -- `RuntimeError` - For runtime errors - -For complete method signatures and detailed documentation, use Python's help system: - -```python -help(ngraph.scenario.Scenario) -help(ngraph.network.Network.max_flow) -``` - ---- +## Errors -*This documentation was auto-generated from the NetGraph source code.* +Invalid input mostly raises `ValueError`; a scenario that fails schema validation raises `jsonschema.ValidationError`. Each entry's Raises section lists its cases. diff --git a/docs/reference/api.md b/docs/reference/api.md index ac6eefc..9f8f707 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -2,17 +2,17 @@ Quick links: -- [Design](design.md) -- architecture, model, algorithms, workflow -- [DSL Reference](dsl.md) -- YAML syntax for scenario definition -- [Workflow Reference](workflow.md) -- analysis workflow configuration and execution -- [CLI Reference](cli.md) -- command-line tools for running scenarios -- [Auto-Generated API Reference](api-full.md) -- complete class and method documentation +- [Design](design.md) - architecture, model, algorithms, workflow +- [DSL Reference](dsl.md) - YAML syntax for scenario definition +- [Workflow Reference](workflow.md) - analysis workflow configuration and execution +- [CLI Reference](cli.md) - command-line tools for running scenarios +- [Auto-Generated API Reference](api-full.md) - complete class and method documentation -The Python API, organized by how it is typically used. +A guide to the Python API. Every public name is listed in the [Auto-Generated API Reference](api-full.md). ## 1. Programmatic Quickstart -Minimal, copy-pastable start: build a tiny network, run max-flow, and reuse a bound context. +Build a three-node network, run max-flow, and reuse a bound context: ```python from ngraph import Network, Node, Link, analyze, Mode @@ -45,7 +45,7 @@ print("baseline", baseline, "degraded", degraded) ## 2. Fundamentals -The three types most NetGraph programs are built from. +Scenario, Network and Results, and how to turn on logging. ### Scenario @@ -67,12 +67,12 @@ exported = scenario.results.to_dict() print(exported["workflow"].keys()) ``` -**Key Methods:** +**Methods:** - `from_yaml(yaml_str, default_components=None)` - Parse scenario from YAML string (use `Path.read_text()` for file loading) - `run(step_hook=None)` - Execute workflow steps in sequence. The optional `step_hook` is a callable that receives each `WorkflowStep` and returns a context manager entered around that step's execution (used by the CLI for per-step profiling) -Network, workflow, and Results can also be used independently of Scenario for direct programmatic access. +Network and Results also work on their own, without a Scenario. ### Network @@ -92,12 +92,12 @@ flow_result = analyze(network).max_flow("^n1$", "^n2$") print(flow_result) # {("^n1$", "^n2$"): 100.0} ``` -**Key Methods:** +**Methods:** - `add_node(node)`, `add_link(link)` - Build topology programmatically - `nodes`, `links` - Access topology as dictionaries -**Key Concepts:** +**Concepts:** - **disabled flags:** Node.disabled and Link.disabled mark components as inactive in the scenario topology (use `excluded_nodes`/`excluded_links` parameters for temporary analysis-time exclusion) - **Risk Groups:** Nodes and links can be tagged with risk group names (e.g., "rack1", "fiber_bundle") to model shared failure domains. @@ -105,7 +105,7 @@ print(flow_result) # {("^n1$", "^n2$"): 100.0} ### Results -Holds each workflow step's output, with metadata, under that step's name; every step writes here. Managed by Scenario - access it via `scenario.results` to read results or to write from a custom step. +Holds each step's `metadata` and `data` under the step name. Read it as `scenario.results`; a custom step writes to the same object. ```python # Access results from scenario @@ -116,7 +116,7 @@ all_data = results.to_dict() print(list(all_data["steps"].keys())) ``` -**Key Methods:** +**Methods:** - `enter_step(step_name)` / `exit_step()` - Scope writes to a step (managed by WorkflowStep.execute()) - `put(key, value)` - Store value under active step; key must be `"metadata"` or `"data"` @@ -124,9 +124,26 @@ print(list(all_data["steps"].keys())) - `get_step(step_name)` - Retrieve complete step dict for cross-step reads - `to_dict()` - Export results with shape `{workflow, steps, scenario}` (JSON-serializable) +### Logging + +Importing `ngraph` attaches only a `NullHandler` to the `ngraph` logger; nothing is printed unless the application opts in. + +```python +import logging +from ngraph.logging import set_global_log_level + +set_global_log_level(logging.DEBUG) # installs a stderr handler on first call +``` + +**Functions:** + +- `set_global_log_level(level)` - Set the level on the `ngraph` logger and its handlers, installing the console handler if none is configured +- `setup_root_logger(level=logging.INFO, format_string=None, handler=None)` - Install a single handler explicitly; later calls are no-ops until `reset_logging()` +- `get_logger(name)` - Logger for a module under the `ngraph` hierarchy + ## 3. NetworkX Integration -Convert between NetworkX graphs and the internal graph format for algorithm execution. +Convert between NetworkX graphs and the Core graph format. ### Converting from NetworkX @@ -156,7 +173,7 @@ dists, _ = algorithms.spf(handle, src=src_idx, dst=dst_idx) print(f"Shortest path cost A->C: {dists[dst_idx]}") # 15.0 (via B) ``` -**Key Functions:** +**Functions:** - `from_networkx(G, *, capacity_attr, cost_attr, default_capacity, default_cost, bidirectional)` - Convert NetworkX graph to internal format - `to_networkx(graph, node_map, *, capacity_attr, cost_attr)` - Convert back to NetworkX MultiDiGraph @@ -174,7 +191,7 @@ print(f"Shortest path cost A->C: {dists[dst_idx]}") # 15.0 (via B) - `bidirectional` - Direction handling. `None` (default) infers from the graph type: directed inputs get one arc per edge; undirected inputs get antiparallel arc pairs (the standard undirected-to-directed reduction for max-flow/reachability). Pass an explicit `True`/`False` to override. - `capacity_attr` / `cost_attr` - Custom attribute names for capacity and cost -- `default_capacity` / `default_cost` - Default values when attributes missing. Cost values must be integers โ€” `from_networkx` raises `ValueError` on fractional costs because the core engine requires int64 costs; pre-scale fractional costs (e.g., multiply by 10 or 100) before conversion. +- `default_capacity` / `default_cost` - Default values when attributes missing. Cost values must be integers: `from_networkx` raises `ValueError` on fractional costs because the core engine requires int64 costs. Scale fractional costs (by 10 or 100, say) before conversion. ### Writing Results Back @@ -199,11 +216,7 @@ Max-flow, shortest paths, and edge sensitivity. ### Flow Analysis with `analyze()` -**Purpose:** Calculate network flows between source and sink groups. - -**When to use:** Measuring capacity between source and sink groups, under a choice of flow placement policy and with nodes or links excluded to model failures. - -Max-flow runs in C++ with the GIL released. The algorithm and its complexity bounds are described in [Design](design.md). +Maximum flow between source and sink groups, under a chosen placement policy and with nodes or links excluded to model failures. It runs in C++ with the GIL released; the algorithm and its complexity bounds are in [Design](design.md). ```python from ngraph import analyze, Mode, FlowPlacement @@ -225,17 +238,17 @@ result = analyze(network).max_flow_detailed( print(summary.cost_distribution) # Dict[float, float] mapping cost to flow volume ``` -**Key Functions:** +**Functions:** -- `analyze(network, *, source=None, sink=None, mode=Mode.COMBINE)` - Create analysis context +- `analyze(network, *, source=None, sink=None, mode=None, augmentations=None)` - Create an `AnalysisContext`; `mode` (default COMBINE) applies to a context bound with `source` and `sink`, which then rejects per-call `source`, `sink` and `mode`; `mode` without `source` and `sink` raises `ValueError` - `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 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 +- `ctx.shortest_path_cost(source, sink, *, mode, excluded_nodes, excluded_links)` - Shortest path cost - `ctx.shortest_paths(source, sink, *, mode, edge_select, split_parallel_edges)` - Full Path objects -**Key Concepts:** +**Concepts:** - **Mode.COMBINE:** Aggregate sources into one super-source, sinks into one super-sink; returns single total flow - **Mode.PAIRWISE:** Compute flow for each (source_group, sink_group) pair independently @@ -246,7 +259,7 @@ print(summary.cost_distribution) # Dict[float, float] mapping cost to flow volu - **require_capacity=True:** Path selection considers available capacity; flow moves to next-cheapest paths as cheaper ones saturate (default) - **require_capacity=False:** Path selection is cost-only; saturated paths are not bypassed (true IP/IGP semantics; pair with shortest_path=True for IP simulation) -### Efficient Repeated Analysis (Bound Context) +### Repeated Analysis with a Bound Context Bind source and sink groups once, then reuse the context across many calls: @@ -265,11 +278,7 @@ for failed_links in failure_scenarios: print(f"Capacity with {failed_links}: {degraded}") ``` -**Benefits of Bound Context:** - -- Graph infrastructure built once at context creation -- Each analysis call rebuilds only the node and edge masks โ€” a full-length array fill (Theta(V) for nodes, Theta(E) for edges) plus O(|excluded| + |disabled|) updates โ€” instead of rebuilding the Core graph -- Thread-safe: can run concurrent analysis calls with different exclusions +A bound context builds the Core graph once. Each call then rebuilds only the node and edge masks (a full-length array fill plus O(|excluded| + |disabled|) updates), and concurrent calls with different exclusions are safe. **Unbound vs. bound construction:** Unbound flow calls (`max_flow`, `max_flow_detailed`, `sensitivity`) construct a full temporary bound context per call, so repeated analysis should use a bound context. A plain unbound context builds its Core graph lazily on first use; bound contexts (and contexts with augmentations) build eagerly at creation. @@ -303,15 +312,15 @@ k_paths = analyze(network).k_shortest_paths( ) ``` -**Key Functions:** +**Functions:** -- `ctx.shortest_path_cost(source, sink, *, mode, edge_select=ALL_MIN_COST)` - Cost only, no path objects +- `ctx.shortest_path_cost(source, sink, *, mode)` - Cost only, no path objects - `ctx.shortest_paths(source, sink, *, mode, edge_select=ALL_MIN_COST, split_parallel_edges=False)` - Full Path objects - `ctx.k_shortest_paths(source, sink, *, mode=PAIRWISE, max_k=3, max_path_cost, max_path_cost_factor, excluded_nodes, excluded_links)` - Multiple paths per pair ### Sensitivity Analysis -Identify critical edges and quantify their impact: +Which edges limit the flow, and by how much: ```python from ngraph import analyze, Mode @@ -332,12 +341,10 @@ for pair, edge_impacts in sensitivity.items(): ## 5. Monte Carlo Analysis -Probabilistic failure analysis using FailureManager. +`FailureManager` samples failures from a policy, runs an analysis function under each sample, and aggregates the iterations. ### FailureManager -**Purpose:** Execute Monte Carlo failure scenarios and aggregate results across multiple iterations. - ```python from ngraph import Network, Node, Link, FailureManager from ngraph.model.failure.policy import FailurePolicy, FailureMode, FailureRule @@ -382,7 +389,7 @@ for iter_result in results["results"]: print(f"Flow: {iter_result.summary.total_placed:.1f} (x{iter_result.occurrence_count})") ``` -**Key Methods:** +**Methods:** - `run_max_flow_monte_carlo(...)` - Max-flow capacity analysis under failures - `run_demand_placement_monte_carlo(...)` - Traffic demand placement under failures @@ -390,7 +397,7 @@ for iter_result in results["results"]: ## 6. Workflow Steps -Pre-built analysis steps for YAML-driven workflows. +The built-in steps as written in a scenario's `workflow` section. Parameters and outputs are in the [Workflow Reference](workflow.md). ### MaxFlow Step @@ -552,4 +559,4 @@ for pair, impacts in sensitivity.items(): Network, Scenario and the workflow steps are Python. Shortest paths, max-flow and k-shortest paths run in C++ (NetGraph-Core) with the GIL released. Public APIs take and return Python types; the C++ layer is only reached through `netgraph_core` when you call it yourself, as in the NetworkX section above. -Threads help only when an iteration spends its time inside the C++ engine (max-flow, the LSP presets). Demand placement for the hop-by-hop presets is Python-bound between short engine calls, which is why `TrafficMatrixPlacement` resolves `parallelism: auto` to 1 for those demand sets. +Threads help only when an iteration spends its time inside the C++ engine (max-flow, the LSP presets). Demand placement for the hop-by-hop presets and `TE_WCMP_UNLIM` is Python-bound between short engine calls, which is why `TrafficMatrixPlacement` resolves `parallelism: auto` to 1 for those demand sets. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1b6ed52..edd0686 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2,25 +2,25 @@ Quick links: -- [Design](design.md) โ€” architecture, model, algorithms, workflow -- [DSL Reference](dsl.md) โ€” YAML syntax for scenario definition -- [Workflow Reference](workflow.md) โ€” analysis workflow configuration and execution -- [API Reference](api.md) โ€” Python API for programmatic scenario creation -- [Auto-Generated API Reference](api-full.md) โ€” complete class and method documentation +- [Design](design.md) - architecture, model, algorithms, workflow +- [DSL Reference](dsl.md) - YAML syntax for scenario definition +- [Workflow Reference](workflow.md) - analysis workflow configuration and execution +- [API Reference](api.md) - Python API for programmatic scenario creation +- [Auto-Generated API Reference](api-full.md) - complete class and method documentation -The `ngraph` command inspects, runs, and analyzes scenarios from the terminal. +The `ngraph` command inspects and runs scenarios from the terminal. ## Basic Usage Two commands: -- `inspect`: Analyze and validate scenario files without running them -- `run`: Execute scenario files and generate results +- `inspect`: Load and validate a scenario and print its structure without running the workflow +- `run`: Run the workflow and write results **Global options** (must be placed before the command): - `--verbose`, `-v`: Enable debug logging -- `--quiet`: Suppress console output (logs only) +- `--quiet`: Show only warnings and errors in logs (command output is still printed) ### Quick Start @@ -37,7 +37,7 @@ ngraph run scenarios/square_mesh.yaml ### `inspect` -Analyze and validate a NetGraph scenario file without executing it. +Load and validate a scenario and print its structure without running the workflow. **Syntax:** @@ -52,11 +52,10 @@ ngraph [--verbose|--quiet] inspect [options] **Options:** - `--detail`, `-d`: Show detailed information including complete node/link tables and step parameters -- `--output`, `-o`: Output directory for generated artifacts (accepted for CLI consistency; `inspect` itself writes no files) -**What it does:** +**Output:** -Loads and validates the scenario file, then reports: +After loading and validating the scenario file, `inspect` reports: - **Scenario metadata**: seed, and whether the run is reproducible - **Network structure**: node/link counts, enabled vs. disabled, hierarchy @@ -67,8 +66,6 @@ Loads and validates the scenario file, then reports: - **Demand sets**: demand patterns and volumes, capacity-vs-demand summary - **Workflow steps**: the steps that would run, in order -In detail mode (`--detail`), shows complete tables for all nodes and links with capacity and connectivity information. - **Examples:** ```bash @@ -84,7 +81,7 @@ ngraph --verbose inspect scenarios/square_mesh.yaml ### `run` -Execute a NetGraph scenario file. +Run the workflow of a scenario file and write the results. **Syntax:** @@ -101,10 +98,10 @@ ngraph [--verbose|--quiet] run [options] - `--results`, `-r`: Path to export results as JSON (default: `.results.json`; relative paths are placed under `--output` when provided) - `--no-results`: Disable results file generation - `--stdout`: Print results to stdout in addition to saving file. Log output, status banners, the `--profile` performance report, and run error messages all go to stderr, so stdout contains only the JSON results (safe to pipe to `jq`) -- `--keys`, `-k`: Space-separated list of workflow step names to include in output -- `--profile`: Enable performance profiling with CPU analysis and bottleneck detection -- `--profile-memory`: Also track peak memory per step -- `--output`, `-o`: Output directory for generated artifacts +- `--keys`, `-k`: Space-separated list of workflow step names to include in output; an unknown name is an error +- `--profile`: Profile the run and print a per-step CPU report to stderr +- `--profile-memory`: Also track peak memory per step (requires `--profile`) +- `--output`, `-o`: Output directory for generated artifacts: the results file and, with `--profile`, the `.profiles` directory of worker profiles ## Examples @@ -126,7 +123,7 @@ ngraph run scenarios/nsfnet.yaml --no-results ### Filtering Results by Step Names -`--keys` restricts the `steps` section to the named workflow steps; the `workflow` metadata section still lists every step that ran: +`--keys` restricts the `steps` section to the named workflow steps; the `workflow` metadata section lists every step that ran. Unknown step names abort the run before any step executes: ```bash # Only include results from the MSD step @@ -139,21 +136,11 @@ ngraph run scenarios/backbone_clos.yml --keys network_statistics tm_placement -- ngraph run scenarios/backbone_clos.yml --keys network_statistics --stdout ``` -The `--keys` option filters by the `name` field of workflow steps defined in your scenario YAML file. For example, if your scenario has: - -```yaml -workflow: - - type: NetworkStats - name: network_statistics - - type: MaximumSupportedDemand - name: msd_baseline -``` - -Then `--keys network_statistics` will include only the results from the NetworkStats step, and `--keys msd_baseline` will include only the MaximumSupportedDemand results. +The names are the `name` fields of the steps in the scenario's `workflow` section. ### Performance Profiling -Enable performance profiling to identify bottlenecks and analyze execution time: +`--profile` reports where the run spends its time: ```bash # Run scenario with profiling @@ -162,7 +149,7 @@ ngraph run scenarios/backbone_clos.yml --profile # Combine profiling with results export ngraph run scenarios/backbone_clos.yml --profile --results analysis.json -# Profile specific workflow steps and track memory +# Track memory too, and export only the tm_placement step's results ngraph run scenarios/backbone_clos.yml --profile --profile-memory --keys tm_placement ``` @@ -197,12 +184,13 @@ The CLI outputs results as JSON with a fixed top-level shape: | `ngraph run scenario.yaml --stdout` | `.results.json` | yes | | `ngraph run scenario.yaml --results out.json --stdout` | `out.json` | yes | | `ngraph run scenario.yaml --no-results` | nothing | no | +| `ngraph run scenario.yaml --no-results --stdout` | nothing | yes | -Logs and status messages go to stderr in every case. +`run` writes logs and status messages to stderr in every case. Exit status is 0 on success, 1 when the scenario cannot be loaded or run, and 2 for invalid command-line arguments. ## Debugging Scenarios -`ngraph run` executes every workflow step in order. Inspect a scenario before running it, and use `--verbose` with `--detail` when blueprint expansion does not produce the nodes or links you expect: +Inspect a scenario before running it. When blueprint expansion does not produce the nodes or links you expect, add `--verbose` and `--detail`: ```bash ngraph inspect scenarios/square_mesh.yaml @@ -210,10 +198,4 @@ ngraph --verbose inspect scenarios/backbone_clos.yml --detail ngraph inspect scenarios/backbone_clos.yml --detail | grep -A 5 "WORKFLOW STEPS" ``` -`inspect` catches common issues: - -- Invalid YAML syntax -- Missing blueprint references -- Incorrect node/link patterns -- Workflow step configuration errors -- Risk group and policy definition problems +`inspect` reports YAML and schema errors, unknown blueprint or risk-group references and invalid workflow step parameters, and its node and demand tables show whether selectors matched what you expected. diff --git a/docs/reference/design.md b/docs/reference/design.md index 254bbf9..3c0f0f6 100644 --- a/docs/reference/design.md +++ b/docs/reference/design.md @@ -2,20 +2,20 @@ NetGraph's internal design: scenario DSL, data models, execution flow, algorithms, manager components, and result handling. -## Overview +## Architecture -NetGraph is a network scenario analysis engine using a **hybrid Python+C++ architecture**. It takes a scenario (defined in a YAML DSL) as input, builds a directed multigraph model of the network, and runs a configurable workflow of analysis steps (like traffic placement or max-flow capacity) to produce structured results. +NetGraph is a Python front end over a C++ engine. It takes a scenario written in a YAML DSL, builds a directed multigraph model of the network, and runs a workflow of analysis steps (traffic placement, max-flow capacity) that produce structured results. -### Architecture Layers +### Layers **Python Layer (NetGraph):** - CLI and API: Entry points to load scenarios and invoke analyses - Scenario DSL Parser: Validates and expands the YAML scenario into an internal model -- Domain Model: In-memory representation of nodes, links, risk groups, etc., with selection and grouping utilities -- Managers: Orchestrators for higher-level behaviors (demand expansion, failure enumeration) -- Workflow Engine: Composes steps into end-to-end analyses, storing outputs in a results store -- Results Store: Collects outputs and metadata from each step, enabling structured JSON export +- Domain Model: In-memory representation of nodes, links and risk groups, with selection and grouping utilities +- Managers: Demand expansion and failure sampling +- Workflow Engine: Runs the steps in order +- Results Store: Per-step outputs and metadata, exported as JSON - Analysis bridge: `AnalysisContext` builds Core graphs from the model, manages name/ID mapping, and executes Core algorithms - NetworkExplorer: Network hierarchy traversal and hardware cost/power aggregation @@ -56,12 +56,12 @@ types, utils -> model -> dsl -> analysis -> workflow -> scenario -> Two deliberate exceptions: -- `model` may use the dependency-free string-expansion helpers in `ngraph.dsl.expansion` (bracket patterns in risk-group references and demand `expand:` blocks). No other `model -> dsl` import is allowed; selector schema types and evaluation live in `ngraph.model.selectors`, and `ngraph.dsl.selectors` re-exports them for backward compatibility. Enforced by `tests/model/test_layering.py`. +- `model` may use the dependency-free string-expansion helpers in `ngraph.dsl.expansion` (bracket patterns in risk-group references and demand `expand:` blocks). No other `model -> dsl` import is allowed; selector schema types and evaluation live in `ngraph.model.selectors`. Enforced by `tests/model/test_layering.py`. - `workflow` references `Scenario` only under `TYPE_CHECKING` (workflow steps execute against a `Scenario`); the runtime import goes downward, from `scenario` to `workflow`. The package root also does not eagerly import `ngraph.cli` (enforced by `tests/cli/test_package_layering.py`); the console entry point and `python -m ngraph` import it explicitly. -Deferred (function-local) imports are used for optional dependencies (networkx, jsonschema), opt-in profiling, and a few narrow internal cases (`ngraph.model.failure.parser` defers `ngraph.dsl.expansion`; `FailureManager._process_sensitivity_results` defers `ngraph.results.flow`) โ€” not as a general layering workaround; outside those cases, if a module needs a lower layer, it imports it at module level. +Deferred (function-local) imports are used only for opt-in profiling and one narrow internal case (`ngraph.model.failure.parser` defers `ngraph.dsl.expansion`), not as a general layering workaround; if a module needs a lower layer, it imports it at module level. ### Integration Points @@ -92,7 +92,7 @@ degraded = ctx.max_flow(excluded_links=failed_links) ### Execution Flow -The diagram below traces a scenario from input through both layers to final results: the Python layer loads the scenario, orchestrates the workflow, and aggregates results, while compute-intensive graph algorithms execute in C++ with the GIL released. +From scenario file to results, across both layers: ![NetGraph execution flow](../assets/diagrams/system_pipeline.dot.svg) @@ -100,29 +100,29 @@ The diagram below traces a scenario from input through both layers to final resu NetGraph scenarios are defined in YAML using a declarative DSL (see [DSL Reference](dsl.md)) covering network topologies, traffic demands, failure policies, and analysis workflows. Before execution, scenario files are validated against a JSON Schema, so unknown keys and type mismatches fail early. -Key elements of the DSL include: +Elements of the DSL: -- **Seed**: A master random seed for the scenario to ensure deterministic behavior across runs. +- **Seed**: A master random seed from which every random draw in the scenario derives, so runs are reproducible. -- **Blueprints**: Reusable templates for subsets of the topology. A blueprint defines internal node types, roles, and optional internal links. Blueprints enable defining a complex multi-node topology once and instantiating it multiple times with different parameters. +- **Blueprints**: Reusable templates for parts of the topology. A blueprint defines node groups and the links between them, and is instantiated many times with different parameters. - **Node Groups**: Definitions of node groups in the topology, either explicitly or via patterns. Groups can use a blueprint (`blueprint`) with parameters (`params`), or define a number of nodes (`count`) with a naming template (`template`). -- **Links**: Rules to generate links between node groups. Instead of enumerating every link, a link rule specifies source and target selectors (by path pattern), a wiring pattern (e.g. mesh for full mesh or one_to_one for paired links), number of parallel links (`count`), and link properties (capacity, cost, attributes like distance, hardware, risk group tags, etc.). Link properties are specified at the top level, not inside a wrapper. Matching can also filter nodes by attributes with logical conditions (AND/OR) so a rule applies to selected nodes only. A single rule can thus expand into many concrete links. +- **Links**: Rules to generate links between node groups. Instead of enumerating every link, a link rule specifies source and target selectors (by path pattern), a wiring pattern (e.g. mesh for full mesh or one_to_one for paired links), number of parallel links (`count`), and link properties (capacity, cost, attributes such as distance or hardware, risk group tags). Matching can also filter nodes by attribute conditions (AND/OR) so a rule applies to selected nodes only. A single rule can thus expand into many concrete links. -- **Rules**: Optional modifications applied after the initial expansion. `node_rules` or `link_rules` can match specific nodes or links (by path or endpoints) and change their attributes or disable them. This allows fine-tuning or simulating removals without changing the base definitions. +- **Rules**: Optional modifications applied after the initial expansion. `node_rules` or `link_rules` can match specific nodes or links (by path or endpoints) and change their attributes or disable them, without editing the definitions that generated them. - **Risk Groups**: Named shared-risk groups (potentially nested) that nodes or links can belong to. These are used in failure scenarios to correlate failures (e.g. all links in a risk group fail together). -- **Demands**: Traffic demand definitions specifying source node sets, target node sets (by regex or attribute path selectors), and volume. Each demand can also include priority or custom flow placement policy. +- **Demands**: Traffic demand definitions specifying source node sets, target node sets (by regex or attribute path selectors), and volume. Each demand can also set a priority and a flow policy preset. -- **Failure Policies**: Definitions of failure scenarios or modes, possibly with weights (probabilities). For example, a policy might say "with 5% chance, fail any single core node" or "fail all links in risk_group X". The failure manager uses these policies to generate specific failure combinations for simulation. +- **Failure Policies**: Weighted modes, each a set of rules selecting nodes, links or risk groups to fail. One mode is drawn per iteration in proportion to its weight, and the failure manager turns the drawn rules into concrete exclusion sets. -- **Workflow**: An ordered list of analysis steps to execute. Each step has a `type` (the analysis to perform, such as "MaxFlow" or "TrafficMatrixPlacement"), a unique name, and parameters (like number of iterations, etc.). The workflow definition orchestrates the analysis pipeline. +- **Workflow**: An ordered list of analysis steps to execute. Each step has a `type` (the analysis to perform, such as "MaxFlow" or "TrafficMatrixPlacement"), a unique name, and parameters such as the number of iterations. ### DSL Expansion Process -The loader validates and expands DSL definitions into concrete nodes and links. Unknown fields or schema violations cause an immediate error before any expansion. After schema validation, blueprints are resolved (each blueprint group becomes actual Node objects), group name patterns are expanded into individual names, and adjacency rules are iterated over matching source-target node sets to create Link objects. The resulting nodes and links are then checked at runtime for duplicate node names and missing link endpoints. +The loader validates and expands DSL definitions into concrete nodes and links. Unknown fields or schema violations cause an immediate error before any expansion. After schema validation, blueprints are resolved (each blueprint group becomes actual Node objects), group name patterns are expanded into individual names, and link definitions are iterated over matching source-target node sets to create Link objects. The resulting nodes and links are then checked at runtime for duplicate node names and missing link endpoints. ## Data Model @@ -148,7 +148,7 @@ A Link represents a directed link between a source and target node. Each link ha - capacity (float, e.g. in some bandwidth unit), -- cost (float, e.g. distance or latency metric), +- cost (float holding a non-negative integer value, e.g. distance or latency metric), - disabled flag, @@ -158,7 +158,7 @@ A Link represents a directed link between a source and target node. Each link ha - a unique id assigned when the link is added to a Network -The id is deterministic: `Network.add_link` assigns "source|target|", where is a per-(source, target) insertion sequence number, so ids and their sort order are stable across identical scenario builds (a provisional uuid-suffixed id exists only on links never added to a Network). The model stores each link as directed (source -> target). When the analysis graph is built, a reverse edge is added by default so algorithms see bidirectional connectivity. +The id is deterministic: `Network.add_link` assigns "source|target|", where is a per-(source, target) insertion sequence number, so ids and their sort order are stable across identical scenario builds (a provisional uuid-suffixed id exists only on links never added to a Network). The model stores each link as directed (source -> target). When the analysis graph is built, each link also gets a reverse edge, so algorithms see both directions. ### RiskGroup @@ -188,7 +188,7 @@ Network enforces invariants during construction: adding a link validates that so ### Node and Link Selection -A single selector system picks groups of nodes by structured name or by attribute; algorithms use it to choose source/sink sets. Selector evaluation (schema types, condition evaluation, node selection, attribute flattening) lives in `ngraph.model.selectors`; `ngraph.dsl.selectors` provides YAML-facing parsing and re-exports the evaluation names for backward compatibility. +A single selector system picks groups of nodes by structured name or by attribute; algorithms use it to choose source/sink sets. Selector evaluation (schema types, condition evaluation, node selection, attribute flattening) lives in `ngraph.model.selectors`; `ngraph.dsl.selectors` provides YAML-facing parsing (`normalize_selector`). **Selector Forms:** @@ -230,11 +230,11 @@ Workflow steps and API calls therefore refer to nodes by readable pattern instea ### Disabled Elements -Nodes or links marked as disabled=True represent elements present in the design but out of service for the analysis. The base model keeps them in the collection but analysis functions filter them out when selecting active nodes. This preserves topology information โ€” the link still exists, it is just turned off โ€” and re-enabling it is a flag change. +Nodes or links marked as disabled=True represent elements present in the design but out of service for the analysis. The base model keeps them in the collection but analysis functions filter them out when selecting active nodes. The link still exists, it is just turned off, and re-enabling it is a flag change. ### Filtered Analysis (Exclusions) -To simulate failures or other what-if scenarios without modifying the base network, NetGraph uses analysis-time exclusions. Instead of creating a stateful view object, you pass sets of excluded nodes and links directly to analysis functions. +Failures and other what-if cases are expressed as sets of excluded nodes and links passed to analysis functions; the base network is never modified. ```python # Analyze with specific exclusions @@ -242,15 +242,13 @@ results = analyze(network).max_flow( "^A$", "^B$", excluded_nodes={"Node5"}, - excluded_links={"A|B|xyz123"} + excluded_links={"A|B|0"} ) ``` -This approach avoids mutating the base graph when simulating failures (e.g., deleting nodes or toggling flags). It separates the static scenario (base network) from dynamic conditions (exclusions), enabling thread-safe parallel analyses and eliminating deep copies for each failure scenario. +Because the base network stays static, analyses with different exclusion sets can run in parallel threads, and no failure scenario needs a copy of the network. -**Implementation:** Exclusions are applied via boolean masks passed to Core algorithms. The graph is built once without exclusions, and masks disable specific elements at algorithm execution time. For repeated analysis (Monte Carlo, FailureManager) this enables O(|excluded|) mask updates rather than O(V+E) graph rebuilding. One-off calls on an unbound context build a temporary bound context per call and apply exclusions the same way. - -Multiple concurrent analyses can run on the same base network with different exclusion sets, which is what makes parallel Monte Carlo over many failure combinations practical. +**Implementation:** Exclusions are applied via boolean masks passed to Core algorithms. The graph is built once without exclusions, and masks disable specific elements at algorithm execution time. For repeated analysis (Monte Carlo, FailureManager) each iteration builds masks (a vectorized array fill plus O(|excluded|) updates) instead of rebuilding the graph. One-off calls on an unbound context build a temporary bound context per call and apply exclusions the same way. ### Graph Construction @@ -268,7 +266,7 @@ There is one construction method: - Assigns stable node IDs (sorted by name for determinism) - Encodes link_id + direction as ext_edge_id (packed int64) - Constructs NumPy arrays (src, dst, capacity, cost, ext_edge_ids) -- Validates inputs: link capacities must be below the internal pseudo-edge capacity `LARGE_CAPACITY` (1e15), and costs must be non-negative integers whose total across all edges stays below 2^62. Core SPF accumulates path costs in int64 with INT64_MAX as the unreachable sentinel, so accumulated path costs โ€” not just per-edge values โ€” must stay in range; bounding the total of all edge costs bounds every path. Violations raise `ValueError` instead of silently corrupting results +- Validates inputs: link capacities must be below the internal pseudo-edge capacity `LARGE_CAPACITY` (1e15), and costs must be non-negative integers whose total across all edges stays below 2^62. Core SPF accumulates path costs in int64 with INT64_MAX as the unreachable sentinel, so accumulated path costs, not just per-edge values, must stay in range; bounding the total of all edge costs bounds every path. Violations raise `ValueError` instead of silently corrupting results - Supports augmentation edges (e.g., pseudo-source/sink for multi-source max-flow) **AnalysisContext Internals:** @@ -279,14 +277,10 @@ There is one construction method: - `_node_mapper`, `_edge_mapper`: Name โ†” ID translation - `_algorithms`: Core Algorithms instance - `_disabled_node_ids`, `_disabled_link_ids`: Pre-computed disabled topology -- `_link_id_to_edge_indices`: Pre-computed mapping for O(|excluded|) mask building +- `_link_id_to_edge_indices`: Pre-computed link-to-edge mapping for mask building - `_pseudo_context`: Optional context for pseudo source/sink node mappings -When analyzing many failure scenarios, the graph is built once via `AnalysisContext.from_network()` and exclusions are applied via boolean masks. The public mask builders (`build_node_mask`/`build_edge_mask`, also usable by custom analysis functions that call Core primitives directly) automatically include disabled nodes/links, ensuring disabled topology is always excluded. Nothing is rebuilt per iteration, which is where the Monte Carlo speedup comes from. - -**Disabled Topology Handling:** - -Disabled nodes and links from the Network are pre-computed during `AnalysisContext.from_network()` and stored in the context. The mask builders automatically include these disabled elements alongside any per-iteration exclusions. +Disabled nodes and links are computed once in `AnalysisContext.from_network()`. The public mask builders (`build_node_mask`/`build_edge_mask`, also usable by custom analysis functions that call Core primitives directly) add them to every mask, so disabled topology is always excluded. **C++ Side (`netgraph_core.StrictMultiDiGraph`):** @@ -299,13 +293,12 @@ Disabled nodes and links from the Network are pre-computed during `AnalysisConte **Edge Direction Handling:** -If `add_reverse=True` (default), graph construction creates bidirectional edges for each network link: +Graph construction creates two directed edges for each network link: - Forward edge: original link direction with ext_edge_id encoding (link_id, 'fwd') - Reverse edge: opposite direction with ext_edge_id encoding (link_id, 'rev') -This allows algorithms to consider traffic flowing in both directions on physical links. -The Core graph itself is always directed; bidirectionality is achieved by explicit reverse edges. +The Core graph is directed; the reverse edges give each physical link both directions. **Augmentation Support:** @@ -315,9 +308,7 @@ are not mapped back to scenario links in results. ### Analysis Algorithms -NetGraph's core algorithms execute in C++ via NetGraph-Core. They operate on the immutable StrictMultiDiGraph and support masking (runtime exclusions via boolean arrays), so repeated analysis under different failure scenarios needs no graph reconstruction. - -All Core algorithms release the Python GIL while they run. +The algorithms below run in NetGraph-Core on the immutable StrictMultiDiGraph. They accept node and edge masks and release the GIL while they run. ### Shortest-Path First (SPF) Algorithm @@ -337,18 +328,18 @@ The algorithm evaluates parallel edges per neighbor using `EdgeSelection` config **Capacity-Aware Tie-Breaking:** -When multiple nodes or edges have equal cost, SPF uses residual capacity for tie-breaking to improve flow distribution: +When nodes or edges tie on cost, SPF breaks the tie by residual capacity: -- **Node-level**: Priority queue ordered by (cost, -residual, node). Among equal-cost nodes, prefers paths with higher bottleneck capacity. This naturally guides flow toward higher-capacity routes. +- **Node-level**: Priority queue ordered by (cost, -residual, node). Among equal-cost nodes, prefers paths with higher bottleneck capacity. - **Edge-level**: When `multi_edge=false` and `tie_break=PreferHigherResidual`, selects the parallel edge with most available capacity among equal-cost options. -This tie-breaking is applied even in IP/IGP mode (`require_capacity=false`) using static capacities, improving flow distribution without altering routing topology. +The tie-breaking also applies in IP/IGP mode (`require_capacity=false`), using static capacities; it does not change which paths are shortest. **Multipath Support:** -With `multipath=True`, SPF stores all minimal-cost predecessors forming a DAG: -`pred[node] = {predecessor: [edge_ids...]}`. This DAG captures all equal-cost paths -in a compact form, used by max-flow for flow splitting. +With `multipath=True`, SPF stores every minimal-cost predecessor, regardless of residual capacity, forming a DAG: +`pred[node] = {predecessor: [edge_ids...]}`. The DAG holds all equal-cost paths +and is what max-flow splits flow over. **Early Termination:** @@ -460,16 +451,6 @@ function tiebreak_edge(current_edges, new_edge, tie_break, new_residual): return [min(new_edge, current_edges[0])] ``` -**Key Tie-Breaking Mechanisms:** - -1. **Node-level tie-breaking**: When multiple nodes have equal cost in the priority queue, prefer nodes reachable via paths with higher bottleneck (residual) capacity. This naturally distributes flows across equal-cost paths based on available capacity. - -2. **Edge-level tie-breaking** (when `multi_edge=false`): - - `PreferHigherResidual`: Among parallel equal-cost edges (u,v), select the one with highest residual capacity - - `Deterministic`: Select edge with smallest ID for reproducible results - -3. **Multipath behavior**: When `multipath=true`, all equal-cost predecessors are retained without capacity-based filtering, enabling flow splitting across all equal-cost paths. - ### Maximum Flow Algorithm Implemented in C++ (`netgraph::core::max_flow`), using successive shortest paths with @@ -486,7 +467,7 @@ configurable flow splitting across equal-cost parallel edges. - `require_capacity=true` + `shortest_path=false` (SDN/TE): SPF filters to edges with residual capacity, routes adapt iteratively during placement - `require_capacity=false` + `shortest_path=true` (IP/IGP): SPF uses all edges based on cost, single-pass flow placement over fixed equal-cost paths -See "Routing Semantics: IP/IGP vs SDN/TE" section for detailed explanation. +See [Routing Semantics](#routing-semantics-ipigp-vs-sdnte) below. The residual network is maintained via `FlowState`, which tracks per-edge flow and computes residual capacities on demand. For each edge uโ†’v: @@ -495,7 +476,7 @@ The residual network is maintained via `FlowState`, which tracks per-edge flow a 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 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. +Reverse residual arcs are distinct from physical reverse edges added 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: @@ -523,15 +504,13 @@ After the loop, the C++ algorithm computes a FlowSummary which includes: - 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. - ### Routing Semantics: IP/IGP vs SDN/TE -NetGraph models two fundamentally different routing paradigms through the `require_capacity` and `shortest_path` parameters: +Two routing models are selected through `require_capacity` and `shortest_path`: **IP/IGP Semantics (`require_capacity=false` + `shortest_path=true`):** -Traditional IP routing with Interior Gateway Protocols (OSPF, IS-IS): +IP routing with an interior gateway protocol (OSPF, IS-IS): - Routes computed based on link costs/metrics only, ignoring available capacity - Single SPF computation determines equal-cost paths; forwarding is fixed until topology/cost change @@ -544,15 +523,15 @@ Traditional IP routing with Interior Gateway Protocols (OSPF, IS-IS): Software-Defined Networking and Traffic Engineering: -- Routes adapt dynamically to residual link capacities during flow placement +- Routes follow residual link capacity during placement - SPF recomputed after each flow placement iteration, excluding saturated links - Iterative augmentation continues until max-flow achieved or capacity exhausted - Flow placement respects capacity constraints, never oversubscribing links -- Models centralized traffic engineering with real-time capacity awareness +- Models a controller that places traffic knowing the residual capacity of every link - Placement is greedy and sequential: demands are placed one at a time in priority order (input order within a priority) and never revisited, so an earlier demand keeps capacity a later one would have used, and totals under contention depend on demand order. It is not a global optimum - Use case: Capacity-aware demand placement, capacity planning, failure impact analysis -This distinction is fundamental: IP networks route on cost alone with fixed forwarding tables (congestion managed via queuing/drops), while TE systems route dynamically on both cost and available capacity (congestion avoided via admission control). The `require_capacity` parameter controls whether SPF filters to available capacity; `shortest_path` controls whether routes are recomputed iteratively or fixed after initial SPF. +IP networks route on cost alone with fixed forwarding tables and manage congestion by queuing and drops; TE systems route on cost and available capacity and avoid congestion by admission control. The `require_capacity` parameter controls whether SPF filters to available capacity; `shortest_path` controls whether routes are recomputed iteratively or fixed after initial SPF. ### Flow Placement Strategies @@ -565,7 +544,7 @@ Beyond routing semantics, NetGraph controls how flow splits across equal-cost pa - Used in networks with heterogeneous link speeds (common in fabrics with multi-generation hardware) - Can be used iteratively (e.g., successive max-flow augmentations) -- **EQUAL_BALANCED** (models traditional ECMP): +- **EQUAL_BALANCED** (models ECMP): - Splits flow equally across all parallel equal-cost edges regardless of capacity - Example: Two 100G links get 50/50; one 100G + one 10G still attempt 50/50 (10G saturates first) - Models IP hash-based load balancing (5-tuple hashing distributes flows uniformly) @@ -608,7 +587,7 @@ analyze(network).max_flow(src, dst, ### Flow Policy Presets -For traffic matrix placement, `FlowPolicyPreset` values bundle the routing semantics above into named configurations that map to real-world network behaviors: +For traffic matrix placement, `FlowPolicyPreset` values bundle the routing semantics above into named configurations: | Preset | Behavior | Use Case | | -------- | ---------- | ---------- | @@ -632,7 +611,7 @@ For traffic matrix placement, `FlowPolicyPreset` values bundle the routing seman | `TE_ECMP_16_LSP` | `true` | `false` | `false` | `16` | `EQUAL_BALANCED` | | `TE_ECMP_UP_TO_256_LSP` | `true` | `false` | `false` | `256` | `EQUAL_BALANCED` | -**Key parameters (preset-managed):** +**Parameters set by the preset:** - `require_capacity`: When `false`, paths are selected based on link costs alone (models IP/IGP routing). When `true`, paths adapt to residual capacity during placement (models SDN/TE). See [Routing Semantics](#routing-semantics-ipigp-vs-sdnte) for details. - `shortest_path`: When `true`, each demand is placed in a single pass on its cost-only shortest-path DAG and the remainder is dropped; when `false`, the remainder is rerouted tier by tier on residual-aware paths. @@ -737,7 +716,7 @@ In practice the loop stops as soon as the residual network disconnects source fr ### Managers and Workflow Orchestration -Managers handle scenario dynamics and prepare inputs for algorithmic steps. +Two components turn scenario definitions into concrete inputs for the algorithms: demand expansion and the failure manager. **Demand Expansion** (`ngraph.analysis.demand`): Expands `TrafficDemand` specs (built from DSL definitions by `ngraph.model.demand.builder`) into concrete placement demands, resolving source/target selectors into node groups. @@ -759,22 +738,19 @@ Managers handle scenario dynamics and prepare inputs for algorithmic steps. - Deterministic results when seed is provided (each iteration derives `seed + iteration_index`); with `seed=None`, the failure policy's own seed is used as a fallback when present - Baseline execution: a no-failure baseline is always run first as a separate reference for comparing degraded vs. intact capacity - Deduplication: identical exclusion patterns execute once and are weighted by multiplicity (`occurrence_count` on results; `metadata["occurrence_counts"]` aligned with the results list). Stored failure traces describe each pattern's representative (first) iteration; this weighting assumes deterministic analysis functions (the built-ins are) -- Thread-safe analysis: Network shared by reference; exclusion sets passed per-iteration -- Graph pre-building: before the iterations start, the engine calls the analysis function's `prepare_inputs(network, kwargs)` hook once per run and merges what it returns into every iteration's call. The built-in functions return a pre-built `AnalysisContext`, and demand placement also returns the expanded demands and resolved node ids. Each iteration then only builds masks, O(|excluded|). Custom analysis functions opt in by setting a `prepare_inputs` attribute; functions without it run with their kwargs unchanged, and passing `context` explicitly skips the hook. - -Both the demand expansion logic and failure manager separate policy (how to expand demands or pick failures) from core algorithms. They prepare concrete inputs (expanded demands or exclusion sets) for each workflow iteration. +- Graph pre-building: before the iterations start, the engine calls the analysis function's `prepare_inputs(network, kwargs)` hook once per run and merges what it returns into every iteration's call. The built-in functions return a pre-built `AnalysisContext`, and demand placement also returns the expanded demands and resolved node ids. Each iteration then only builds masks. Custom analysis functions opt in by setting a `prepare_inputs` attribute; functions without it run with their kwargs unchanged, and passing `context` explicitly skips the hook. ### Workflow Engine and Steps -A NetGraph workflow (see Workflow Reference) is an ordered recipe of analysis steps. Each step is a pure function: it takes the current model and possibly prior results, performs an analysis, and stores its outputs. The workflow engine runs the steps in sequence and records their data in a Results store. +A workflow (see [Workflow Reference](workflow.md)) is an ordered list of analysis steps. Each step reads the model and possibly earlier results, runs one analysis, and writes its output; it never modifies the Network. The engine runs the steps in sequence and records their data in a Results store. Common built-in steps: -- BuildGraph: validates network topology and stores node-link JSON representation via NetworkX `MultiDiGraph`. Stores graph structure under `data.graph` and parameters under `data.context`. Primarily for validation and export; Core graph building happens in analysis functions. +- BuildGraph: exports the network as node-link JSON via NetworkX `MultiDiGraph`. Stores graph structure under `data.graph` and parameters under `data.context`. For export only; Core graph building happens in analysis functions. - NetworkStats: computes node/link counts, capacity statistics, cost statistics, and degree statistics. Supports optional `excluded_nodes`/`excluded_links` and `include_disabled`. -- TrafficMatrixPlacement: runs Monte Carlo placement using a named demand set and the Failure Manager; a no-failure baseline always runs first. Supports `iterations`, `parallelism`, `store_failure_patterns`, `include_flow_details`, `include_used_edges`, and `alpha` or `alpha_from_step` (default field `data.alpha_star`). Produces `data.baseline` and `data.flow_results` (unique failure patterns with `occurrence_count`). (`placement_rounds` is deprecated and accepted only as a no-op for backward compatibility.) +- TrafficMatrixPlacement: runs Monte Carlo placement using a named demand set and the Failure Manager; a no-failure baseline always runs first. Supports `iterations`, `parallelism`, `store_failure_patterns`, `include_flow_details`, `include_used_edges`, and `alpha` or `alpha_from_step` (default field `data.alpha_star`). Produces `data.baseline` and `data.flow_results` (unique failure patterns with `occurrence_count`). - MaxFlow: runs Monte Carlo maximum-flow analysis between node groups using the Failure Manager; a no-failure baseline always runs first. Supports `mode` (combine/pairwise), `iterations`, `parallelism`, `shortest_path`, `require_capacity`, `flow_placement`, and optional `include_flow_details`/`include_min_cut`. Produces `data.baseline` and `data.flow_results` (unique failure patterns with `occurrence_count`). @@ -782,53 +758,23 @@ Common built-in steps: - CostPower: aggregates platform and per-end optics capex/power by hierarchy level (0..N). Respects `include_disabled` and `aggregation_level`. Stores `data.levels` and `data.context`. Performs no hardware capacity/ports validation and completes even on networks the explorer's strict validation would reject; hardware validation is available via `NetworkExplorer` (strict validation) or the `ngraph inspect` command. -Each step is implemented in the `ngraph.workflow` module and has a corresponding `type` name. Steps do not modify the Network. Their inputs often include references to prior steps' results: a placement step might need the value of alpha* from an MSD step, and the workflow definition names that link. +Each step is implemented in `ngraph.workflow` and registered under its `type` name. Their inputs often include references to prior steps' results: a placement step might need the value of alpha* from an MSD step, and the workflow definition names that link. ### Results storage -The Results object is a container that the workflow passes through steps. When a step runs, it "enters" a scope in the Results (by step name) and writes any outputs to either metadata or data within that scope. - -For example, the MaxFlow step named "maxflow_between_metros" will put the total flow and details under `results.steps["maxflow_between_metros"]["data"]` and perhaps record parameters in metadata. The Results store also captures each step's execution metadata (like step order, type, seeds) in a workflow registry. At the end of the workflow, a single nested dictionary can be exported via Results.to_dict() containing all step outputs in a structured way. - -This design ensures consistency (every step has metadata and data keys) and JSON serialization (handles custom objects via to_dict() when available, converts keys to strings). The results often include artifacts like tables or lists of flows for reporting. +The Results object is passed through the steps. A running step enters a scope named after it and writes its outputs to `metadata` or `data` within that scope: a MaxFlow step named "maxflow_between_metros" puts its flows under `results.steps["maxflow_between_metros"]["data"]`, with its parameters in `data.context`. The store also records each step's execution metadata (order, type, seeds) in a workflow registry, and `Results.to_dict()` exports everything as one nested dictionary. -### Design Elements and Comparisons - -Design choices worth knowing about: - -- Declarative Scenario DSL: A YAML DSL with blueprints and expansion rules turns abstract definitions (e.g., a fully meshed Clos) into concrete nodes and links. Schema validation rejects unknown or invalid fields before expansion. - -- Runtime Exclusions vs graph copying: Analysis-time exclusions avoid copying large structures for each scenario. The design separates static topology from dynamic failure states. - -- Deterministic link IDs: `Network.add_link` assigns each link a unique ID (`source|target|`, a per-endpoint-pair insertion sequence) that is stable across identical scenario builds and throughout analysis, simplifying correlation of results to original links and keeping seeded failure sampling reproducible. - -- Dual routing semantics: Models both IP/IGP (cost-only, fixed paths via `require_capacity=false` + `shortest_path=true`) and SDN/TE (capacity-aware, iterative via `require_capacity=true` + `shortest_path=false`) - -- 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. 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. - -- Structured results store: Collects results with metadata in a consistent format for JSON export and downstream analysis. +Every step has `metadata` and `data` keys. Export converts custom objects through their `to_dict()` and keys to strings, so the document is JSON-serializable. ### Performance Considerations -**C++ Algorithm Implementation:** - -- Native C++ execution with optimized data structures (CSR adjacency, flat arrays) -- GIL released during algorithm execution, enabling concurrent analysis across Python threads -- Zero-copy NumPy integration for array inputs/outputs (via buffer protocol) -- Deterministic edge ordering for reproducible results -- Cache-friendly CSR representation for neighbor traversal - **Graph Building and Reuse:** For Monte Carlo analysis with many failure iterations, graph construction is amortized via `AnalysisContext`: - Context built once before iterations begin (includes all nodes and augmentation edges) - Per-iteration exclusions applied via boolean masks rather than graph rebuilding -- Mask building is O(|excluded|) using pre-computed `link_id_to_edge_indices` mapping +- Mask building is a vectorized array fill plus O(|excluded|) updates via the pre-computed `link_id_to_edge_indices` mapping - FailureManager automatically pre-builds the `AnalysisContext` before parallel execution (via the analysis function's `prepare_inputs` hook) Graph construction involves Python processing, NumPy array creation, and C++ object initialization. Building the graph once keeps that work off the per-iteration critical path, leaving the GIL-releasing C++ algorithms to run with minimal Python overhead. diff --git a/docs/reference/dsl.md b/docs/reference/dsl.md index 550f9ff..5a8b7cb 100644 --- a/docs/reference/dsl.md +++ b/docs/reference/dsl.md @@ -2,99 +2,53 @@ Quick links: -- [Design](design.md) โ€” architecture, model, algorithms, workflow -- [Workflow Reference](workflow.md) โ€” analysis workflow configuration and execution -- [CLI Reference](cli.md) โ€” command-line tools for running scenarios -- [API Reference](api.md) โ€” Python API for programmatic scenario creation -- [Auto-Generated API Reference](api-full.md) โ€” complete class and method documentation +- [Design](design.md) - architecture, model, algorithms, workflow +- [Workflow Reference](workflow.md) - analysis workflow configuration and execution +- [CLI Reference](cli.md) - command-line tools for running scenarios +- [API Reference](api.md) - Python API for programmatic scenario creation +- [Auto-Generated API Reference](api-full.md) - complete class and method documentation -NetGraph scenarios are YAML files describing network topology, traffic demands, and analysis workflows. This document is the reference for that DSL. - -## Overview - -A scenario file defines a complete network simulation including: - -- **Network topology**: Nodes, links, and their relationships, as well as risk groups -- **Analysis configuration**: Traffic demands, failure policies, workflows -- **Reusable components**: Blueprints, hardware definitions - -Every structure can be written out directly or generated from templates and parameters. +A scenario is a YAML file describing the network topology (nodes, links, risk groups), the analysis configuration (traffic demands, failure policies, workflow) and reusable parts (blueprints, hardware components). Every structure can be written out directly or generated from templates and parameters. ## Template Syntaxes -The DSL uses three distinct template syntaxes in different contexts: - -| Syntax | Example | Context | Purpose | -|--------|---------|---------|---------| -| `[1-3]` | `dc[1-3]/rack[a,b]` | Node/risk group names | Generate multiple groups | -| `$var` / `${var}` | `pod${p}/leaf` | Links, rules, demands | Template expansion with `expand` block | -| `{n}` | `srv-{n}` | `template` field | Node naming (1-indexed counter) | - -**These syntaxes are not interchangeable.** Each works only in its designated context. - -**Why different syntaxes?** - -| Syntax | Operation | Key Difference | -|--------|-----------|----------------| -| `[1-3]` | Static generation | Creates multiple definitions at parse time | -| `${var}` | Template substitution | Requires explicit `expand` block with `vars` | -| `{n}` | Sequential counter | Auto-increments based on `count` | - -Bracket expansion generates structure; variable expansion parameterizes rules; node naming indexes instances. +Three template syntaxes, each valid only in its own context: -## Entity Creation Architecture +| Syntax | Example | Where | Effect | +|--------|---------|-------|--------| +| `[1-3]` | `dc[1-3]/rack[a,b]` | Node and risk group names | One definition per expansion, created at parse time | +| `$var` / `${var}` | `pod${p}/leaf` | Links, rules, demands | Substituted from the `vars` of an `expand` block | +| `{n}` | `srv-{n}` | `template` field | 1-indexed counter up to `count` | -The DSL has two selection patterns. Which one applies is fixed by the operation, not chosen by the author, so it is worth knowing which is which before writing selectors. +## Selection Models -### Two Selection Models +The DSL has two ways of selecting entities. Which one applies is fixed by the operation, not chosen by the author. **1. Path-Based Node Selection** (link rules, traffic demands, workflow steps) -- Uses regex patterns on hierarchical node names -- Supports capture group-based grouping -- Supports attribute-based grouping (`group_by`) -- Supports attribute filtering (`match` conditions) -- Supports `active_only` filtering +- Regex patterns on hierarchical node names; capture groups define the groups +- `group_by` regroups the selection by an attribute +- `match` filters by attribute conditions +- `active_only` drops disabled nodes **2. Condition-Based Entity Selection** (failure rules, membership rules, risk group generation) -- Works on nodes, links, or risk_groups (`scope`) -- Supports attribute-based filtering (`conditions`) -- Supports optional `path` regex filtering (a pre-filter for membership and generate rules; applied after condition matching for failure rules) +- `scope` picks nodes, links or risk groups +- `conditions` filter by attribute +- An optional `path` regex filters by name (before conditions for membership and generate rules, after them for failure rules) Both build on the same primitives (condition evaluation, match specification), but they are not interchangeable. ### Link Creation Flow -Link definitions create links between nodes using path-based selection with optional filtering: - -```mermaid -flowchart TD - Start[Link Definition] --> VarExpand{Has expand block?} - VarExpand -->|Yes| VarSubst[Variable Substitution] - VarSubst --> PathFilter - VarExpand -->|No| PathFilter[1. Path-Based Selection] - PathFilter --> PathDesc[Select nodes via regex pattern
Groups by capture groups] - PathDesc --> MatchFilter{Has match conditions?} - MatchFilter -->|Yes| AttrFilter[2. Attribute Filtering] - MatchFilter -->|No| ActiveFilter - AttrFilter --> AttrDesc[Filter by attribute conditions
using logic and/or] - AttrDesc --> ActiveFilter[3. Active/Excluded Filtering] - ActiveFilter --> GroupBy{Has group_by?} - GroupBy -->|Yes| Regroup[4. Re-group by Attribute] - GroupBy -->|No| Pattern - Regroup --> Pattern[5. Apply Pattern] - Pattern --> PatternDesc[mesh or one_to_one
Creates links between groups] -``` - -**Processing Steps:** +A link definition is processed in five steps: 1. **Path Selection**: Regex pattern matches nodes by hierarchical name - Capture groups create initial grouping - If no path specified, selects all nodes 2. **Attribute Filtering**: Optional `match` conditions filter nodes - Uses `logic: "and"` or `"or"` (default: `"or"`) - - Supports operators: `==`, `!=`, `<`, `>`, `contains`, `in`, etc. + - Operators are listed under Shared Evaluation Primitives below 3. **Active Filtering**: Filters disabled nodes based on context - Links default: `active_only=false` (creates links to disabled nodes) 4. **Attribute Grouping**: Optional `group_by` overrides regex capture grouping @@ -102,85 +56,26 @@ flowchart TD - `mesh`: Every source to every target - `one_to_one`: Pairwise with wrap-around -**Key Characteristics:** - -- `default_active_only=False` (links are created to disabled nodes) -- `match.logic` defaults to `"or"` (inclusive matching) -- Supports variable expansion via `expand` block - ### Traffic Demand Creation Flow -Traffic demands follow a similar pattern, with these differences: - -```mermaid -flowchart TD - Start[Traffic Demand Spec] --> VarExpand{Has expand block?} - VarExpand -->|Yes| VarSubst[Variable Substitution
Creates multiple demand specs] - VarSubst --> Process - VarExpand -->|No| Process[Process Single Demand] - Process --> SrcSelect[1. Select Source Nodes] - SrcSelect --> TgtSelect[2. Select Target Nodes] - TgtSelect --> SrcDesc[Uses same path + match + group_by
selection as links] - SrcDesc --> Mode{Demand Mode?} - Mode -->|pairwise| Pairwise[3a. Pairwise Expansion] - Mode -->|combine| Combine[3b. Combine Expansion] - Pairwise --> PairDesc[Create demand for each src-tgt pair
Volume distributed evenly
No pseudo nodes] - Combine --> CombDesc[Create pseudo-source and pseudo-target
Single aggregated demand
Augmentation edges connect real nodes] -``` +Traffic demands select nodes the same way as links, with these differences: -**Key Differences from Links:** - -1. **Active-only default**: `default_active_only=True` (only active nodes participate) -2. **Two selection phases**: Source nodes first, then target nodes (both use same selector logic) -3. **Expansion modes**: - - **Pairwise**: Creates individual demands for each (source, target) pair - - **Combine**: Creates pseudo nodes and a single aggregated demand -4. **Group modes**: Additional layer (`flatten`, `per_group`, `group_pairwise`) for handling grouped selections - -**Processing Steps:** - -1. Select source nodes using unified selector (path + match + group_by) -2. Select target nodes using unified selector -3. Apply mode-specific expansion: - - **Pairwise**: Volume evenly distributed across all pairs - - **Combine**: Single demand with pseudo nodes for aggregation +1. `active_only` defaults to `true`: only active nodes participate +2. Two selections, source nodes first and then target nodes, each with `path`, `match` and `group_by` +3. `mode` expands the selection: `pairwise` creates one demand per (source, target) pair with the volume split evenly; `combine` creates pseudo source and target nodes and a single aggregated demand +4. `group_mode` (`flatten`, `per_group`, `group_pairwise`) decides how grouped selections produce demands ### Risk Group Creation Flow -Risk groups use the condition-based selection model: - -```mermaid -flowchart TD - Start[Risk Groups Definition] --> Three[Three Creation Methods] - Three --> Direct[1. Direct Definition] - Three --> Member[2. Membership Rules] - Three --> Generate[3. Generate Blocks] +Risk groups use the condition-based model and are populated in three ways: - Direct --> DirectDesc[Simply name the risk group
Entities reference it explicitly] +1. **Direct definition**: the group is named and entities reference it +2. **Membership rules**: entities whose attributes match are assigned to the group +3. **Generate blocks**: one group per distinct value of an attribute - Member --> MemberScope[Specify scope
node, link, or risk_group] - MemberScope --> MemberCond[Define match conditions
logic defaults to and
optional path pre-filter] - MemberCond --> MemberExec[Scan entities of that scope
Add matching entities to risk group] +Membership rules and generate blocks scan every entity of their `scope`; an optional `path` regex narrows candidates by name (links match against their `source|target` form), `conditions` filter by attribute, and membership `logic` defaults to `and`. - Generate --> GenScope[Specify scope
node or link only] - GenScope --> GenGroupBy[Specify group_by attribute] - GenGroupBy --> GenExec[Collect unique values
Create risk group for each value
Add entities with that value] -``` - -**Creation Methods:** - -1. **Direct Definition**: Explicitly name risk groups, entities reference them -2. **Membership Rules**: Auto-assign entities based on attribute matching -3. **Generate Blocks**: Auto-create risk groups from unique attribute values - -**Key Characteristics:** - -- **Scope-wide scan**: Operates on all entities of the specified scope; an optional `path` regex narrows candidates by name (links match against their `source|target` form) -- **Attribute-based filtering**: Uses `conditions`; no capture-group grouping -- **Logic defaults to "and"** for membership (stricter matching) -- **Hierarchical support**: Risk groups can contain other risk groups as children - -### Comparison Table +### Comparison | Feature | Links | Traffic Demands | Risk Groups | |---------|-------|-----------------|-------------| @@ -229,7 +124,7 @@ conditions: Operator semantics: - Ordering operators (`<`, `<=`, `>`, `>=`) coerce both sides to float when possible, so `"10" > 5` is true; equality (`==`, `!=`) does **not** coerce, so `"10" == 10` is false. Keep attribute and condition value types consistent. -- For a missing or null attribute, every operator except `not_exists` returns false โ€” including the negative ones (`!=`, `not_contains`, `not_in`). Use `not_exists` to match absent attributes. +- For a missing or null attribute, every operator except `not_exists` returns false, including the negative ones (`!=`, `not_contains`, `not_in`). Use `not_exists` to match absent attributes. - `in`/`not_in` require a list value. Link selectors, node/link rules, failure rules, and membership rules reject a scalar at scenario load; demand selectors reject it when the demand is first evaluated. **3. Condition Combining (`logic`)** @@ -250,6 +145,7 @@ match: Conditions evaluate against a flattened view of entity attributes: - Node top-level fields: `name`, `disabled`, `risk_groups` - Link top-level fields: `id`, `source`, `target`, `capacity`, `cost`, `disabled`, `risk_groups` +- Risk group top-level fields (rules with `scope: risk_group`): `name`, `disabled`, `children` (list of child group names) - Custom attributes from `attrs` block (top-level fields take precedence on key conflicts) **5. Dot-Notation for Nested Attributes** @@ -309,13 +205,13 @@ seed: 42 # Master seed for reproducibility (integer) | `workflow` | No | Analysis workflow steps to execute | | `seed` | No | Master seed (integer) for reproducible random operations | -All sections are optional. A scenario that omits `network` entirely, or sets it to an empty mapping (`network: {}`), builds with an empty topology โ€” note that a bare `network:` with no value is a YAML null and fails schema validation; any unrecognized top-level key is rejected during JSON Schema validation with `jsonschema.ValidationError` (the schema sets `additionalProperties: false`). +All sections are optional. A scenario that omits `network` entirely, or sets it to an empty mapping (`network: {}`), builds with an empty topology. A bare `network:` with no value is a YAML null and fails schema validation. Any unrecognized top-level key is rejected during JSON Schema validation with `jsonschema.ValidationError` (the schema sets `additionalProperties: false`). -**Seed:** When specified, the `seed` value is used to derive deterministic per-component seeds (via SHA-256 hashing) for failure sampling and workflow steps, ensuring reproducible results across runs. Each failure iteration creates a single isolated random number generator from its derived seed. Without a seed, results may vary between executions. +**Seed:** `seed` derives per-component seeds (via SHA-256) for failure sampling and workflow steps, so runs are reproducible. Each failure iteration gets its own random number generator from its derived seed. Without a seed, results vary between runs. -## `network` - Core Foundation +## `network` - Topology -Defines network topology through nodes and links. +Nodes and links, written out directly or generated from node groups and link definitions. **Network metadata fields:** @@ -376,7 +272,7 @@ Recognized keys for each link entry: - `source`, `target`: node names (required) - `capacity`: link capacity (optional; default 1.0) -- `cost`: link cost (optional; default 1.0; must be an integer value โ€” fractional costs are rejected with `ValueError` when the analysis graph is built, because the core engine requires int64 costs) +- `cost`: link cost (optional; default 1.0; must be an integer value; fractional costs are rejected with `ValueError` when the analysis graph is built, because the core engine requires int64 costs) - `disabled`: boolean (optional) - `risk_groups`: list of risk-group names (optional) - `attrs`: mapping of attributes (optional) @@ -403,7 +299,7 @@ network: Creates: `leaf/leaf-1`, `leaf/leaf-2`, `leaf/leaf-3`, `leaf/leaf-4`, `spine/spine-1`, `spine/spine-2` -The `{n}` placeholder is replaced with a 1-indexed counter (1, 2, 3, ...) up to `count`. The group name becomes the parent path, and template generates child node names. +The `{n}` placeholder is replaced with a 1-indexed counter (1, 2, 3, ...) up to `count`. The group name becomes the parent path, and template generates child node names. When `count` is given without `template`, the template defaults to `-{n}` (so `leaf: {count: 2}` creates `leaf/leaf-1` and `leaf/leaf-2`). **Nested Nodes (Inline Hierarchy):** @@ -497,7 +393,7 @@ Notes: Path semantics: - All paths are relative to the current scope. There is no concept of absolute paths. -- Leading `/` is stripped and has no functional effect - `/leaf` and `leaf` are equivalent. +- A leading `/` is stripped, so `/leaf` and `leaf` are equivalent. - Within a blueprint, paths resolve relative to the instantiation path. For example, if a blueprint is used under group `pod1`, then `source: /leaf` resolves to `pod1/leaf`. - At top-level `network.links`, the parent path is empty, so patterns match against full node names. @@ -530,7 +426,7 @@ network: **Connectivity Patterns:** - `mesh`: Full connectivity between all source and target nodes -- `one_to_one`: Pairwise connections. Compatible sizes means max(|S|,|T|) must be an integer multiple of min(|S|,|T|); mapping wraps modulo the smaller set (e.g., 4x2 and 6x3 valid; 3x2 invalid). Self-pairs are skipped, so `one_to_one` between a group and itself creates no links โ€” use `mesh` to interconnect a group with itself. +- `one_to_one`: Pairwise connections. Compatible sizes means max(|S|,|T|) must be an integer multiple of min(|S|,|T|); mapping wraps modulo the smaller set (e.g., 4x2 and 6x3 valid; 3x2 invalid). Self-pairs are skipped, so `one_to_one` between a group and itself creates no links; use `mesh` to interconnect a group with itself. ### Bracket Expansion @@ -558,7 +454,7 @@ dc[1-2]/rack[a,b]: # Creates: dc1/racka, dc1/rackb, dc2/racka, dc2/rackb **Scope:** Bracket expansion applies to: -- **Node names** under `network.nodes` and `blueprints.*.nodes` โ€” including direct single-node entries without count/template (`SEA[1-2]: {}` creates nodes `SEA1` and `SEA2`) +- **Node names** under `network.nodes` and `blueprints.*.nodes`, including direct single-node entries without count/template (`SEA[1-2]: {}` creates nodes `SEA1` and `SEA2`) - **Risk group names** in top-level `risk_groups` definitions (including children) - **Risk group membership arrays** on nodes, links, node groups, and in node/link rules @@ -593,11 +489,11 @@ The range syntax `[start-end]` only supports integers. For letters, mixed sequen Use `$var` or `${var}` syntax with an `expand` block for template substitution. Variables are recursively substituted in all string fields within the block, including nested `attrs`. -**Type preservation:** A value consisting of exactly one placeholder (e.g. `value: "${t}"`) is replaced by the variable's native value, preserving its type โ€” so `match` conditions compare correctly against numeric node/link attributes. Placeholders embedded in longer strings (e.g. `"dc${dc}_internal"`) interpolate as text and always produce strings. A bare placeholder bound to a non-string variable used where a path selector is required (e.g. `source: "${n}"` with `n: [1, 2]`) raises `ValueError` instead of being silently stringified; use an embedded form such as `"dc${n}/leaf"` for selector strings. +**Type preservation:** A value consisting of exactly one placeholder (e.g. `value: "${t}"`) is replaced by the variable's native value, preserving its type, so `match` conditions compare correctly against numeric node/link attributes. Placeholders embedded in longer strings (e.g. `"dc${dc}_internal"`) interpolate as text and always produce strings. A bare placeholder bound to a non-string variable used where a path selector is required (e.g. `source: "${n}"` with `n: [1, 2]`) raises `ValueError` instead of being silently stringified; use an embedded form such as `"dc${n}/leaf"` for selector strings. **Supported contexts:** -- Link definitions (`network.links`) +- Link definitions (`network.links` and `blueprints.*.links`) - Link rules (`network.link_rules`) - Node rules (`network.node_rules`) - Traffic demands (`demands.*`) @@ -609,7 +505,7 @@ Use `$var` or `${var}` syntax with an `expand` block for template substitution. | `cartesian` (default) | All combinations of variable values | `p:[1,2]`, `r:[a,b]` โ†’ 4 expansions | | `zip` | Pair values by index (lists must have equal length) | `a:[1,2]`, `b:[x,y]` โ†’ 2 expansions | -**Warning โ€” cartesian expansion and reversed pairs:** Each variable combination of an `expand` block is an independent link definition. Reversed-pair deduplication applies only within one combination, so cartesian expansion over symmetric variable lists (e.g. `vars: {a: [1, 2], b: [1, 2]}` with `source: "dc${a}/gw"`, `target: "dc${b}/gw"`) creates *both* orientations as separate parallel links, doubling capacity. To mesh one node set, prefer a single mesh definition with a regex selector (e.g. `source: "dc[0-9]+/gw"`, `target: "dc[0-9]+/gw"`, `pattern: mesh`), which deduplicates reversed pairs. +**Cartesian expansion and reversed pairs:** Each variable combination of an `expand` block is an independent link definition. Reversed-pair deduplication applies only within one combination, so cartesian expansion over symmetric variable lists (e.g. `vars: {a: [1, 2], b: [1, 2]}` with `source: "dc${a}/gw"`, `target: "dc${b}/gw"`) creates *both* orientations as separate parallel links, doubling capacity. To mesh one node set, prefer a single mesh definition with a regex selector (e.g. `source: "dc[0-9]+/gw"`, `target: "dc[0-9]+/gw"`, `pattern: mesh`), which deduplicates reversed pairs. **Example in links:** @@ -683,11 +579,7 @@ network: spine.template: "core-{n}" ``` -**Blueprint Features:** - -- Define nodes and link rules once, reuse multiple times -- Override parameters using dot notation during instantiation -- Hierarchical naming: `pod1/leaf/leaf-1`, `pod2/spine/core-1` +Instance names are prefixed with the group name: `pod1/leaf/leaf-1`, `pod2/spine/core-1`. **Parameter override rules:** @@ -765,7 +657,7 @@ network: - `match`: Optional attribute conditions to filter matched nodes - `disabled`: Set node disabled state - `attrs`: Attributes to merge into matched nodes -- `risk_groups`: Risk groups for matched nodes โ€” **replaces** the node's existing `risk_groups` set (it does not add to it) +- `risk_groups`: Risk groups for matched nodes; **replaces** the node's existing `risk_groups` set (it does not add to it) - `expand`: Variable expansion block for templated rules ### Link Rules @@ -819,9 +711,9 @@ network: - `source`, `target`: Regex patterns or selector objects for endpoint matching (both required on every rule) - `bidirectional`: Match links in both directions (default: `true`) - `link_match`: Filter by link's own attributes (not endpoint attributes) -- `capacity`, `cost`, `disabled`: Override link properties (`cost` must be an integer value โ€” fractional costs are rejected when the analysis graph is built) +- `capacity`, `cost`, `disabled`: Override link properties (`cost` must be an integer value; fractional costs are rejected when the analysis graph is built) - `attrs`: Attributes to merge into matched links -- `risk_groups`: Risk groups for matched links โ€” **replaces** the link's existing `risk_groups` set (it does not add to it) +- `risk_groups`: Risk groups for matched links; **replaces** the link's existing `risk_groups` set (it does not add to it) - `expand`: Variable expansion block for templated rules **Execution order:** @@ -875,7 +767,7 @@ network: attrs: hardware: component: "SpineRouter" - count: 2 # Optional multiplier; defaults to 1 if not set + count: 2 # Optional positive multiplier; defaults to 1 links: - source: spine-1 target: leaf-1 @@ -885,11 +777,13 @@ network: target: {component: "Optic400G", count: 4} ``` +`count` (default 1) must be a finite positive number; any other value raises `ValueError` when the hardware is resolved (by the `CostPower` step or the network explorer). A link end may also set `exclusive: true` to mark its hardware as not shared with other links; the explorer's bill of materials then rounds that end's count up to a whole unit. + ## `risk_groups` - Risk Modeling Risk groups model correlated failures as hierarchies: physical infrastructure, geographic regions, vendor dependencies, or custom domains. -### Understanding Hierarchy +### Hierarchy Risk groups form parent-child trees that model **cascading failures**: @@ -901,7 +795,7 @@ risk_groups: - name: "Site_Portland" ``` -**Cascading semantics:** When a parent fails, all descendants also fail. This models real-world correlations where a regional outage affects all sites in that region. +**Cascading semantics:** When a parent fails, all descendants fail with it: a regional outage takes every site in the region. **Storage model:** Children are nested within parents, not in the top-level dictionary: @@ -915,7 +809,11 @@ for child in region.children: print(child.name) # Site_Seattle, Site_Portland ``` -**Top-level-only keys:** `membership`, `disabled`, and `generate` are honored only on top-level `risk_groups` entries. Nested `children` entries allow only `name`, `attrs`, and `children`; placing any other key on a child is rejected โ€” by the JSON schema at scenario load and by the parser with `ValueError`. Define such groups at top level and reference them by name as children. +**String shorthand:** an entry that is a bare string defines a group with that name and no other fields: `- "Region_West"` is equivalent to `- name: "Region_West"`. The same shorthand works inside `children`. + +**Disabled groups:** `disabled: true` on a top-level group disables every member node and link at scenario load, including members of its descendant groups. This runs after membership rules and generate blocks, so entities assigned by those mechanisms are covered. + +**Top-level-only keys:** `membership`, `disabled`, and `generate` are honored only on top-level `risk_groups` entries. Nested `children` entries allow only `name`, `attrs`, and `children`; placing any other key on a child is rejected by the JSON schema at scenario load and by the parser with `ValueError`. Define such groups at top level and reference them by name as children. **Entity references:** Nodes and links reference risk groups by name. To reference a group, it must be defined at top level (children alone are not sufficient): @@ -932,15 +830,6 @@ network: risk_groups: ["Site_Seattle"] ``` -### Common Use Cases - -Common correlation patterns: - -**Physical Infrastructure** (fiber paths, power zones, cooling systems) -**Geographic/Administrative** (regions, availability zones, maintenance windows) -**Vendor/Software Dependencies** (shared components, software versions) -**Logical Grouping** (service tiers, customer segments, custom domains) - ### Example 1: Physical Infrastructure (Fiber Links) For fiber links, a hierarchy of Path -> Conduit -> Fiber Pair mirrors the physical plant. @@ -1040,7 +929,7 @@ risk_groups: ### Membership Rules -Dynamically assign entities to risk groups based on attributes: +Assign entities to a risk group by attribute: ```yaml risk_groups: @@ -1065,13 +954,13 @@ risk_groups: value: "DC1-R1-PZ-A" ``` -**Note:** Membership rules default to `logic: "and"`, stricter than link/demand selectors, which default to `"or"`. +Membership rules default to `logic: "and"`, stricter than link and demand selectors, which default to `"or"`. A membership rule requires `scope` plus at least one of `path` or `match`; a `match` block must contain at least one condition. The optional `path` regex pre-filters candidates by name before conditions are evaluated (links match against their `source|target` form). ### Generated Risk Groups -Automatically create risk groups from entity attributes: +One risk group per distinct attribute value: ```yaml risk_groups: @@ -1128,11 +1017,11 @@ risk_groups: Cycle detection runs over top-level groups (whose children are followed by name), including parent-child links added by membership rules with `scope: risk_group`. Detection walks only names that are registered as top-level risk groups: a direct child entry repeating its own parent's name is a self-cycle and *is* rejected, whereas a name repeated deeper than a direct child (nested under a child that is not itself a top-level group) is never followed and is not detected. -Validation errors list affected entities and undefined groups to aid debugging. +Error messages name the affected entities and the undefined groups. ## `vars` - YAML Anchors -Defines reusable values using YAML anchors (`&name`) and aliases (`*name`) for deduplicating complex scenarios: +Reusable values through YAML anchors (`&name`) and aliases (`*name`): ```yaml vars: @@ -1154,7 +1043,7 @@ network: - **Scalar**: `&cap 10000` - Reference primitive values - **Mapping**: `&attrs {cost: 100}` - Reference objects -- **Merge**: `<<: *attrs` - Merge properties with override capability +- **Merge**: `<<: *attrs` - Merge a mapping; explicit keys win **Processing Behavior:** @@ -1163,9 +1052,9 @@ network: - Anchors can be defined in any section, not just `vars` - Merge semantics (as parsed by PyYAML): explicit keys override merged keys regardless of position; with repeated `<<:` merge keys, later merges override earlier ones, while the sequence form `<<: [*a, *b]` gives earlier entries precedence -## `demands` - Traffic Analysis +## `demands` - Traffic Demands -Define traffic demand patterns for capacity analysis: +Named demand sets, each a list of demands: ```yaml demands: @@ -1248,7 +1137,7 @@ demands: | `priority` | integer | Priority class; lower = higher priority (default: 0) | | `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 | +| `flow_policy` | string | Routing policy preset name (case-insensitive); omitted or `null` selects `SHORTEST_PATHS_ECMP`. Non-string values (integers, inline policy mappings) fail schema validation at scenario load; any other string (unknown name, numeric string, blank) raises `ValueError` | | `static_paths` | array | Explicit routes to pin the demand to; see below | | `attrs` | object | Arbitrary metadata | | `expand` | object | Variable expansion block | @@ -1281,7 +1170,7 @@ 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 +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. @@ -1364,10 +1253,12 @@ demands: | `pairwise` | `per_group` | Pairwise within each group | | `pairwise` | `group_pairwise` | Pairwise for each group pair combination | -In `per_group`, `group_pairwise`, and `pairwise` expansions the configured volume is split evenly at each expansion level (across groups or group pairs, then across node pairs within each), so total volume is conserved โ€” except that a skipped expansion's share is dropped: in `combine` mode any group (or group pair) whose target set is empty after excluding shared source/target nodes is skipped, and in `pairwise` mode a group (or group pair) with no non-self node pairs is skipped. If no demands remain after exclusion, expansion fails with `No demands could be expanded`. +In `per_group`, `group_pairwise`, and `pairwise` expansions the configured volume is split evenly at each expansion level (across groups or group pairs, then across node pairs within each), so total volume is conserved, except that a skipped expansion's share is dropped: in `combine` mode any group (or group pair) whose target set is empty after excluding shared source/target nodes is skipped, and in `pairwise` mode a group (or group pair) with no non-self node pairs is skipped. If no demands remain after exclusion, expansion fails with `No demands could be expanded`. ### Flow Policies +Demands that omit `flow_policy` use `SHORTEST_PATHS_ECMP`. + - `SHORTEST_PATHS_ECMP`: IP/IGP routing with hash-based ECMP; equal split across equal-cost paths, admitted without loss. `placed` is what the network carries with no drops; a next hop filled by an earlier demand blocks later demands hashed onto it - `SHORTEST_PATHS_ECMP_LOSSY`: the same routing, forwarded best-effort. Every link carries what fits and drops the rest; `placed` is what arrives and `dropped` what was lost. With `include_flow_details` each entry reports `dropped_edges`, the lost volume per link - `SHORTEST_PATHS_WCMP`: IP/IGP routing with weighted ECMP; proportional split by residual capacity (equal to link capacity on an unloaded network) @@ -1375,11 +1266,11 @@ In `per_group`, `group_pairwise`, and `pairwise` expansions the configured volum - `TE_ECMP_16_LSP`: MPLS-TE with exactly 16 ECMP LSPs per demand - `TE_ECMP_UP_TO_256_LSP`: MPLS-TE with up to 256 ECMP LSPs per demand -See [Flow Policy Presets](design.md#flow-policy-presets) for detailed configuration mapping and real-world network behavior. +See [Flow Policy Presets](design.md#flow-policy-presets) for what each preset sets internally. -## `failures` - Failure Simulation +## `failures` - Failure Policies -Define failure policies for resilience testing: +Named failure policies: ```yaml failures: @@ -1454,7 +1345,9 @@ failures: | `attrs` | object | `{}` | Policy metadata (e.g., description) | | `expand_groups` | boolean | `false` | Also fail every entity sharing a risk group with a failed entity (members of a failed risk group are always excluded regardless; this flag adds shared-group correlation, applied identically whether the failure came from an entity rule or a risk_group rule) | -A failed risk group always cascades to its child groups recursively; cascading is inherent to the risk-group hierarchy and is not controlled by a policy flag. +A failed risk group always cascades to its child groups recursively. + +Each entry of `modes` accepts `weight` (required, non-negative), `rules` (required) and optional `attrs`; mode `attrs` are recorded as `mode_attrs` in failure traces. **Risk group expansion example:** @@ -1496,9 +1389,9 @@ failures: - At least one mode must have `weight > 0`; a policy whose modes all have zero weight is rejected at scenario load. Modes with zero weight are never selected. - Condition syntax uses the same operators as link/demand selectors. See [Condition Operators](#shared-evaluation-primitives) for the full reference. -## `workflow` - Execution Steps +## `workflow` - Analysis Steps -Define analysis workflow steps: +The ordered list of analysis steps: ```yaml workflow: @@ -1514,10 +1407,10 @@ workflow: iterations: 1000 ``` -**Common Steps:** +**Built-in steps:** - `BuildGraph`: Export graph to JSON (node-link) for external analysis -- `NetworkStats`: Compute basic statistics +- `NetworkStats`: Node, link, capacity and degree statistics - `MaxFlow`: Monte Carlo capacity analysis between node groups - `TrafficMatrixPlacement`: Monte Carlo demand placement for a named demand set - `MaximumSupportedDemand`: Search for `alpha_star` (scaling factor) for a named demand set @@ -1662,8 +1555,3 @@ network: path: "^dc2/leaf/.*" pattern: mesh ``` - -### Notes - -- For links, risk groups, and failure policies, use `conditions` with an `attr` field in rules (see Failure Simulation). -- Blueprint scoping: In blueprints, paths are relative to the blueprint instantiation path. diff --git a/docs/reference/schemas.md b/docs/reference/schemas.md index 0aba633..8e5ced5 100644 --- a/docs/reference/schemas.md +++ b/docs/reference/schemas.md @@ -2,31 +2,24 @@ Quick links: -- [Design](design.md) โ€” architecture, model, algorithms, workflow -- [DSL Reference](dsl.md) โ€” YAML syntax for scenario definition -- [Workflow Reference](workflow.md) โ€” analysis workflow configuration and execution -- [CLI Reference](cli.md) โ€” command-line tools for running scenarios -- [API Reference](api.md) โ€” Python API for programmatic scenario creation -- [Auto-Generated API Reference](api-full.md) โ€” complete class and method documentation +- [Design](design.md) - architecture, model, algorithms, workflow +- [DSL Reference](dsl.md) - YAML syntax for scenario definition +- [Workflow Reference](workflow.md) - analysis workflow configuration and execution +- [CLI Reference](cli.md) - command-line tools for running scenarios +- [API Reference](api.md) - Python API for programmatic scenario creation +- [Auto-Generated API Reference](api-full.md) - complete class and method documentation A JSON Schema describes the scenario YAML. It drives load-time validation, IDE completion, and tests. ## Schema Location -The schema is packaged with the library at: **`ngraph/schemas/scenario.json`**. - -This file validates NetGraph scenario YAML structure including network topology, blueprints, risk groups, failure policies, traffic matrices, workflows, and components. +The schema ships in the package as `ngraph/schemas/scenario.json` and covers every top-level section. ## Validation Scope -The schema validates: - -- YAML syntax and data types -- Required fields and property structure -- Top-level section organization -- Basic constraint checking +The schema checks value types, required fields, allowed keys and simple constraints such as enumerations and minimums. -`Scenario.from_yaml` always validates against the schema (in `ngraph.dsl.loader.load_scenario_yaml`) before expansion. Rules the schema cannot express, such as blueprint parameter names or risk-group references, are checked in code and raise `ValueError`. +`Scenario.from_yaml` always validates against the schema (in `ngraph.dsl.loader.load_scenario_yaml`) before expansion. Rules the schema cannot express are checked in code and raise `ValueError`: blueprint parameter names, risk-group references, and unrecognized keys in sections whose schema allows additional properties, such as workflow step parameters. ## IDE Integration (VS Code) @@ -43,11 +36,9 @@ Add to `.vscode/settings.json` (not committed to the repository): } ``` -Provides real-time validation, autocompletion, inline documentation, and error highlighting. +The YAML extension then validates and completes scenario files as you type. -## Automated Validation - -### Development Workflow +## Validation in Development ```bash # Validate all scenarios @@ -57,11 +48,7 @@ make validate make check ``` -### Integration Points - -- Pre-commit hooks: Runs `make validate` when `scenarios/*.yaml` files change -- CI pipeline: Validates scenarios on push/PR -- Test suite: Validation exercised in integration tests +The pre-commit hook runs `make validate` when scenario or integration YAML files, or the schema, change; CI runs it on every push and pull request, and the integration tests load every bundled scenario. ### Python API @@ -98,4 +85,4 @@ jsonschema.validate(data, schema) ## Schema Maintenance -Update the schema whenever a top-level section, a field's type, or a workflow step type changes, then run `make test` (the integration tests load every bundled scenario and the DSL examples). The code is authoritative: `ngraph/dsl/loader.py` validates, and `ngraph/dsl/blueprints/expand.py` and the model classes enforce what the schema cannot express. +Update the schema whenever a top-level section, a field's type, or a workflow step type changes, then run `make validate` (checks every bundled scenario and integration YAML against the schema) and `make test`. The code is authoritative: `ngraph/dsl/loader.py` validates, and `ngraph/dsl/blueprints/expand.py` and the model classes enforce what the schema cannot express. diff --git a/docs/reference/workflow.md b/docs/reference/workflow.md index 616709a..b12848f 100644 --- a/docs/reference/workflow.md +++ b/docs/reference/workflow.md @@ -2,17 +2,13 @@ Quick links: -- [Design](design.md) โ€” architecture, model, algorithms, workflow -- [DSL Reference](dsl.md) โ€” YAML syntax for scenario definition -- [CLI Reference](cli.md) โ€” command-line tools for running scenarios -- [API Reference](api.md) โ€” Python API for programmatic scenario creation -- [Auto-Generated API Reference](api-full.md) โ€” complete class and method documentation +- [Design](design.md) - architecture, model, algorithms, workflow +- [DSL Reference](dsl.md) - YAML syntax for scenario definition +- [CLI Reference](cli.md) - command-line tools for running scenarios +- [API Reference](api.md) - Python API for programmatic scenario creation +- [Auto-Generated API Reference](api-full.md) - complete class and method documentation -A workflow is the ordered list of analysis steps a scenario runs. - -## Overview - -Each step computes one result (statistics, a Monte Carlo analysis, an export) and writes it under its step name in the results store. +A workflow is the ordered list of analysis steps a scenario runs. Each step computes one result (statistics, a Monte Carlo analysis, an export) and writes it under its step name in the results store. ```yaml workflow: @@ -32,13 +28,13 @@ workflow: - Steps run sequentially via `WorkflowStep.execute()`, which records timing and metadata and stores outputs under `{metadata, data}` for the step. - Monte Carlo steps (`MaxFlow`, `TrafficMatrixPlacement`) execute iterations using the Failure Manager. Each iteration analyzes the network with exclusion sets applied to mask failed nodes/links without mutating the base network. Workers are controlled by `parallelism: auto|int`. For `MaxFlow`, `auto` is the CPU count. For `TrafficMatrixPlacement`, `auto` is 1 unless the demand set uses an LSP preset or the interpreter is free-threaded, because iterations for the other presets are Python-bound and threads only slow them down; an explicit integer is always honoured. -- Seeding: a scenario-level `seed` derives per-step seeds unless a step sets an explicit `seed`. Metadata includes `scenario_seed`, `step_seed`, `seed_source`, and `active_seed`. `seed_source`/`active_seed` reflect the seed the step actually uses: a step constructed without its own seed reports `seed_source: none` even when the scenario has a seed (YAML-loaded scenarios derive per-step seeds at parse time, so those report `scenario-derived`). +- Seeding: a scenario-level `seed` derives per-step seeds unless a step sets an explicit `seed`. Metadata includes `scenario_seed`, `step_seed`, and `seed_source`. `seed_source` reflects the seed the step actually uses: a step constructed without its own seed reports `seed_source: none` even when the scenario has a seed (YAML-loaded scenarios derive per-step seeds at parse time, so those report `scenario-derived`). -## Core Workflow Steps +## Built-in Steps ### BuildGraph -Validates network topology and exports node-link JSON for external analysis. Optional for other workflow steps. +Exports the network as node-link JSON for external tools. No other step depends on it. ```yaml - type: BuildGraph @@ -48,11 +44,11 @@ Validates network topology and exports node-link JSON for external analysis. Opt Parameters: -- `add_reverse`: If `true`, adds reverse edges for each link to enable bidirectional connectivity. Set to `false` for directed-only graphs. Default: `true`. +- `add_reverse`: Add a reverse edge for each link. Default: `true`. ### NetworkStats -Compute node, link, and degree metrics. Supports temporary exclusions without modifying the base network. +Node, link, capacity and degree statistics, optionally with nodes or links excluded. ```yaml - type: NetworkStats @@ -89,6 +85,30 @@ Monte Carlo maximum flow analysis between node groups. Baseline (no failures) is include_min_cut: false # per-flow min-cut edge list ``` +Parameters: + +- `source`, `target`: Node selectors, a string pattern or a selector object (see Node Selection below). Required. +- `mode`: `combine` or `pairwise`. Default: `combine`. +- `failure_policy`: Name of a policy in the `failures` section. Default: none (no failures). +- `iterations`: Number of failure iterations; the no-failure baseline is extra. Default: `1`. +- `parallelism`: Worker threads, an integer or `auto` (the CPU count). Default: `auto`. +- `shortest_path`: Restrict flow to the lowest-cost paths. Default: `false`. +- `require_capacity`: Path selection considers residual capacity; `false` gives cost-only IP/IGP routing. Default: `true`. +- `flow_placement`: `PROPORTIONAL` or `EQUAL_BALANCED`. Default: `PROPORTIONAL`. +- `store_failure_patterns`: Record the failure trace on each result. Default: `false`. +- `include_flow_details`: Emit `cost_distribution` per flow. Default: `false`. +- `include_min_cut`: Emit the min-cut edge list per flow. Default: `false`. + +Outputs: + +- metadata: iterations, parallelism, analysis_function, policy_name, + execution_time, unique_patterns, occurrence_counts +- data.baseline and data.flow_results: see Results Export Shape below +- data.context: source, target, mode, shortest_path, require_capacity, + flow_placement, include_flow_details, include_min_cut +- each flow entry's `data` holds `edges`/`edges_kind: min_cut` with + `include_min_cut` + ### TrafficMatrixPlacement Monte Carlo placement of a named demand set with optional alpha scaling. Baseline (no failures) is always run first as a separate reference. @@ -103,12 +123,25 @@ Monte Carlo placement of a named demand set with optional alpha scaling. Baselin include_flow_details: true # cost_distribution per flow include_used_edges: false # include per-demand used edge lists store_failure_patterns: false - # Alpha scaling โ€“ explicit or from another step + # Alpha scaling โ€“ explicit (default 1.0) or from another step, not both alpha: 1.0 # alpha_from_step: msd_default # alpha_from_field: data.alpha_star ``` +Parameters: + +- `demand_set`: Name of the demand set to place. Required. +- `failure_policy`: Name of a policy in the `failures` section. Default: none (no failures). +- `iterations`: Number of failure iterations (>= 0). Default: `1`. +- `parallelism`: Worker threads, an integer or `auto`. Default: `auto` (see Execution Model). +- `store_failure_patterns`: Record the failure trace on each result. Default: `false`. +- `include_flow_details`: Emit `cost_distribution` per flow. Default: `false`. +- `include_used_edges`: Emit the used edge list per demand. Default: `false`. +- `alpha`: Demand volume multiplier, must be > 0. Default: `1.0`. Cannot be combined with `alpha_from_step`. +- `alpha_from_step`: Name of an earlier step whose result supplies alpha. +- `alpha_from_field`: Dotted path of the alpha value in that step's results. Default: `data.alpha_star`. + Outputs: - metadata: iterations, parallelism, analysis_function, policy_name, @@ -120,17 +153,14 @@ Outputs: `include_used_edges`, and `dropped_edges` (volume lost per link) for `SHORTEST_PATHS_ECMP_LOSSY` demands with `include_flow_details` -Note: `placement_rounds` is deprecated and has no effect. It is still accepted in YAML for backward compatibility and is not exported in `data.context`; setting it to any value other than `auto` also logs a deprecation warning. - ### MaximumSupportedDemand -Search for the maximum uniform traffic multiplier `alpha_star` that is fully placeable. +Search for the maximum uniform traffic multiplier `alpha_star` that is fully placeable. An alpha is feasible when every demand is placed to within the core engine's resolution of 1/4096 and no demand places nothing. ```yaml - type: MaximumSupportedDemand name: msd_default demand_set: default - acceptance_rule: hard # Currently only "hard" is supported alpha_start: 1.0 # Starting alpha value for search growth_factor: 2.0 # Growth factor for bracketing (must be > 1.0) alpha_min: 0.000001 # Minimum alpha bound (default: 1e-6) @@ -143,15 +173,13 @@ Search for the maximum uniform traffic multiplier `alpha_star` that is fully pla Parameters: - `demand_set`: Name of the demand set to analyze (default: "default"). -- `acceptance_rule`: Acceptance rule for feasibility (currently only "hard" is supported): every demand must be placed to within the core engine's resolution of 1/4096 and no demand may place nothing. -- `alpha_start`: Initial alpha value to probe. -- `growth_factor`: Multiplier for bracketing phase (must be > 1.0). -- `alpha_min`: Minimum alpha bound for search. -- `alpha_max`: Maximum alpha bound for search. -- `resolution`: Convergence threshold for bisection. -- `max_bracket_iters`: Maximum iterations for bracketing phase. -- `max_bisect_iters`: Maximum iterations for bisection phase. -- `placement_rounds`: Deprecated; accepted for backward compatibility but has no effect (each demand is placed in one deterministic pass, so repeated rounds change nothing). +- `alpha_start`: Initial alpha value to probe. Default: `1.0`. +- `growth_factor`: Multiplier for bracketing phase (must be > 1.0). Default: `2.0`. +- `alpha_min`: Minimum alpha bound for search. Default: `1e-6`. +- `alpha_max`: Maximum alpha bound for search. Default: `1e9`. +- `resolution`: Convergence threshold for bisection (must be positive). Default: `0.01`. +- `max_bracket_iters`: Maximum iterations for bracketing phase. Default: `32`. +- `max_bisect_iters`: Maximum iterations for bisection phase. Default: `32`. Outputs: @@ -171,117 +199,38 @@ Aggregate platform and optics capex/power by hierarchy level (split by `/`). aggregation_level: 2 ``` +Parameters: + +- `include_disabled`: If `true`, include disabled nodes and links. Default: `false`. +- `aggregation_level`: Deepest hierarchy level to report; levels `0..N` are produced and `0` is the root. Must be >= 0. Default: `2`. + Outputs: - data.context: include_disabled, aggregation_level -- data.levels: mapping level->list of {path, platform_capex, platform_power_watts, +- data.levels: mapping level (`"0"`..`"N"`) -> list of {path, platform_capex, platform_power_watts, optics_capex, optics_power_watts, capex_total, power_total_watts} CostPower performs no hardware capacity/ports validation and completes even on networks that strict hardware validation would reject; use `ngraph inspect` for hardware validation. -## Node Selection Mechanism - -Every workflow step selects nodes the same way: a selector is either a string pattern or a selector object. - -### String Pattern Matching - -String patterns are regular expressions matched against node names, anchored at the start (Python `re.match()`). +## Node Selection -```yaml -# Exact match (also matches names that continue past it, e.g. "spine-10") -source: "spine-1" - -# Prefix match -source: "datacenter/servers/" - -# Pattern match -source: "^pod[1-3]/leaf/.*$" -``` +`MaxFlow` `source` and `target` accept a string pattern or a selector object; the syntax is the one described in the [DSL Reference](dsl.md#node-selection). -### Selector Objects +- A string is a regular expression matched against node names from the start (Python `re.match()`), so `"spine-1"` also matches `"spine-10"`; anchor with `^...$` for an exact match. +- Capturing groups define the groups: each distinct captured value (several captures joined with `|`) becomes one group, and a pattern without captures forms a single group labeled by the pattern. +- A selector object combines `path` (a regex), `group_by` (an attribute whose values become the groups) and `match` (attribute conditions). ```yaml -# Attribute-based grouping -source: - group_by: "dc" - -# Combined path and grouping -source: - path: "^datacenter/.*" - group_by: "role" - -# With attribute filtering -source: +source: "(dc[1-3])/servers/.*" # one group per captured value: dc1, dc2, dc3 +target: path: "^pod[1-3]/.*" + group_by: "role" match: conditions: - - attr: "tier" - op: "==" - value: "leaf" -``` - -### Capturing Groups for Node Grouping - -**No Capturing Groups**: All matching nodes form one group labeled by the pattern. - -```yaml -source: "edge/.*" -# Creates one group: "edge/.*" containing all matching nodes -``` - -**Single Capturing Group**: Each unique captured value creates a separate group. - -```yaml -source: "(dc[1-3])/servers/.*" -# Creates groups: "dc1", "dc2", "dc3" -# Each group contains servers from that datacenter -``` - -**Multiple Capturing Groups**: Group labels join captured values with `|`. - -```yaml -source: '(dc[1-3])/(spine|leaf)/switch-(\d+)' -# Creates groups: "dc1|spine|1", "dc1|leaf|2", "dc2|spine|1", etc. -``` - -### Attribute-based Grouping - -```yaml -# Group by node attribute value (e.g., node.attrs["dc"]) -source: - group_by: "dc" -``` - -### Flow Analysis Modes - -**`combine` Mode**: Aggregates all source matches into one virtual source, all target matches into one virtual target. Produces single flow value. - -**`pairwise` Mode**: Computes flow between each source group and target group pair. Produces flow matrix keyed by `(source_group, target_group)`. - -## MaxFlow Parameters - -### Required Parameters - -- `source`: Node selector for source nodes (string pattern or selector object) -- `target`: Node selector for target nodes (string pattern or selector object) - -### Analysis Configuration - -```yaml -mode: combine # combine | pairwise (default: combine) -iterations: 1000 # Failure iterations to run (default: 1) -failure_policy: policy_name # Name in failures section (default: null) -parallelism: auto # Worker threads (default: auto) -shortest_path: false # Restrict to shortest paths (default: false) -require_capacity: true # Path selection considers capacity (default: true) - # Set false for true IP/IGP semantics (cost-only routing) -flow_placement: PROPORTIONAL # PROPORTIONAL | EQUAL_BALANCED -store_failure_patterns: false # Store failure patterns in results -include_flow_details: false # Emit cost_distribution per flow -include_min_cut: false # Emit min-cut edge list per flow + - {attr: "tier", op: "==", value: "leaf"} ``` -Note: Baseline (no failures) is always run first as a separate reference; `iterations` counts failure scenarios only. +`mode: combine` aggregates all source matches into one virtual source and all target matches into one virtual target and produces one flow value; `mode: pairwise` computes a flow for each (source group, target group) pair. ## Results Export Shape @@ -296,8 +245,7 @@ Exported results have a fixed top-level structure. Keys under `workflow` and `st "execution_order": 0, "scenario_seed": 42, "step_seed": 1903777304, - "seed_source": "scenario-derived", - "active_seed": 1903777304 + "seed_source": "scenario-derived" } }, "steps": { @@ -340,7 +288,8 @@ Exported results have a fixed top-level structure. Keys under `workflow` and `st "failure_trace": null, "occurrence_count": 1, "flows": [ ... ], - "summary": { "total_demand": 10.0, "total_placed": 10.0, "overall_ratio": 1.0 } + "summary": { "total_demand": 10.0, "total_placed": 10.0, "overall_ratio": 1.0, "dropped_flows": 0, "num_flows": 2 }, + "data": {} }, "flow_results": [ { @@ -349,7 +298,8 @@ Exported results have a fixed top-level structure. Keys under `workflow` and `st "failure_trace": { "mode_index": 0, "selections": [...], ... }, "occurrence_count": 5, "flows": [ ... ], - "summary": { "total_demand": 10.0, "total_placed": 8.0, "overall_ratio": 0.8 } + "summary": { "total_demand": 10.0, "total_placed": 8.0, "overall_ratio": 0.8, "dropped_flows": 1, "num_flows": 2 }, + "data": {} } ], "context": { ... } @@ -365,4 +315,5 @@ Notes: - `failure_trace` contains policy selection details when `store_failure_patterns: true`. - `failure_state` contains `excluded_nodes` and `excluded_links` lists. - `cost_distribution` uses string keys for JSON stability; values are numeric. +- `data` on each entry is reserved for per-iteration extras; the built-in analyses leave it empty. - Effective `parallelism` and other execution fields are recorded in step metadata. diff --git a/ngraph/__init__.py b/ngraph/__init__.py index b575351..08594ab 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -5,7 +5,7 @@ Primary API: analyze() - Create an analysis context for network queries - AnalysisContext - Prepared state for efficient repeated analysis + AnalysisContext - Prepared graph state reused across queries Network, Node, Link - Network topology model from_networkx() - Convert NetworkX graph to internal format to_networkx() - Convert internal format back to NetworkX @@ -22,7 +22,7 @@ # One-off analysis flow = analyze(net).max_flow("^A$", "^B$") - # Efficient repeated analysis + # Bound context: graph built once, reused across queries ctx = analyze(net, source="^A$", sink="^B$") baseline = ctx.max_flow() degraded = ctx.max_flow(excluded_links=failed_links) diff --git a/ngraph/analysis/__init__.py b/ngraph/analysis/__init__.py index f27db7a..a18e219 100644 --- a/ngraph/analysis/__init__.py +++ b/ngraph/analysis/__init__.py @@ -17,7 +17,6 @@ from __future__ import annotations -from ngraph.analysis.context import LARGE_CAPACITY as LARGE_CAPACITY from ngraph.analysis.context import ( AnalysisContext, AugmentationEdge, diff --git a/ngraph/analysis/context.py b/ngraph/analysis/context.py index 9d2b2b0..7b384a0 100644 --- a/ngraph/analysis/context.py +++ b/ngraph/analysis/context.py @@ -53,8 +53,7 @@ class AugmentationEdge: """Edge specification for graph augmentation. Augmentation edges are added to the graph as-is (unidirectional). - Nodes referenced in augmentations that don't exist in the network - are automatically treated as pseudo/virtual nodes. + Endpoints that are not network nodes become pseudo nodes. Attributes: source: Source node name (real or pseudo) @@ -154,19 +153,6 @@ def decode_ext_id(self, ext_id: int) -> Optional[EdgeRef]: direction = "rev" if dir_bit else "fwd" return EdgeRef(link_id=link_id, direction=direction) - def to_ref( - self, core_edge_id: int, multidigraph: netgraph_core.StrictMultiDiGraph - ) -> Optional[EdgeRef]: - ext_edge_ids = multidigraph.ext_edge_ids_view() - ext_id = ext_edge_ids[core_edge_id] - return self.decode_ext_id(int(ext_id)) - - def to_name(self, ext_id: int) -> Optional[str]: - if ext_id == -1: - return None - edge_ref = self.decode_ext_id(ext_id) - return edge_ref.link_id if edge_ref else None - @dataclass class _PseudoNodeContext: @@ -191,7 +177,7 @@ class AnalysisContext: Wraps the Core graph infrastructure. Two usage patterns: - **Unbound** - source/sink given per call: + **Unbound** (source/sink given per call): ctx = AnalysisContext.from_network(network) cost = ctx.shortest_path_cost("A", "B") @@ -201,7 +187,7 @@ class AnalysisContext: context, which rebuilds the graph from scratch; bind the context instead for repeated flow analysis. - **Bound** - source/sink fixed at construction, reused across calls: + **Bound** (source/sink fixed at construction, reused across calls): ctx = AnalysisContext.from_network( network, @@ -223,7 +209,7 @@ class AnalysisContext: is_bound: True if source/sink groups are pre-configured. """ - # Public read-only reference + # Exposed read-only through the `network` property _network: "Network" # Core infrastructure (internal). Built eagerly for bound contexts and @@ -244,11 +230,23 @@ class AnalysisContext: default_factory=threading.Lock, repr=False, compare=False ) + # Resolved pinned-route DAGs, keyed by (src, dst, routes); see + # ngraph.analysis.static_paths.build_static_path_bundles. + _static_path_cache: Dict[tuple, List[netgraph_core.PredDAG]] = field( + default_factory=dict, repr=False, compare=False + ) + @property def network(self) -> "Network": - """Reference to source network (read-only).""" + """Network this context was built from (read-only).""" return self._network + @property + def _bound_pseudo(self) -> _PseudoNodeContext: + """Pseudo-node pairs of a bound context (set whenever it is bound).""" + assert self._pseudo_context is not None, "context is not bound" + return self._pseudo_context + @property def is_bound(self) -> bool: """True if source/sink groups are pre-configured.""" @@ -297,7 +295,6 @@ def _ensure_core(self) -> _GraphBuildResult: if core is None: core = _build_graph_core( self._network, - add_reverse=True, augmentations=( list(self._augmentations) if self._augmentations else None ), @@ -396,24 +393,27 @@ def from_network( *, source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, augmentations: Optional[List[AugmentationEdge]] = None, ) -> "AnalysisContext": - """Create analysis context from network. + """Build a context for a network, bound when source and sink are given. Args: network: Network topology to analyze. source: Optional source node selector (string path or selector dict). If provided with sink, creates bound context with pre-built pseudo-nodes. sink: Optional sink node selector (string path or selector dict). - mode: Group mode (COMBINE or PAIRWISE). Only used if bound. - augmentations: Optional custom augmentation edges. + mode: Group mode of a bound context: COMBINE (default) or + PAIRWISE. Unbound calls choose the mode per method call. + augmentations: Extra edges added to the graph as-is (see + AugmentationEdge). Returns: AnalysisContext ready for analysis. Raises: - ValueError: If only one of source/sink is provided. + ValueError: If only one of source/sink is provided, or mode is + given without them. ValueError: If bound and no matching nodes found. ValueError: If any link capacity is at or above LARGE_CAPACITY (1e15, the internal pseudo-edge capacity), since such a link @@ -431,18 +431,22 @@ def from_network( """ if (source is None) != (sink is None): raise ValueError("source and sink must both be provided or both None") + if source is None and mode is not None: + raise ValueError( + "mode applies to a bound context; pass it with source and " + "sink, or per call on an unbound context" + ) + bound_mode = Mode.COMBINE if mode is None else mode - # Collect all augmentations all_augmentations: List[AugmentationEdge] = [] if augmentations: all_augmentations.extend(augmentations) - # Build pseudo node augmentations if source/sink provided pseudo_pairs: Optional[Dict[Tuple[str, str], Tuple[str, str]]] = None expected_pairs: Tuple[Tuple[str, str], ...] = () if source is not None and sink is not None: pseudo_augmentations, pseudo_pairs, expected_pairs = ( - _build_pseudo_node_augmentations(network, source, sink, mode) + _build_pseudo_node_augmentations(network, source, sink, bound_mode) ) all_augmentations.extend(pseudo_augmentations) @@ -453,11 +457,9 @@ def from_network( if all_augmentations or source is not None: core = _build_graph_core( network, - add_reverse=True, augmentations=all_augmentations if all_augmentations else None, ) - # Create pseudo context if bound pseudo_context: Optional[_PseudoNodeContext] = None if source is not None and sink is not None: assert core is not None # Bound contexts always build eagerly @@ -481,7 +483,7 @@ def from_network( _core=core, _source=source, _sink=sink, - _mode=mode if source is not None else None, + _mode=bound_mode if source is not None else None, _pseudo_context=pseudo_context, _augmentations=tuple(augmentations) if augmentations else (), ) @@ -494,22 +496,23 @@ def _dispatch_bound( self, source: Optional[Union[str, Dict[str, Any]]], sink: Optional[Union[str, Dict[str, Any]]], + mode: Optional[Mode] = None, ) -> bool: - """Validate source/sink against the binding state of this context. + """Validate source/sink/mode against the binding state of this context. Returns: True when the context is bound (dispatch to the *_bound path); False when unbound (source and sink are then non-None). Raises: - ValueError: If bound and source/sink are provided, or unbound - and source/sink are missing. + ValueError: If bound and source/sink/mode are provided, or + unbound and source/sink are missing. """ if self.is_bound: - if source is not None or sink is not None: + if source is not None or sink is not None or mode is not None: raise ValueError( - "Bound context: source/sink already configured. " - "Create new context for different groups." + "Bound context: source/sink/mode already configured. " + "Create a new context for different groups." ) return True if source is None or sink is None: @@ -521,7 +524,7 @@ def max_flow( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, shortest_path: bool = False, require_capacity: bool = True, flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, @@ -538,13 +541,13 @@ def max_flow( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). + mode: COMBINE (default) or PAIRWISE; unbound contexts only. shortest_path: If True, use only shortest paths (IP/IGP mode). require_capacity: If True (default), path selection considers available capacity. If False, path selection is cost-only - (true IP/IGP semantics where saturated paths still receive - traffic). For true IP simulation, use shortest_path=True - with require_capacity=False. + (IP/IGP semantics: saturated paths still receive traffic). + To model IP routing, use shortest_path=True with + require_capacity=False. flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). excluded_nodes: Nodes to exclude from this analysis. excluded_links: Links to exclude from this analysis. @@ -554,9 +557,9 @@ def max_flow( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. """ - if self._dispatch_bound(source, sink): + if self._dispatch_bound(source, sink, mode): return self._max_flow_bound( shortest_path=shortest_path, require_capacity=require_capacity, @@ -568,7 +571,7 @@ def max_flow( return self._max_flow_unbound( source=source, sink=sink, - mode=mode, + mode=Mode.COMBINE if mode is None else mode, shortest_path=shortest_path, require_capacity=require_capacity, flow_placement=flow_placement, @@ -581,7 +584,7 @@ def max_flow_detailed( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, shortest_path: bool = False, require_capacity: bool = True, flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, @@ -598,19 +601,23 @@ def max_flow_detailed( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). + mode: COMBINE (default) or PAIRWISE; unbound contexts only. shortest_path: If True, restricts flow to shortest paths. require_capacity: If True (default), path selection considers available capacity. If False, path selection is cost-only. - flow_placement: Flow placement strategy. + flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). excluded_nodes: Nodes to exclude from this analysis. excluded_links: Links to exclude from this analysis. include_min_cut: If True, compute and include min-cut edges. Returns: Dict mapping (source_label, sink_label) to MaxFlowResult. + + Raises: + ValueError: If unbound and source/sink not provided. + ValueError: If bound and source/sink/mode are provided. """ - if self._dispatch_bound(source, sink): + if self._dispatch_bound(source, sink, mode): return self._max_flow_detailed_bound( shortest_path=shortest_path, require_capacity=require_capacity, @@ -623,7 +630,7 @@ def max_flow_detailed( return self._max_flow_detailed_unbound( source=source, sink=sink, - mode=mode, + mode=Mode.COMBINE if mode is None else mode, shortest_path=shortest_path, require_capacity=require_capacity, flow_placement=flow_placement, @@ -637,7 +644,7 @@ def sensitivity( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, shortest_path: bool = False, require_capacity: bool = True, flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, @@ -656,18 +663,22 @@ def sensitivity( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). + mode: COMBINE (default) or PAIRWISE; unbound contexts only. shortest_path: If True, use shortest-path-only flow (IP/IGP mode). require_capacity: If True (default), path selection considers available capacity. If False, path selection is cost-only. - flow_placement: Flow placement strategy. + flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). excluded_nodes: Nodes to exclude from this analysis. excluded_links: Links to exclude from this analysis. Returns: Dict mapping (source_label, sink_label) to {link_id:direction: flow_reduction}. + + Raises: + ValueError: If unbound and source/sink not provided. + ValueError: If bound and source/sink/mode are provided. """ - if self._dispatch_bound(source, sink): + if self._dispatch_bound(source, sink, mode): return self._sensitivity_bound( shortest_path=shortest_path, require_capacity=require_capacity, @@ -679,7 +690,7 @@ def sensitivity( return self._sensitivity_unbound( source=source, sink=sink, - mode=mode, + mode=Mode.COMBINE if mode is None else mode, shortest_path=shortest_path, require_capacity=require_capacity, flow_placement=flow_placement, @@ -692,7 +703,7 @@ def sensitivity_with_flow( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, shortest_path: bool = False, require_capacity: bool = True, flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, @@ -713,11 +724,11 @@ def sensitivity_with_flow( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). + mode: COMBINE (default) or PAIRWISE; unbound contexts only. shortest_path: If True, use shortest-path-only flow (IP/IGP mode). require_capacity: If True (default), path selection considers available capacity. If False, path selection is cost-only. - flow_placement: Flow placement strategy. + flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). excluded_nodes: Nodes to exclude from this analysis. excluded_links: Links to exclude from this analysis. @@ -727,9 +738,9 @@ def sensitivity_with_flow( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. """ - if self._dispatch_bound(source, sink): + if self._dispatch_bound(source, sink, mode): return self._sensitivity_with_flow_bound( shortest_path=shortest_path, require_capacity=require_capacity, @@ -741,7 +752,7 @@ def sensitivity_with_flow( return self._sensitivity_with_flow_unbound( source=source, sink=sink, - mode=mode, + mode=Mode.COMBINE if mode is None else mode, shortest_path=shortest_path, require_capacity=require_capacity, flow_placement=flow_placement, @@ -758,8 +769,7 @@ def shortest_path_cost( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, - edge_select: EdgeSelect = EdgeSelect.ALL_MIN_COST, + mode: Optional[Mode] = None, excluded_nodes: Optional[Set[str]] = None, excluded_links: Optional[Set[str]] = None, ) -> Dict[Tuple[str, str], float]: @@ -772,8 +782,7 @@ def shortest_path_cost( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). - edge_select: SPF edge selection strategy. + mode: COMBINE (default) or PAIRWISE; unbound contexts only. excluded_nodes: Nodes to exclude from this analysis. excluded_links: Links to exclude from this analysis. @@ -782,18 +791,17 @@ def shortest_path_cost( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. ValueError: If no source nodes match source pattern. ValueError: If no sink nodes match sink pattern. """ resolved_source, resolved_sink, resolved_mode = self._resolve_source_sink( - source, sink, mode + source, sink, mode, Mode.COMBINE ) return self._shortest_path_costs_impl( source=resolved_source, sink=resolved_sink, mode=resolved_mode, - edge_select=edge_select, excluded_nodes=excluded_nodes, excluded_links=excluded_links, ) @@ -803,7 +811,7 @@ def shortest_paths( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, edge_select: EdgeSelect = EdgeSelect.ALL_MIN_COST, split_parallel_edges: bool = False, excluded_nodes: Optional[Set[str]] = None, @@ -818,7 +826,7 @@ def shortest_paths( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: COMBINE or PAIRWISE (ignored if bound). + mode: COMBINE (default) or PAIRWISE; unbound contexts only. edge_select: SPF edge selection strategy. split_parallel_edges: Expand parallel edges into distinct paths. excluded_nodes: Nodes to exclude from this analysis. @@ -829,10 +837,10 @@ def shortest_paths( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. """ resolved_source, resolved_sink, resolved_mode = self._resolve_source_sink( - source, sink, mode + source, sink, mode, Mode.COMBINE ) return self._shortest_paths_impl( source=resolved_source, @@ -849,9 +857,8 @@ def k_shortest_paths( source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, *, - mode: Mode = Mode.PAIRWISE, + mode: Optional[Mode] = None, max_k: int = 3, - edge_select: EdgeSelect = EdgeSelect.ALL_MIN_COST, max_path_cost: float = float("inf"), max_path_cost_factor: Optional[float] = None, split_parallel_edges: bool = False, @@ -867,14 +874,10 @@ def k_shortest_paths( source: Source node selector (required if unbound); see the class docstring for the accepted selector forms. sink: Sink node selector (required if unbound). - mode: PAIRWISE (default) or COMBINE (ignored if bound). - max_k: Maximum paths per pair. - edge_select: SPF/KSP edge selection strategy. Note: it governs - only the pruning SPF pass; Core's KSP enumeration uses a - fixed internal selection (all parallel min-cost edges, - capacity-blind, deterministic tie-break), so SINGLE_MIN_COST - may yield one path per parallel edge where `shortest_paths` - returns one. + mode: PAIRWISE (default) or COMBINE; unbound contexts only. + max_k: Maximum paths per pair. Core's KSP enumeration keeps all + parallel min-cost edges (capacity-blind, deterministic + tie-break). max_path_cost: Absolute cost threshold. max_path_cost_factor: Relative threshold versus best path. split_parallel_edges: Expand parallel edges into distinct paths. @@ -886,17 +889,16 @@ def k_shortest_paths( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. """ resolved_source, resolved_sink, resolved_mode = self._resolve_source_sink( - source, sink, mode + source, sink, mode, Mode.PAIRWISE ) return self._k_shortest_paths_impl( source=resolved_source, sink=resolved_sink, mode=resolved_mode, max_k=max_k, - edge_select=edge_select, max_path_cost=max_path_cost, max_path_cost_factor=max_path_cost_factor, split_parallel_edges=split_parallel_edges, @@ -912,14 +914,16 @@ def _resolve_source_sink( self, source: Optional[Union[str, Dict[str, Any]]], sink: Optional[Union[str, Dict[str, Any]]], - mode: Mode, + mode: Optional[Mode], + default_mode: Mode, ) -> Tuple[Union[str, Dict[str, Any]], Union[str, Dict[str, Any]], Mode]: """Resolve source/sink from arguments or bound context. Args: source: Source selector from method call (or None). sink: Sink selector from method call (or None). - mode: Mode from method call. + mode: Mode from method call (or None). + default_mode: Mode of an unbound call that passes none. Returns: Tuple of (resolved_source, resolved_sink, resolved_mode). @@ -927,13 +931,12 @@ def _resolve_source_sink( Raises: ValueError: If unbound and source/sink not provided. - ValueError: If bound and source/sink are provided. + ValueError: If bound and source/sink/mode are provided. """ - if self._dispatch_bound(source, sink): - # Use bound values (can be str or dict) + if self._dispatch_bound(source, sink, mode): return self._source, self._sink, self._mode # type: ignore[return-value] assert source is not None and sink is not None - return source, sink, mode + return source, sink, default_mode if mode is None else mode def build_node_mask(self, excluded_nodes: Optional[Set[str]] = None) -> np.ndarray: """Build a node inclusion mask for Core algorithms. @@ -982,8 +985,8 @@ def build_edge_mask(self, excluded_links: Optional[Set[str]] = None) -> np.ndarr Returns: Boolean numpy array of shape (num_edges,) where True means - included. There is one entry per Core edge - forward and reverse - direction of each link, plus any augmentation edges - not one + included. There is one entry per Core edge (forward and reverse + direction of each link, plus any augmentation edges), not one entry per link. """ num_edges = self._multidigraph.num_edges() @@ -1042,7 +1045,7 @@ def _max_flow_bound( node_mask = self.build_node_mask(excluded_nodes) edge_mask = self.build_edge_mask(excluded_links) - pseudo_node_pairs = self._pseudo_context.pairs if self._pseudo_context else {} + pseudo_node_pairs = self._bound_pseudo.pairs results: Dict[Tuple[str, str], float] = {} for pair_key, (pseudo_src_id, pseudo_snk_id) in pseudo_node_pairs.items(): @@ -1058,7 +1061,7 @@ def _max_flow_bound( ) results[pair_key] = flow_value - # Fill missing pairs (overlapping src/snk) + # Pairs skipped at build time (empty or overlapping groups) get 0.0 self._fill_missing_pairs_bound(results, lambda: 0.0) return results @@ -1118,7 +1121,7 @@ def _max_flow_detailed_bound( edge_mask = self.build_edge_mask(excluded_links) ext_edge_ids = self._multidigraph.ext_edge_ids_view() - pseudo_node_pairs = self._pseudo_context.pairs if self._pseudo_context else {} + pseudo_node_pairs = self._bound_pseudo.pairs results: Dict[Tuple[str, str], MaxFlowResult] = {} for pair_key, (pseudo_src_id, pseudo_snk_id) in pseudo_node_pairs.items(): @@ -1149,7 +1152,6 @@ def _max_flow_detailed_bound( flow_value, core_summary, min_cut_edges ) - # Fill missing pairs self._fill_missing_pairs_bound(results, lambda: _construct_max_flow_result(0.0)) return results @@ -1203,7 +1205,7 @@ def _sensitivity_bound( edge_mask = self.build_edge_mask(excluded_links) ext_edge_ids = self._multidigraph.ext_edge_ids_view() - pseudo_node_pairs = self._pseudo_context.pairs if self._pseudo_context else {} + pseudo_node_pairs = self._bound_pseudo.pairs results: Dict[Tuple[str, str], Dict[str, float]] = {} for pair_key, (pseudo_src_id, pseudo_snk_id) in pseudo_node_pairs.items(): @@ -1266,7 +1268,7 @@ def _sensitivity_with_flow_bound( edge_mask = self.build_edge_mask(excluded_links) ext_edge_ids = self._multidigraph.ext_edge_ids_view() - pseudo_node_pairs = self._pseudo_context.pairs if self._pseudo_context else {} + pseudo_node_pairs = self._bound_pseudo.pairs results: Dict[Tuple[str, str], Tuple[float, Dict[str, float]]] = {} for pair_key, (pseudo_src_id, pseudo_snk_id) in pseudo_node_pairs.items(): @@ -1324,16 +1326,14 @@ def _sensitivity_with_flow_unbound( def _fill_missing_pairs_bound( self, results: Dict, default_factory: Callable[[], Any] ) -> None: - """Fill results for pairs not in the graph (e.g., overlapping). + """Fill results for pairs skipped at build time (empty or overlapping groups). Uses pair keys precomputed at bind time; no node selection is re-run (the context is immutable after creation). The factory is called once per missing pair so mutable defaults (dicts, result objects) are never aliased across pairs. """ - if not self._pseudo_context: - return - for pair_key in self._pseudo_context.expected_pairs: + for pair_key in self._bound_pseudo.expected_pairs: if pair_key not in results: results[pair_key] = default_factory() @@ -1343,16 +1343,18 @@ def _shortest_path_costs_impl( source: Union[str, Dict[str, Any]], sink: Union[str, Dict[str, Any]], mode: Mode, - edge_select: EdgeSelect, excluded_nodes: Optional[Set[str]], excluded_links: Optional[Set[str]], ) -> Dict[Tuple[str, str], float]: - """Implementation of shortest_path_cost.""" + """Implementation of shortest_path_cost. + + Only SPF distances are read, which do not depend on edge selection. + """ src_groups, snk_groups = _resolve_selector_groups(self._network, source, sink) node_mask = self.build_node_mask(excluded_nodes) edge_mask = self.build_edge_mask(excluded_links) - core_edge_select = self._map_edge_select(edge_select) + core_edge_select = self._map_edge_select(EdgeSelect.ALL_MIN_COST) def _best_cost_for_groups(src_names: List[str], snk_names: List[str]) -> float: if not src_names or not snk_names: @@ -1504,7 +1506,6 @@ def _k_shortest_paths_impl( sink: Union[str, Dict[str, Any]], mode: Mode, max_k: int, - edge_select: EdgeSelect, max_path_cost: float, max_path_cost_factor: Optional[float], split_parallel_edges: bool, @@ -1516,7 +1517,7 @@ def _k_shortest_paths_impl( node_mask = self.build_node_mask(excluded_nodes) edge_mask = self.build_edge_mask(excluded_links) - core_edge_select = self._map_edge_select(edge_select) + core_edge_select = self._map_edge_select(EdgeSelect.ALL_MIN_COST) def _ksp_for_groups(src_names: List[str], snk_names: List[str]) -> List[Path]: if not src_names or not snk_names: @@ -1655,8 +1656,7 @@ def _path_sort_key(path: Path) -> Tuple[Any, ...]: directions). Any two distinct paths differ in the key, so sorted output and equal-cost truncation (e.g., k_shortest_paths max_k) are independent of hash order. Paths that differ only in which parallel - link they traverse still order by link id, which embeds a build-time - UUID; node-sequence-level selection is stable across runs. + link they traverse order by link id (`src|dst|seq`). """ return ( path.cost, @@ -1778,7 +1778,7 @@ def _build_pseudo_node_augmentations( @dataclass class _GraphBuildResult: - """Intermediate result from _build_graph_core.""" + """Core graph and lookup tables built by _build_graph_core.""" _handle: netgraph_core.Graph _multidigraph: netgraph_core.StrictMultiDiGraph @@ -1793,13 +1793,18 @@ class _GraphBuildResult: def _build_graph_core( network: "Network", *, - add_reverse: bool = True, augmentations: Optional[List[AugmentationEdge]] = None, ) -> _GraphBuildResult: - """Build Core graph infrastructure from Network.""" + """Build the Core graph, ID mappers and disabled-element lookups. + + Raises: + ValueError: If a link capacity is at or above LARGE_CAPACITY, or a + link or augmentation cost is negative or non-integer, or the + total of all edge costs reaches 2**62. + """ real_node_names = set(network.nodes.keys()) - # Infer pseudo nodes from augmentations + # Augmentation endpoints that are not network nodes become pseudo nodes pseudo_node_names: Set[str] = set() if augmentations: for aug_edge in augmentations: @@ -1826,22 +1831,14 @@ def _build_graph_core( src_id = node_mapper.to_id(link.source) dst_id = node_mapper.to_id(link.target) - # Forward edge - src_list.append(src_id) - dst_list.append(dst_id) - capacity_list.append(link.capacity) - cost_list.append(link.cost) + # Forward and reverse edge: links are bidirectional + src_list.extend((src_id, dst_id)) + dst_list.extend((dst_id, src_id)) + capacity_list.extend((link.capacity, link.capacity)) + cost_list.extend((link.cost, link.cost)) ext_edge_id_list.append(edge_mapper.encode_ext_id(link_id, "fwd")) + ext_edge_id_list.append(edge_mapper.encode_ext_id(link_id, "rev")) - # Reverse edge - if add_reverse: - src_list.append(dst_id) - dst_list.append(src_id) - capacity_list.append(link.capacity) - cost_list.append(link.cost) - ext_edge_id_list.append(edge_mapper.encode_ext_id(link_id, "rev")) - - # Add augmentation edges if augmentations: for aug_edge in augmentations: src_id = node_mapper.to_id(aug_edge.source) @@ -1886,7 +1883,7 @@ def _build_graph_core( ) if not np.array_equal(cost_f, np.trunc(cost_f)): bad_indices = np.nonzero(cost_f != np.trunc(cost_f))[0] - edges_per_link = 2 if add_reverse else 1 + edges_per_link = 2 # forward and reverse edge per link num_link_edges = len(link_ids) * edges_per_link offenders: List[str] = [] for idx in bad_indices: @@ -1919,18 +1916,15 @@ def _build_graph_core( algorithms = netgraph_core.Algorithms(backend) handle = algorithms.build_graph(multidigraph) - # Pre-compute disabled node IDs disabled_node_ids: Set[int] = set() for node_name, node in network.nodes.items(): if node.disabled and node_name in node_mapper.node_id_of: disabled_node_ids.add(node_mapper.node_id_of[node_name]) - # Pre-compute disabled link IDs disabled_link_ids: Set[str] = { link_id for link_id, link in network.links.items() if link.disabled } - # Pre-compute link_id -> edge indices mapping ext_edge_ids = multidigraph.ext_edge_ids_view() link_id_to_edge_indices: Dict[str, List[int]] = {} for edge_idx in range(len(ext_edge_ids)): @@ -1962,7 +1956,7 @@ def _construct_max_flow_result( core_summary=None, min_cut: Optional[Tuple[EdgeRef, ...]] = None, ) -> MaxFlowResult: - """Construct MaxFlowResult from Core results.""" + """Build a MaxFlowResult; without core_summary the cost distribution is empty.""" cost_dist: Dict[float, float] = {} if core_summary is not None and len(core_summary.costs) > 0: cost_dist = { @@ -1986,7 +1980,7 @@ def _extract_paths_from_pred_dag( multidigraph: netgraph_core.StrictMultiDiGraph, split_parallel_edges: bool, ) -> List[Path]: - """Extract Path objects from a PredDAG.""" + """Resolve src->snk paths in a PredDAG to Path objects, dropping pseudo edges.""" src_id = node_mapper.to_id(src_name) snk_id = node_mapper.to_id(snk_name) @@ -2018,7 +2012,7 @@ def _extract_paths_from_pred_dag( # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Module-level utilities for advanced/workflow use +# Public entry point # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -2027,7 +2021,7 @@ def analyze( *, source: Optional[Union[str, Dict[str, Any]]] = None, sink: Optional[Union[str, Dict[str, Any]]] = None, - mode: Mode = Mode.COMBINE, + mode: Optional[Mode] = None, augmentations: Optional[List[AugmentationEdge]] = None, ) -> AnalysisContext: """Create an analysis context for the network. @@ -2040,15 +2034,17 @@ def analyze( If provided with sink, creates a bound context whose pseudo nodes are pre-built once and reused by every flow call. sink: Optional sink node selector (string path or selector dict). - mode: Group mode (COMBINE or PAIRWISE). Only used if bound. - augmentations: Optional custom augmentation edges. + mode: Group mode of a bound context: COMBINE (default) or PAIRWISE. + Unbound calls choose the mode per method call. + augmentations: Extra edges added to the graph as-is (see + AugmentationEdge). Returns: AnalysisContext ready for analysis calls. Raises: - ValueError: If only one of source/sink is provided, or if a bound - selector matches no nodes. + ValueError: If only one of source/sink is provided, if mode is given + without them, or if a bound selector matches no nodes. ValueError: If any link capacity is at or above LARGE_CAPACITY (1e15, the internal pseudo-edge capacity), since such a link would be silently clamped by the pseudo attachment edges in combine-mode diff --git a/ngraph/analysis/demand.py b/ngraph/analysis/demand.py index b2bae2c..443ac5f 100644 --- a/ngraph/analysis/demand.py +++ b/ngraph/analysis/demand.py @@ -13,7 +13,7 @@ from ngraph.analysis.context import LARGE_CAPACITY, AugmentationEdge from ngraph.dsl.selectors import normalize_selector from ngraph.model.demand.spec import StaticPath, TrafficDemand -from ngraph.model.flow.policy_config import FlowPolicyPreset +from ngraph.model.flow.policy_config import DEFAULT_PRESET, FlowPolicyPreset from ngraph.model.network import Network, Node from ngraph.model.selectors import select_nodes @@ -50,7 +50,7 @@ class ExpandedDemand: @dataclass class DemandExpansion: - """Demand expansion result. + """Output of expand_demands. Attributes: demands: Concrete demands ready for placement (sorted by priority). @@ -82,8 +82,8 @@ def _expand_combine( ) -> tuple[list[ExpandedDemand], list[AugmentationEdge]]: """Expand combine mode: aggregate sources/sinks through pseudo nodes. - The pseudo source is a virtual source, a pool of the selected sources: a - TE preset carries the aggregate with whichever sources have capacity. + The pseudo source pools the selected sources: a TE preset carries the + aggregate over whichever sources have capacity. Hop-by-hop presets cannot steer origination, so placement originates an even share at every member of ``src_members`` that can reach a target instead; the pseudo sink still delivers each share to that source's @@ -111,15 +111,12 @@ def _expand_combine( augmentations = [] - # Pseudo-source -> real sources (unidirectional OUT) for src_name in src_names: augmentations.append(AugmentationEdge(pseudo_src, src_name, LARGE_CAPACITY, 0)) - # Real targets -> pseudo-target (unidirectional IN) for dst_name in dst_names: augmentations.append(AugmentationEdge(dst_name, pseudo_snk, LARGE_CAPACITY, 0)) - # Single aggregated demand expanded = ExpandedDemand( src_name=pseudo_src, dst_name=pseudo_snk, @@ -138,11 +135,13 @@ def _expand_pairwise( dst_groups: Dict[str, List[Node]], policy_preset: FlowPolicyPreset, ) -> tuple[list[ExpandedDemand], list[AugmentationEdge]]: - """Expand pairwise mode: create demand for each (src, dst) pair.""" + """Expand pairwise mode: one demand per (src, dst) pair. + + Self-pairs are skipped and td.volume is split evenly across the rest. + """ src_nodes = _flatten_groups(src_groups) dst_nodes = _flatten_groups(dst_groups) - # Filter self-pairs pairs = [ (src, dst) for src in src_nodes for dst in dst_nodes if src.name != dst.name ] @@ -150,7 +149,6 @@ def _expand_pairwise( if not pairs: return [], [] - # Distribute volume evenly volume_per_pair = td.volume / len(pairs) demands = [ @@ -165,7 +163,7 @@ def _expand_pairwise( for src, dst in pairs ] - return demands, [] # No augmentations for pairwise + return demands, [] def _expand_by_group_mode( @@ -195,7 +193,6 @@ def _expand_by_group_mode( # td.mode and td.group_mode are validated by TrafficDemand.__post_init__, # so mode is "combine" or "pairwise" in every branch below. if td.group_mode == "flatten": - # Standard behavior: flatten all groups, then apply mode if td.mode == "combine": return _expand_combine(td, src_groups, dst_groups, policy_preset) return _expand_pairwise(td, src_groups, dst_groups, policy_preset) @@ -252,7 +249,6 @@ def _expand_by_group_mode( if not group_pairs: return [], [] - # Divide volume among group pairs volume_per_group_pair = td.volume / len(group_pairs) for pair_index, (src_label, dst_label) in enumerate(group_pairs): @@ -286,26 +282,23 @@ def _expand_by_group_mode( def expand_demands( network: Network, traffic_demands: List[TrafficDemand], - default_policy_preset: FlowPolicyPreset = FlowPolicyPreset.SHORTEST_PATHS_ECMP, ) -> DemandExpansion: """Expand TrafficDemand specifications into concrete demands with augmentations. - Pure function that: - 1. Normalizes and evaluates selectors to get node groups - 2. Distributes volume based on mode (combine/pairwise) and group_mode - 3. Generates augmentation edges for combine mode (pseudo nodes) - 4. Returns demands (node names) + augmentations + Resolves each demand's selectors to node groups, splits its volume by + mode and group_mode, and generates pseudo-node augmentation edges for + combine mode. Inputs are not modified. - Node names are used (not IDs) so expansion happens BEFORE graph building. - IDs are resolved after graph is built with augmentations. + Demands carry node names, not IDs, so expansion runs before the graph is + built; IDs are resolved once the graph includes the augmentations. - Note: Variable expansion (expand: block) is handled during YAML parsing in - build_demand_set(), so TrafficDemand objects here are already expanded. + Variable expansion (`expand:` blocks) happens earlier, during YAML parsing + in build_demand_set(), so the TrafficDemand objects here are already + expanded. Args: network: Network for node selection. traffic_demands: High-level demand specifications. - default_policy_preset: Default policy if demand doesn't specify one. Returns: DemandExpansion with demands and augmentations. @@ -330,11 +323,9 @@ def expand_demands( all_augmentations: List[AugmentationEdge] = [] for td in traffic_demands: - # Step 1: Normalize selectors src_sel = normalize_selector(td.source, "demand") tgt_sel = normalize_selector(td.target, "demand") - # Step 2: Select nodes (active_only=True for demands by context default) src_groups = select_nodes(network, src_sel, default_active_only=True) dst_groups = select_nodes(network, tgt_sel, default_active_only=True) @@ -346,9 +337,8 @@ def expand_demands( ) continue - policy_preset = td.flow_policy or default_policy_preset + policy_preset = td.flow_policy or DEFAULT_PRESET - # Step 3: Expand by group_mode demands, augmentations = _expand_by_group_mode( td, src_groups, dst_groups, policy_preset ) @@ -402,7 +392,8 @@ def expand_demands( ) seen_endpoints.add(name) - # Sort by priority (lower = higher priority) + # Lower value = higher priority; the stable sort keeps expansion order + # within a priority class. sorted_demands = sorted(all_demands, key=lambda d: d.priority) return DemandExpansion(demands=sorted_demands, augmentations=all_augmentations) diff --git a/ngraph/analysis/failure_manager.py b/ngraph/analysis/failure_manager.py index 199a775..955fd3e 100644 --- a/ngraph/analysis/failure_manager.py +++ b/ngraph/analysis/failure_manager.py @@ -8,15 +8,15 @@ Time complexity: O(S + I * A / P), where S is one-time graph setup cost, I is iteration count, A is per-iteration analysis cost, and P is parallelism. Graph caching amortizes graph construction across all iterations: each -iteration applies its exclusions as an O(|excluded|) mask update instead of -rebuilding the graph or re-scanning all O(V+E) nodes and edges. +iteration applies its exclusions as boolean masks (a vectorized O(V+E) fill +plus O(|excluded|) updates) instead of rebuilding the graph. Space complexity: O(V + E + I * R), where V and E are node and link counts, and R is result size per iteration. The pre-built graph is shared across all iterations. -Parallelism: The C++ Core backend releases the GIL during computation, -enabling true parallelism with Python threads. With graph caching, most +Parallelism: The C++ Core backend releases the GIL during computation, so +Python threads run Core work in parallel. With graph caching, most per-iteration work runs in GIL-free C++ code; speedup depends on workload and parallelism level. """ @@ -25,11 +25,14 @@ import hashlib import os +import threading import time +from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol, Set from ngraph.logging import get_logger +from ngraph.model.demand.matrix import DemandSet from ngraph.model.failure.policy_set import FailurePolicySet from ngraph.model.selectors import ( flatten_link_attrs, @@ -49,66 +52,19 @@ sensitivity_analysis, ) from ngraph.model.failure.policy import FailurePolicy +from ngraph.results.flow import FlowIterationResult logger = get_logger(__name__) -def _is_hashable(obj: Any) -> bool: - """Return True if obj is hashable, False otherwise.""" - try: - hash(obj) - return True - except TypeError: - return False +def _create_dedup_key(excluded_nodes: Set[str], excluded_links: Set[str]) -> tuple: + """Create the deduplication key of one failure pattern. - -def _hashable_kwargs_key(analysis_kwargs: Dict[str, Any]) -> tuple: - """Build the hashable, order-independent kwargs component of a dedup key. - - This part of the key is invariant across Monte Carlo iterations, so - callers should compute it once and combine it with the per-iteration - exclusion sets via `_create_dedup_key`. - """ - hashable_kwargs = [] - for key, value in sorted(analysis_kwargs.items()): - if _is_hashable((key, value)): - hashable_kwargs.append((key, value)) - else: - # Use object id for non-hashable values. Avoid str() which triggers - # deep __repr__ traversals on large objects (e.g., graphs with - # thousands of edges). id() is safe here because these objects - # persist across calls within one FailureManager lifetime. - hashable_kwargs.append((key, f"{type(value).__name__}_{id(value)}")) - return tuple(hashable_kwargs) - - -def _create_dedup_key( - excluded_nodes: Set[str], - excluded_links: Set[str], - analysis_name: str, - kwargs_key: tuple, -) -> tuple: - """Create deduplication key from exclusions, analysis name, and parameters. - - The key identifies identical failure patterns before dispatch: iterations - sharing a key are executed once and fanned back out via occurrence_count. - This is pre-dispatch deduplication, not a result cache. - - Args: - excluded_nodes: Set of excluded node names. - excluded_links: Set of excluded link IDs. - analysis_name: Name of the analysis function. - kwargs_key: Precomputed `_hashable_kwargs_key(analysis_kwargs)`. - - Returns: - Tuple suitable for use as a deduplication key. + Iterations sharing a key are executed once and fanned back out via + occurrence_count. The analysis function and its kwargs are fixed within + one run, so the exclusion sets alone identify the work. """ - return ( - tuple(sorted(excluded_nodes)), - tuple(sorted(excluded_links)), - analysis_name, - kwargs_key, - ) + return (tuple(sorted(excluded_nodes)), tuple(sorted(excluded_links))) class AnalysisFunction(Protocol): @@ -119,7 +75,7 @@ class AnalysisFunction(Protocol): """ def __call__(self, *args: Any, **kwargs: Any) -> Any: - """Execute analysis on network with exclusions and parameters.""" + """Called as func(network, excluded_nodes, excluded_links, **kwargs).""" ... @@ -152,51 +108,43 @@ def _generic_worker(args: tuple[Any, ...]) -> Any: analysis_name, ) = args - # Optional per-worker profiling for performance analysis + # Optional per-worker profiling. Python 3.12+ allows one active profiler + # per interpreter, which already sees worker threads, so enable() raises + # there and only 3.11 collects per-worker profiles. profile_dir_env = os.getenv("NGRAPH_PROFILE_DIR") - collect_profile: bool = bool(profile_dir_env) - profiler: "cProfile.Profile | None" = None - if collect_profile: + if profile_dir_env: import cProfile profiler = cProfile.Profile() try: profiler.enable() except ValueError: - # Another profiler is already active (e.g., pytest-cov in threading mode) profiler = None - collect_profile = False - - import threading worker_id = threading.current_thread().name worker_logger.debug( - f"Worker {worker_id} starting: iteration={iteration_index}, " + f"Worker {worker_id} running {analysis_name}: iteration={iteration_index}, " f"excluded_nodes={len(excluded_nodes)}, excluded_links={len(excluded_links)}" ) - - worker_logger.debug(f"Worker {worker_id} executing {analysis_name}") result = analysis_func(network, excluded_nodes, excluded_links, **analysis_kwargs) worker_logger.debug(f"Worker {worker_id} completed analysis") - # Dump profile if enabled (for performance analysis) - if profiler is not None: + if profiler is not None and profile_dir_env: profiler.disable() import pstats import uuid from pathlib import Path - profile_dir = Path(profile_dir_env) if profile_dir_env else None - if profile_dir is not None: - profile_dir.mkdir(parents=True, exist_ok=True) - unique_id = uuid.uuid4().hex[:8] - thread_id = threading.current_thread().ident - profile_path = ( - profile_dir / f"{analysis_name}_thread_{thread_id}_{unique_id}.pstats" - ) - pstats.Stats(profiler).dump_stats(profile_path) - worker_logger.debug("Saved worker profile to %s", profile_path.name) + profile_dir = Path(profile_dir_env) + profile_dir.mkdir(parents=True, exist_ok=True) + unique_id = uuid.uuid4().hex[:8] + thread_id = threading.current_thread().ident + profile_path = ( + profile_dir / f"{analysis_name}_thread_{thread_id}_{unique_id}.pstats" + ) + pstats.Stats(profiler).dump_stats(profile_path) + worker_logger.debug("Saved worker profile to %s", profile_path.name) return result @@ -223,7 +171,7 @@ def __init__( failure_policy_set: FailurePolicySet, policy_name: str | None = None, ) -> None: - """Initialize FailureManager. + """Store the inputs; policy and risk-group caches are built lazily. Args: network: Network to analyze (read-only, not modified). @@ -257,7 +205,7 @@ def __init__( self._prepared_rg_index: dict[str, set[str]] | None = None def get_failure_policy(self) -> "FailurePolicy | None": - """Get failure policy for analysis. + """Return the failure policy named by policy_name, if any. Returns: FailurePolicy instance or None if no policy should be applied. @@ -279,7 +227,7 @@ def _ensure_flattened_maps(self) -> None: """Build flattened attribute views for all entity types (once). Merges top-level model fields (name, disabled, etc.) with .attrs - so that condition matching in apply_failures works uniformly. + so that condition matching in apply_failures_typed works uniformly. All three maps are built together to prevent partial initialization. """ if self._merged_node_attrs is not None: @@ -340,9 +288,7 @@ def compute_exclusions( prepared_weights = self._get_prepared_policy_weights( policy, prepared_matches, node_map, link_map, rg_map ) - # getattr: compute_exclusions accepts duck-typed policy objects that - # may not declare expand_groups. - wants_expansion = getattr(policy, "expand_groups", False) + wants_expansion = policy.expand_groups prepared_rg_index = self._get_prepared_rg_index() if wants_expansion else None if wants_expansion: if self._risk_group_exclusions is None: @@ -351,7 +297,6 @@ def compute_exclusions( else: prepared_rg_members = None - # Apply failure policy with optional deterministic seed override. # The typed variant keeps entity kinds separate: probing merged IDs # against network collections misclassifies a risk group that shares # its name with a node or link. @@ -480,22 +425,17 @@ def _get_prepared_policy_weights( if cached is not None and cached[0] is policy: return cached[1] - # Duck-typed policy objects may not implement the optional - # prepare_weights optimization; treat it as "no precomputed weights". - prepare = getattr(policy, "prepare_weights", None) - prepared = ( - prepare(prepared_matches, node_map, link_map, rg_map) if prepare else {} - ) + prepared = policy.prepare_weights(prepared_matches, node_map, link_map, rg_map) self._prepared_policy_weights[policy_key] = (policy, prepared) return prepared def _get_prepared_rg_index(self) -> dict[str, set[str]]: """Build the risk-group -> entity-ID expansion index once per manager. - The index consumed by ``FailurePolicy.apply_failures`` (via + The index consumed by ``FailurePolicy.apply_failures_typed`` (via ``prepared_rg_index``) depends only on the static network, so it is built once and reused across policies and Monte Carlo iterations - instead of being rebuilt on every ``apply_failures`` call. + instead of being rebuilt on every call. """ if self._prepared_rg_index is None: self._ensure_flattened_maps() @@ -573,7 +513,7 @@ def run_monte_carlo_analysis( policy and any(len(m.rules) > 0 for m in policy.modes) ) - # Without effective rules, only baseline makes sense (no failure iterations) + # With no policy or no rules nothing fails, so only the baseline runs if not has_effective_rules: iterations = 0 @@ -583,11 +523,9 @@ def run_monte_carlo_analysis( else "Running baseline only (no failure policy)" ) - # Pre-build expensive per-run inputs when the analysis function - # carries a `prepare_inputs` hook (attached by the built-in analysis - # functions; third-party functions can set the attribute themselves) - # and the caller did not supply a pre-built context. This amortizes - # graph construction across all iterations. + # Run the `prepare_inputs` hook once per run unless the caller passed + # a pre-built context; this amortizes graph construction across all + # iterations. prepare = getattr(analysis_func, "prepare_inputs", None) if prepare is not None and "context" not in analysis_kwargs: cache_start = time.time() @@ -597,14 +535,13 @@ def run_monte_carlo_analysis( f"Pre-built analysis inputs in {time.time() - cache_start:.3f}s" ) - # Get function name safely (Protocol doesn't guarantee __name__) + # The Protocol does not guarantee __name__ func_name = getattr(analysis_func, "__name__", "analysis_function") logger.debug( f"Analysis parameters: function={func_name}, " f"parallelism={parallelism}, policy={self.policy_name}" ) - # Baseline is always run first (no failures, separate from failure iterations) baseline_arg = ( self.network, set(), # No excluded nodes @@ -630,9 +567,6 @@ def run_monte_carlo_analysis( if effective_seed is None and policy is not None: effective_seed = policy.seed - # Invariant across iterations; only the exclusion sets vary. - kwargs_key = _hashable_kwargs_key(analysis_kwargs) - for i in range(iterations): seed_offset = effective_seed + i if effective_seed is not None else None trace = {} if store_failure_patterns else None @@ -640,9 +574,7 @@ def run_monte_carlo_analysis( policy, seed_offset, failure_trace=trace ) - dedup_key = _create_dedup_key( - excluded_nodes, excluded_links, func_name, kwargs_key - ) + dedup_key = _create_dedup_key(excluded_nodes, excluded_links) if dedup_key not in key_to_first_arg: key_to_first_arg[dedup_key] = ( self.network, @@ -674,10 +606,9 @@ def run_monte_carlo_analysis( start_time = time.time() - baseline_result_raw = self._run_serial([baseline_arg]) - baseline_result = baseline_result_raw[0] if baseline_result_raw else None + baseline_result = self._run_serial([baseline_arg])[0] - if baseline_result is not None and hasattr(baseline_result, "failure_id"): + if hasattr(baseline_result, "failure_id"): baseline_result.failure_id = "" baseline_result.failure_state = {"excluded_nodes": [], "excluded_links": []} baseline_result.failure_trace = None @@ -708,14 +639,12 @@ def run_monte_carlo_analysis( results: list[Any] = [] occurrence_counts: list[int] = [] for dedup_key, rep_arg in key_to_first_arg.items(): - result = key_to_result.get(dedup_key) - if result is None: - continue + result = key_to_result[dedup_key] exc_nodes: set[str] = rep_arg[1] exc_links: set[str] = rep_arg[2] - # Compute failure_id (hash of exclusions, or "" for empty) + # failure_id: short hash of the sorted exclusions, "" when none if not exc_nodes and not exc_links: fid = "" else: @@ -761,48 +690,35 @@ def _run_parallel( total_tasks: int, parallelism: int, ) -> list[Any]: - """Run analysis in parallel using shared network approach. + """Run worker_args on a thread pool. - Network is shared by reference across all threads (zero-copy), which is - safe since the network is read-only during analysis. Each worker receives - only small exclusion sets, and the C++ Core backend releases the GIL - during computation to enable true parallelism. + The network is shared by reference across threads (no copy), which is + safe because analysis does not mutate it. Each worker receives only + its exclusion sets, and the C++ Core backend releases the GIL during + computation, so threads run in parallel. Args: worker_args: Pre-computed worker arguments for all iterations. - total_tasks: Number of tasks to run. - parallelism: Number of parallel worker threads to use. + total_tasks: len(worker_args); also caps the worker count. + parallelism: Maximum number of worker threads. Returns: - List of analysis results. + Analysis results in worker_args order. """ workers = min(parallelism, total_tasks) - logger.info( - f"Running parallel analysis with {workers} workers for {total_tasks} iterations" - ) - - # Network is shared by reference (zero-copy) across threads - logger.debug(f"Sharing network by reference across {workers} threads") + logger.info(f"Running {total_tasks} iterations on {workers} worker threads") start_time = time.time() completed_tasks = 0 results = [] - with ThreadPoolExecutor( - max_workers=workers, - ) as pool: - logger.debug( - f"ThreadPoolExecutor created with {workers} workers and shared network" - ) - logger.info(f"Starting parallel execution of {total_tasks} iterations") - + with ThreadPoolExecutor(max_workers=workers) as pool: for result in pool.map(_generic_worker, worker_args): completed_tasks += 1 results.append(result) - # Progress logging (throttle for small N at INFO) + # Log progress at about 10% steps; skip it for small runs if total_tasks >= 20: - # Show approx 10% increments step = max(1, total_tasks // 10) if completed_tasks % step == 0: logger.info( @@ -821,13 +737,13 @@ def _run_serial( self, worker_args: list[tuple], ) -> list[Any]: - """Run analysis serially for single process execution. + """Run analysis serially in the calling thread. Args: worker_args: Pre-computed worker arguments for all iterations. Returns: - List of analysis results. + Analysis results in worker_args order. """ logger.info("Running serial analysis") start_time = time.time() @@ -851,8 +767,7 @@ def _run_serial( for i, args in enumerate(worker_args): iter_start = time.time() - is_baseline_arg = len(args) > 6 and args[6] # is_baseline flag - baseline_msg = " (baseline)" if is_baseline_arg else "" + baseline_msg = " (baseline)" if args[6] else "" logger.debug( f"Serial iteration {i + 1}/{len(worker_args)}{baseline_msg}" ) @@ -888,29 +803,6 @@ def _run_serial( return results - def run_single_failure_scenario( - self, analysis_func: AnalysisFunction, **kwargs - ) -> Any: - """Run one failure iteration, for quick analysis or debugging. - - For full Monte Carlo analysis, use run_monte_carlo_analysis(). - - Args: - analysis_func: Function that takes (network, excluded_nodes, excluded_links, **kwargs) - and returns results. - **kwargs: Additional arguments passed to analysis_func. - - Returns: - Result from the analysis function. Returns the first failure result if - available, otherwise the baseline result. - """ - result = self.run_monte_carlo_analysis( - analysis_func=analysis_func, iterations=1, parallelism=1, **kwargs - ) - if result["results"]: - return result["results"][0] - return result["baseline"] - # Convenience methods for common analysis patterns def run_max_flow_monte_carlo( @@ -925,7 +817,7 @@ def run_max_flow_monte_carlo( flow_placement: FlowPlacement | str = FlowPlacement.PROPORTIONAL, seed: int | None = None, store_failure_patterns: bool = False, - include_flow_summary: bool = False, + include_flow_details: bool = False, include_min_cut: bool = False, ) -> Any: """Compute max-flow capacity envelopes between node groups under failures. @@ -945,13 +837,13 @@ def run_max_flow_monte_carlo( shortest_path: If True, use single-tier shortest-path flow (IP/IGP mode) instead of full iterative max-flow. require_capacity: If True (default), path selection considers available - capacity. If False, path selection is cost-only (true IP/IGP semantics). + capacity. If False, path selection is cost-only (IP/IGP semantics). flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP); accepts the enum or its string name. seed: Optional seed for reproducible results. If None, falls back to the policy's own seed when set. store_failure_patterns: Whether to store failure trace on results. - include_flow_summary: Whether to collect detailed flow summary data. + include_flow_details: Whether to collect cost distribution per flow. include_min_cut: Whether to include min-cut edges in results. Returns: @@ -976,7 +868,7 @@ def run_max_flow_monte_carlo( shortest_path=shortest_path, require_capacity=require_capacity, flow_placement=flow_placement, - include_flow_details=include_flow_summary, + include_flow_details=include_flow_details, include_min_cut=include_min_cut, ) return raw_results @@ -984,7 +876,7 @@ def run_max_flow_monte_carlo( def _process_sensitivity_results( self, results: list[Any] ) -> dict[str, dict[str, dict[str, float]]]: - """Process sensitivity results to aggregate component impact scores. + """Aggregate per-component sensitivity scores, weighted by occurrence_count. Args: results: List of unique FlowIterationResult objects (deduplicated). @@ -992,14 +884,11 @@ def _process_sensitivity_results( produced that pattern. Returns: - Dictionary mapping flow keys to component impact aggregations. + Mapping of "src->dst" flow key to + {component: {"mean", "max", "min", "count"}}. """ - from collections import defaultdict - from ngraph.results.flow import FlowIterationResult - - # Aggregate component scores weighted by occurrence_count - # Store (weighted_sum, total_count, min, max) per component + # Per component: [weighted_sum, total_count, min, max] flow_aggregates: dict[str, dict[str, list[float]]] = defaultdict( lambda: defaultdict(lambda: [0.0, 0, float("inf"), float("-inf")]) ) @@ -1007,18 +896,17 @@ def _process_sensitivity_results( for result in results: if not isinstance(result, FlowIterationResult): continue - count = getattr(result, "occurrence_count", 1) + count = result.occurrence_count for entry in result.flows: flow_key = f"{entry.source}->{entry.destination}" sensitivity = entry.data.get("sensitivity", {}) for component_key, score in sensitivity.items(): agg = flow_aggregates[flow_key][component_key] - agg[0] += score * count # weighted sum - agg[1] += count # total count - agg[2] = min(agg[2], score) # min - agg[3] = max(agg[3], score) # max + agg[0] += score * count + agg[1] += count + agg[2] = min(agg[2], score) + agg[3] = max(agg[3], score) - # Calculate statistics for each component processed_scores: dict[str, dict[str, dict[str, float]]] = {} for flow_key, components in flow_aggregates.items(): flow_stats: dict[str, dict[str, float]] = {} @@ -1040,8 +928,7 @@ def _process_sensitivity_results( def run_demand_placement_monte_carlo( self, - demands_config: list[dict[str, Any]] - | Any, # List of demand configs or DemandSet + demands_config: list[dict[str, Any]] | DemandSet, iterations: int = 100, parallelism: int = 1, seed: int | None = None, @@ -1051,13 +938,11 @@ def run_demand_placement_monte_carlo( ) -> Any: """Analyze traffic demand placement success under failures. - Attempts to place traffic demands on the network across - Monte Carlo failure scenarios and measures success rates. - Baseline (no failures) is always run first as a separate reference. Args: - demands_config: List of demand configs or DemandSet object. + demands_config: Demand configs (``TrafficDemand.to_dict()`` form) + or a DemandSet, whose sets are placed together. iterations: Number of failure scenarios to simulate. parallelism: Number of parallel worker threads. seed: Optional seed for reproducible results. If None, falls back @@ -1072,21 +957,17 @@ def run_demand_placement_monte_carlo( - 'results': List of unique FlowIterationResult objects (deduplicated patterns). Each result has occurrence_count indicating how many iterations matched. - 'metadata': Execution metadata (iterations, unique_patterns, execution_time, etc.) + + Raises: + TypeError: If ``demands_config`` is neither a list nor a DemandSet. """ - # If caller passed a sequence of TrafficDemand objects, convert to dicts - if not isinstance(demands_config, list): - # Accept DemandSet or any container providing get_all_demands() - serializable_demands: list[dict[str, Any]] = [] - if hasattr(demands_config, "get_all_demands"): - td_iter = demands_config.get_all_demands() # DemandSet helper - else: - td_iter = [] - for demand in td_iter: # type: ignore[assignment] - # Analysis wire format: canonical dict with the raw preset - serializable_demands.append( - {**demand.to_dict(), "flow_policy": demand.flow_policy} - ) - demands_config = serializable_demands + if isinstance(demands_config, DemandSet): + demands_config = [td.to_dict() for td in demands_config.get_all_demands()] + elif not isinstance(demands_config, list): + raise TypeError( + "demands_config must be a list of demand configs or a DemandSet, " + f"got {type(demands_config).__name__}" + ) raw_results = self.run_monte_carlo_analysis( analysis_func=demand_placement_analysis, @@ -1114,8 +995,9 @@ def run_sensitivity_monte_carlo( ) -> dict[str, Any]: """Analyze component criticality for flow capacity under failures. - Identifies critical network components by measuring their impact on flow - capacity across Monte Carlo failure scenarios. + Each critical edge's score is the flow reduction caused by removing + it; ``component_scores`` aggregates those scores across failure + patterns. Baseline (no failures) is always run first as a separate reference. @@ -1136,7 +1018,7 @@ def run_sensitivity_monte_carlo( Returns: Dictionary with keys: - 'baseline': Baseline result (no failures) - - 'results': List of unique per-iteration sensitivity dicts (deduplicated patterns). + - 'results': List of unique FlowIterationResult objects (deduplicated patterns). Each result has occurrence_count indicating how many iterations matched. - 'component_scores': aggregated statistics (mean, max, min, count) per component per flow - 'metadata': Execution metadata (iterations, unique_patterns, execution_time, etc.) @@ -1157,12 +1039,10 @@ def run_sensitivity_monte_carlo( flow_placement=flow_placement, ) - # Aggregate component scores across iterations for statistical analysis raw_results["component_scores"] = self._process_sensitivity_results( raw_results["results"] ) - # Augment metadata with analysis-specific context raw_results["metadata"]["source"] = source raw_results["metadata"]["target"] = target raw_results["metadata"]["mode"] = mode diff --git a/ngraph/analysis/functions.py b/ngraph/analysis/functions.py index 4b585fa..7f4e83d 100644 --- a/ngraph/analysis/functions.py +++ b/ngraph/analysis/functions.py @@ -1,19 +1,14 @@ -"""Flow analysis functions for network evaluation. +"""Flow analysis functions for FailureManager. -These functions are designed for use with FailureManager. Each analysis function -takes a Network, exclusion sets, and analysis-specific parameters, returning -results of type FlowIterationResult. +Each function takes a Network, exclusion sets, and analysis-specific +parameters, and returns a FlowIterationResult. -Parameters should ideally be hashable so FailureManager can deduplicate -identical failure patterns before dispatch; non-hashable objects are keyed -by memory address. +Graph caching builds the graph once and applies each exclusion set as a +boolean mask (a vectorized array fill plus O(|excluded|) updates) instead of +rebuilding. -Graph caching builds the graph once and applies each exclusion set as an -O(|excluded|) mask instead of rebuilding. - -SPF caching computes shortest paths once per unique source node rather than -once per demand. For networks with many demands sharing the same sources, this -can reduce SPF computations by an order of magnitude. +SPF caching computes the base shortest-path DAG once per unique source node +rather than once per demand, so SPF runs scale with unique sources. """ from __future__ import annotations @@ -27,7 +22,6 @@ from ngraph.analysis.placement import place_demands from ngraph.model.demand.builder import coerce_flow_policy from ngraph.model.demand.spec import StaticPath, TrafficDemand -from ngraph.model.flow.policy_config import FlowPolicyPreset from ngraph.results.flow import FlowEntry, FlowIterationResult, FlowSummary from ngraph.types.base import FlowPlacement, Mode @@ -75,7 +69,7 @@ def _reconstruct_traffic_demands( Field defaults match TrafficDemand's own defaults (mode="combine", group_mode="flatten"), so a config produced by `TrafficDemand.to_dict` - round-trips faithfully. + round-trips. Args: demands_config: List of demand configurations with fields: @@ -156,6 +150,40 @@ def _prepare_maxflow_inputs( } +def _pair_context( + network: "Network", + source: str | dict[str, Any], + target: str | dict[str, Any], + mode: str, + context: Optional[AnalysisContext], +) -> AnalysisContext: + """Return `context`, or a context bound to source/target/mode. + + A supplied context must be bound to the same source/target/mode; silently + ignoring mismatched arguments would return results for the wrong pair. + """ + mode_enum = Mode.from_string(mode) + if context is None: + return analyze(network, source=source, sink=target, mode=mode_enum) + if not context.is_bound: + raise ValueError( + "Provided context is unbound; build it with build_maxflow_context " + "(or analyze(..., source=, sink=, mode=)) for these arguments." + ) + if ( + context.bound_source != source + or context.bound_sink != target + or context.bound_mode != mode_enum + ): + raise ValueError( + "Provided context is bound to " + f"source={context.bound_source!r}, sink={context.bound_sink!r}, " + f"mode={context.bound_mode}, which differs from the analysis " + "arguments; rebuild the context or pass matching arguments." + ) + return context + + @_with_prepare_inputs(_prepare_maxflow_inputs) def max_flow_analysis( network: "Network", @@ -174,7 +202,7 @@ def max_flow_analysis( """Analyze maximum flow capacity between node groups. Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. source: Source node selector (string path or selector dict). @@ -183,37 +211,23 @@ def max_flow_analysis( shortest_path: If True, use single-tier shortest-path flow (IP/IGP mode) instead of full iterative max-flow. require_capacity: If True (default), path selection considers available - capacity. If False, path selection is cost-only (true IP/IGP semantics). + capacity. If False, path selection is cost-only (IP/IGP semantics). flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). - include_flow_details: Whether to collect cost distribution and similar details. + include_flow_details: Whether to fill each entry's cost_distribution. include_min_cut: Whether to include min-cut edge list in entry data. - context: Pre-built AnalysisContext reused across calls. Must be - unbound or bound to these same source/target/mode arguments. + context: Pre-built AnalysisContext reused across calls, bound to + these same source/target/mode arguments (see + ``build_maxflow_context``). Returns: - FlowIterationResult describing this iteration. - """ - # Convert string mode to Mode enum (raises on invalid values) - mode_enum = Mode.from_string(mode) + FlowIterationResult with one entry per source/sink pair; demand and + placed both equal the max flow. - # Use provided context or create a new one. A bound context carries its - # own source/sink/mode; silently ignoring mismatched arguments would - # return results for the wrong pair, so reject the mismatch loudly. - if context is not None: - ctx = context - if ctx.is_bound and ( - ctx.bound_source != source - or ctx.bound_sink != target - or ctx.bound_mode != mode_enum - ): - raise ValueError( - "Provided context is bound to " - f"source={ctx.bound_source!r}, sink={ctx.bound_sink!r}, " - f"mode={ctx.bound_mode}, which differs from the analysis " - "arguments; rebuild the context or pass matching arguments." - ) - else: - ctx = analyze(network, source=source, sink=target, mode=mode_enum) + Raises: + ValueError: If ``context`` is unbound or bound to different + source/target/mode arguments. + """ + ctx = _pair_context(network, source, target, mode, context) flow_entries: list[FlowEntry] = [] total_demand = 0.0 @@ -324,48 +338,39 @@ def demand_placement_analysis( ``include_flow_details`` a lossy demand's entry carries ``data["dropped_edges"]``, the dropped volume per ``link_id:direction``. - SPF Caching Optimization: - For cacheable policies (ECMP, WCMP, TE_WCMP_UNLIM), SPF results are - cached by source node. This reduces SPF computations from O(demands) - to O(unique_sources), typically a 5-10x reduction for workloads with - many demands sharing the same sources. + SPF Caching: + For cacheable presets (the hop-by-hop ``SHORTEST_PATHS_*`` presets and + ``TE_WCMP_UNLIM``), base SPF DAGs are cached by source node, which + cuts SPF runs from O(demands) to O(unique_sources). Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. demands_config: List of demand configurations (serializable dicts). include_flow_details: When True, include cost_distribution per flow. include_used_edges: When True, include set of used edges per demand in entry data. context: Pre-built AnalysisContext, reused across calls. Must be built - from this same demands_config - pseudo node names embed demand + from this same demands_config: pseudo node names embed demand ids, so a context built from a different config raises ValueError during endpoint resolution. See build_demand_placement_inputs. expansion: Pre-computed DemandExpansion matching demands_config. When provided, per-call demand reconstruction and expansion are skipped. Must be built together with ``context`` (pseudo node names embed - demand ids) - see build_demand_placement_inputs. + demand ids); see build_demand_placement_inputs. resolved_ids: Pre-resolved (src_id, dst_id) pairs aligned with expansion.demands. Only valid together with ``context``. Returns: - FlowIterationResult describing this iteration. + FlowIterationResult with one entry per expanded demand. """ if expansion is None: traffic_demands = _reconstruct_traffic_demands(demands_config) + expansion = expand_demands(network, traffic_demands) - # Phase 1: Expand demands (pure logic, returns names + augmentations) - expansion = expand_demands( - network, - traffic_demands, - default_policy_preset=FlowPolicyPreset.SHORTEST_PATHS_ECMP, - ) - - # Phase 2: Use cached context infrastructure or build fresh if context is not None: ctx = context else: - # Build fresh context with augmentations ctx = AnalysisContext.from_network( network, augmentations=expansion.augmentations ) @@ -374,7 +379,6 @@ def demand_placement_analysis( edge_mask = ctx.build_edge_mask(excluded_links) flow_graph = netgraph_core.FlowGraph(ctx.multidigraph) - # Phase 3: Place demands using unified placement module result = place_demands( expansion.demands, [d.volume for d in expansion.demands], @@ -388,7 +392,6 @@ def demand_placement_analysis( include_used_edges=include_used_edges, ) - # Phase 4: Convert to FlowEntry format flow_entries = [] for e in result.entries or []: data: dict[str, Any] = {} @@ -440,11 +443,11 @@ def sensitivity_analysis( caused by removing each one. Returns a FlowIterationResult where each FlowEntry represents a source/target pair with: - demand/placed = max flow value (the capacity being analyzed) - - dropped = 0.0 (baseline analysis, no failures applied) + - dropped = 0.0 (the max flow is reported as fully placed) - data["sensitivity"] = {link_id:direction: flow_reduction} for critical edges Args: - network: Network instance. + network: Network to analyze. excluded_nodes: Set of node names to exclude temporarily. excluded_links: Set of link IDs to exclude temporarily. source: Source node selector (string path or selector dict). @@ -454,36 +457,21 @@ def sensitivity_analysis( Reports only edges used under ECMP routing. If False (default), use full iterative max-flow (SDN/TE mode) and report all saturated edges. flow_placement: PROPORTIONAL (WCMP) or EQUAL_BALANCED (ECMP). - context: Pre-built AnalysisContext reused across calls. Must be - unbound or bound to these same source/target/mode arguments. + context: Pre-built AnalysisContext reused across calls, bound to + these same source/target/mode arguments (see + ``build_maxflow_context``). Returns: FlowIterationResult with sensitivity data in each FlowEntry.data. - """ - # Convert string mode to Mode enum (raises on invalid values) - mode_enum = Mode.from_string(mode) - # Use provided context or create a new one. A bound context carries its - # own source/sink/mode; silently ignoring mismatched arguments would - # return results for the wrong pair, so reject the mismatch loudly. - if context is not None: - ctx = context - if ctx.is_bound and ( - ctx.bound_source != source - or ctx.bound_sink != target - or ctx.bound_mode != mode_enum - ): - raise ValueError( - "Provided context is bound to " - f"source={ctx.bound_source!r}, sink={ctx.bound_sink!r}, " - f"mode={ctx.bound_mode}, which differs from the analysis " - "arguments; rebuild the context or pass matching arguments." - ) - else: - ctx = analyze(network, source=source, sink=target, mode=mode_enum) + Raises: + ValueError: If ``context`` is unbound or bound to different + source/target/mode arguments. + """ + ctx = _pair_context(network, source, target, mode, context) - # Get max flow and sensitivity (critical edges) for each pair in a - # single pass: masks are built once and the pairs are walked once. + # One pass for both results: masks are built once and the pairs are + # walked once. combined = ctx.sensitivity_with_flow( shortest_path=shortest_path, flow_placement=flow_placement, @@ -491,7 +479,6 @@ def sensitivity_analysis( excluded_links=excluded_links, ) - # Build FlowEntry for each pair flow_entries: list[FlowEntry] = [] total_flow = 0.0 @@ -508,7 +495,6 @@ def sensitivity_analysis( flow_entries.append(entry) total_flow += flow_value - # Build summary summary = FlowSummary( total_demand=total_flow, total_placed=total_flow, @@ -533,7 +519,7 @@ def build_demand_placement_inputs( (derived from demand ids) match the context's graph. Args: - network: Network instance. + network: Network to analyze. demands_config: List of demand configurations (same format as demand_placement_analysis). @@ -543,17 +529,10 @@ def build_demand_placement_inputs( """ traffic_demands = _reconstruct_traffic_demands(demands_config) - # Expand demands once to get augmentations and concrete demands - expansion = expand_demands( - network, - traffic_demands, - default_policy_preset=FlowPolicyPreset.SHORTEST_PATHS_ECMP, - ) + expansion = expand_demands(network, traffic_demands) - # Build context with augmentations context = analyze(network, augmentations=expansion.augmentations) - # Pre-resolve node IDs once resolved_ids = [ (context.node_mapper.to_id(d.src_name), context.node_mapper.to_id(d.dst_name)) for d in expansion.demands @@ -570,10 +549,10 @@ def build_maxflow_context( """Build an AnalysisContext for repeated max-flow analysis. Pre-computes the graph with pseudo source/target nodes for all source/target - pairs, enabling O(|excluded|) mask building per iteration. + pairs, so each iteration only builds exclusion masks. Args: - network: Network instance. + network: Network to analyze. source: Source node selector (string path or selector dict). target: Target node selector (string path or selector dict). mode: Flow analysis mode ("combine" or "pairwise"). diff --git a/ngraph/analysis/placement.py b/ngraph/analysis/placement.py index c472169..ec56d3c 100644 --- a/ngraph/analysis/placement.py +++ b/ngraph/analysis/placement.py @@ -45,7 +45,7 @@ FLOW_RESOLUTION = 1.0 / 4096.0 # Threshold for recording a placed amount as a flow entry. Any nonzero amount -# the core returns clears FLOW_RESOLUTION and hence this comfortably. +# the core returns is at least FLOW_RESOLUTION, far above this. _MIN_FLOW = 1e-9 # Cached-path FlowIndex ids start far above the ids Core's FlowPolicy assigns @@ -175,16 +175,6 @@ def _preset_modes( return modes -def _get_edge_selection(preset: FlowPolicyPreset) -> netgraph_core.EdgeSelection: - """Get EdgeSelection for a cacheable preset.""" - return _preset_modes(preset)[0] - - -def _get_flow_placement(preset: FlowPolicyPreset) -> netgraph_core.FlowPlacement: - """Get FlowPlacement for a cacheable preset.""" - return _preset_modes(preset)[1] - - def place_demands( demands: Sequence["ExpandedDemand"], volumes: Sequence[float], @@ -207,8 +197,8 @@ def place_demands( capacity and the totals of rerouting presets depend on demand order. Hop-by-hop presets (``HOP_BY_HOP_PRESETS``) place each demand in one pass - on the cost-only shortest-path DAG of its source. A combine-mode demand is - a virtual source, a pool of the selected sources: with such a preset + on the cost-only shortest-path DAG of its source. A combine-mode demand + pools the selected sources behind a virtual source: with such a preset (``ExpandedDemand.src_members`` set) every member that can reach a target originates an even share of the volume, since hop-by-hop routing has no controller that could choose where traffic originates, and each share is @@ -504,7 +494,13 @@ def _place_cached( include_cost_distribution: bool, include_used_edges: bool, ) -> tuple[_CachedPlacement, int]: - """Place single demand with SPF caching.""" + """Place one demand on cached SPF DAGs. + + TE presets then reroute the remainder on fresh residual-aware DAGs. + + Returns: + The placement outcome and the next free flow id. + """ selection, placement = _preset_modes(preset) is_te = preset in _CACHEABLE_TE lossy = placement == netgraph_core.FlowPlacement.EQUAL_BALANCED_LOSSY @@ -560,9 +556,9 @@ def _place_cached( residual = np.ascontiguousarray( flow_graph.residual_view(), dtype=np.float64 ) - # Note: Do NOT cache residual-based DAGs. The TE loop computes - # DAGs specific to this demand's placement; caching them would - # corrupt results for other demands from the same source. + # Residual-based DAGs are not cached: they reflect this demand's + # placement so far, and reusing them would corrupt results for + # other demands from the same source. fresh_dists, fresh_dag = ctx.algorithms.spf( ctx.handle, src=src_id, diff --git a/ngraph/analysis/static_paths.py b/ngraph/analysis/static_paths.py index b27db7f..5689342 100644 --- a/ngraph/analysis/static_paths.py +++ b/ngraph/analysis/static_paths.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, List, Sequence, Tuple import netgraph_core @@ -19,12 +19,6 @@ __all__ = ["build_static_path_bundles"] -# Bundles depend only on the static graph, not on the per-iteration masks -# (Core prunes them against the masks itself), so they are resolved once per -# context and reused across Monte Carlo iterations and MSD probes. The cache -# lives on the context so it dies with it. -_CACHE_ATTR = "_static_path_bundle_cache" - def _disabled_edge_ids(ctx: "AnalysisContext") -> frozenset: """Core edge ids belonging to administratively disabled links.""" @@ -113,13 +107,13 @@ def _edges_from_links( f"Static path names unknown link {link_id!r}; " f"route was {list(path.links)}" ) - # A link has a forward and (for bidirectional links) a reverse edge; - # pick whichever leaves the node the route has reached. if link_id in ctx.disabled_link_ids: raise ValueError( f"Static path names disabled link {link_id!r}; a route pinned to " "a disabled link can never carry traffic" ) + # Each link has a forward and a reverse edge; pick the one that + # leaves the node the route has reached. chosen = next( (int(e) for e in candidates if int(edge_src[int(e)]) == current), None ) @@ -160,13 +154,11 @@ def build_static_path_bundles( reached, does not run from `src_name` to `dst_name`, or revisits a node (a pinned route must be a simple path). """ + # Bundles depend only on the static graph, not on the per-iteration masks + # (Core prunes them against the masks itself), so they are resolved once + # per context and reused across Monte Carlo iterations and MSD probes. cache_key = (src_name, dst_name, tuple(paths)) - per_ctx: Optional[Dict[tuple, List[netgraph_core.PredDAG]]] = getattr( - ctx, _CACHE_ATTR, None - ) - if per_ctx is None: - per_ctx = {} - setattr(ctx, _CACHE_ATTR, per_ctx) + per_ctx = ctx._static_path_cache cached = per_ctx.get(cache_key) if cached is not None: return cached diff --git a/ngraph/cli.py b/ngraph/cli.py index 59c5de0..c68840f 100644 --- a/ngraph/cli.py +++ b/ngraph/cli.py @@ -8,6 +8,7 @@ import os import sys from contextlib import contextmanager +from enum import Enum from pathlib import Path from statistics import median from time import perf_counter @@ -49,11 +50,9 @@ def _format_table( if not rows: return "" - # Optionally clip cells to max_col_width for visual consistency def clip(val: Any) -> str: s = str(val) if max_col_width is not None and len(s) > max_col_width: - # Use ASCII ellipsis for consistency return s[: max_col_width - 3] + "..." return s @@ -81,31 +80,23 @@ def format_row(row_data: List[str]) -> str: return "\n".join(lines) -def _format_cost(value: Any) -> str: +def _format_cost(value: float) -> str: """Return cost formatted with up to three decimals. Uses thousands separators, trims trailing zeros and the decimal point when - not needed. Falls back to ``str(value)`` if the input cannot be parsed as a - float. + not needed. Examples: 0.1 -> "0.1"; 10.0 -> "10"; 1234.567 -> "1,234.567". """ - try: - v = float(value) - except Exception: - return str(value) - - s = f"{v:,.3f}" + s = f"{float(value):,.3f}" if "." in s: s = s.rstrip("0").rstrip(".") return s def _format_duration(seconds: float) -> str: - """Return a concise human-readable duration string. - - Uses ASCII units and keeps output short for logs. + """Format seconds as a short ASCII duration for logs. Examples: 0.123 -> "123.0 ms"; 1.234 -> "1.23 s"; 75.2 -> "1m 15.2s". @@ -120,11 +111,13 @@ def _format_duration(seconds: float) -> str: def _collect_step_path_fields(step: Any) -> list[tuple[str, str]]: - """Return (field, pattern) pairs for fields that represent node selectors. + """Return (field, pattern) pairs for a step's node selector fields. + + Fields considered, when their value is a non-empty string: + - `source` and `target` + - names ending with "_path" or "_regex" - Fields considered: - - `source` and `target` selector fields with string values - - names ending with "_path" or "_regex" with non-empty string values + Private fields (leading underscore) are skipped. """ fields: list[tuple[str, str]] = [] for key, value in step.__dict__.items(): @@ -134,7 +127,6 @@ def _collect_step_path_fields(step: Any) -> list[tuple[str, str]]: continue if not value.strip(): continue - # Selector fields or pattern fields if ( key in ("source", "target") or key.endswith("_path") @@ -145,9 +137,12 @@ def _collect_step_path_fields(step: Any) -> list[tuple[str, str]]: def _summarize_pattern(pattern: str, net: Any) -> Dict[str, Any]: - """Summarize node matches for a given pattern against a network. + """Count the node groups and nodes that a selector pattern matches. - Returns dict with keys: pattern, groups, nodes, enabled_nodes, labels (preview) or error. + Returns: + Dict with ``pattern``, ``groups``, ``nodes``, ``enabled_nodes`` and + ``labels`` (first five group labels), or ``pattern`` and ``error`` + when selection raises. """ try: groups = net.select_node_groups_by_path(pattern) @@ -169,7 +164,7 @@ def _summarize_pattern(pattern: str, net: Any) -> Dict[str, Any]: def _summarize_node_matches(step: Any, net: Any) -> Dict[str, Dict[str, Any]]: - """Summarize all path-like fields for a workflow step against a network.""" + """Map each node selector field of a workflow step to its match summary.""" summary: Dict[str, Dict[str, Any]] = {} fields = _collect_step_path_fields(step) if not fields: @@ -185,9 +180,10 @@ def _print_network_structure( """Print network structure summary and return total enabled link capacity. Args: - network: Network model instance. + network: Network to summarize. components_library: Components library used for hierarchy analysis. - detail: Whether to show detailed tables. + detail: Print the full hierarchy, every violation, and per-node and + per-link tables instead of previews. Returns: Total capacity across enabled links as a float. Returns 0.0 when @@ -229,7 +225,6 @@ def _print_network_structure( if disabled_links: print(f" Disabled Links: {len(disabled_links):,}") - # Network hierarchy analysis if nodes: pkg_logger = logging.getLogger("ngraph") original_level = pkg_logger.level @@ -242,7 +237,7 @@ def _print_network_structure( ) print("\n Network Hierarchy:") print( - " Legend: counts are for enabled (active) nodes/links; cost/power are\n" + " Legend: counts include disabled nodes/links; cost/power are\n" " aggregated from components if defined." ) # Keep the printed tree shallow in non-detailed mode for readability @@ -258,13 +253,12 @@ def _print_network_structure( finally: pkg_logger.setLevel(original_level) - # Hardware utilization and validation summary (non-fatal) + # Best effort: a failure here is logged at debug level and skipped. try: if explorer is not None: node_utils = explorer.get_node_utilization() link_issues = explorer.get_link_issues() - # Node capacity violations cap_viol = [u for u in node_utils if u.capacity_violation] port_viol = [u for u in node_utils if u.ports_violation] @@ -273,7 +267,6 @@ def _print_network_structure( f" nodes with capacity violations: {len(cap_viol):,}; ports violations: {len(port_viol):,}" ) - # Show over-capacity nodes table (top N by utilization) if cap_viol: cap_viol_sorted = sorted( cap_viol, @@ -310,7 +303,6 @@ def _print_network_structure( if not detail and len(cap_viol_sorted) > len(top): print(f" ... and {len(cap_viol_sorted) - len(top)} more") - # Optional port violations table if detail and port_viol: port_sorted = sorted( port_viol, @@ -344,7 +336,6 @@ def _print_network_structure( ) print("\n".join(f" {ln}" for ln in tbl2.split("\n"))) - # Link issues if link_issues: issues = link_issues if detail else link_issues[:10] rows3: list[list[str]] = [] @@ -368,12 +359,9 @@ def _print_network_structure( if not detail and len(link_issues) > len(issues): print(f" ... and {len(link_issues) - len(issues)} more") except Exception as exc: - # Non-fatal logger.debug("Failed to display hardware utilization: %s", exc) - # Show complete node and link tables in detail mode if detail: - # Nodes table if nodes: print("\n Nodes:") node_rows = [] @@ -395,25 +383,13 @@ def _print_network_structure( ) print(node_table) - # Links table if links: print("\n Links:") link_rows = [] for _link_id, link in links.items(): status = "disabled" if link.disabled else "enabled" capacity = f"{link.capacity:,.0f}" - - # Get cost if available - cost_val: Any | None = None - if hasattr(link, "cost"): - cost_val = link.cost - elif ( - hasattr(link, "attrs") - and isinstance(link.attrs, dict) - and "cost" in link.attrs - ): - cost_val = link.attrs["cost"] - cost = _format_cost(cost_val) if cost_val is not None else "" + cost = _format_cost(link.cost) link_rows.append([link.source, link.target, status, capacity, cost]) @@ -422,7 +398,6 @@ def _print_network_structure( ) print(link_table) - # Link capacity analysis as table total_enabled_link_capacity: float = 0.0 if links: link_caps = [float(link.capacity) for link in enabled_links] @@ -441,7 +416,6 @@ def _print_network_structure( ) print(cap_table) - # Node capacity analysis if nodes and links: print("\n Node Capacity Statistics:") # Only include nodes with enabled links attached @@ -464,7 +438,7 @@ def _print_network_structure( def _print_risk_groups(network: Any, detail: bool) -> None: - """Print a concise summary of defined risk groups. + """Print the risk group count and names (first five unless ``detail``). Args: network: Network instance containing optional ``risk_groups`` mapping. @@ -491,7 +465,7 @@ def _print_risk_groups(network: Any, detail: bool) -> None: def _print_components_library(components_library: Any, detail: bool) -> None: - """Print a summary of available components in the library. + """Print the component count and names (first five unless ``detail``). Args: components_library: Components library with ``components`` mapping. @@ -519,7 +493,8 @@ def _print_failure_policies(failure_policy_set: Any, detail: bool) -> None: Args: failure_policy_set: Collection of failure policies under ``policies``. - detail: When True, show modes and rule previews; else, a brief count. + detail: When True, also show the first three modes of each policy + and the first three rules of each mode. """ print("\n5. FAILURE POLICIES") print("-" * 30) @@ -528,7 +503,7 @@ def _print_failure_policies(failure_policy_set: Any, detail: bool) -> None: if failure_policy_set.policies: policy_items = list(failure_policy_set.policies.items())[:5] for policy_name, policy in policy_items: - mode_count = len(getattr(policy, "modes", []) or []) + mode_count = len(policy.modes) print( f" {policy_name}: {mode_count} mode{'s' if mode_count != 1 else ''}" ) @@ -540,10 +515,10 @@ def _print_failure_policies(failure_policy_set: Any, detail: bool) -> None: ) for ri, rule in enumerate(mode.rules[:3]): extra = ( - f" count={getattr(rule, 'count', '')}" + f" count={rule.count}" if rule.mode == "choice" else ( - f" p={getattr(rule, 'probability', '')}" + f" p={rule.probability}" if rule.mode == "random" else "" ) @@ -563,7 +538,7 @@ def _print_demand_sets( Args: network: Network instance for node pattern summarization. - ds: DemandSet with defined sets. + ds: Demand set collection to summarize. detail: Whether to print detailed tables. total_enabled_link_capacity: Sum of capacities of enabled links. """ @@ -574,7 +549,7 @@ def _print_demand_sets( if not ds.sets: return - # Capacity vs Demand summary across all sets (shown first for visibility) + # Totals across all sets print before the per-set breakdown. try: grand_total_demand = 0.0 grand_demand_count = 0 @@ -668,7 +643,6 @@ def _print_demand_sets( ) print("\n".join(f" {line}" for line in table.split("\n"))) - # Optional: Top N demands by offered volume for quick understanding try: top_n = 5 sorted_demands = sorted( @@ -701,7 +675,7 @@ def _print_demand_sets( logger.debug("Failed to display top demands: %s", exc) if demands: - for i, demand in enumerate(demands[:3]): # Show first 3 demands + for i, demand in enumerate(demands[:3]): src = ( demand.source if isinstance(demand.source, str) @@ -805,7 +779,8 @@ def _print_workflow_steps(scenario: Any, detail: bool, network: Any) -> None: param_rows = [] for key, value in step_dict.items(): if key not in ["name", "seed"] and not key.startswith("_"): - param_rows.append([key, str(value)]) + shown = value.name if isinstance(value, Enum) else str(value) + param_rows.append([key, shown]) if param_rows: param_table = _format_table(["Parameter", "Value"], param_rows) @@ -856,37 +831,37 @@ def _print_workflow_steps(scenario: Any, detail: bool, network: Any) -> None: def _inspect_scenario(path: Path, detail: bool = False) -> None: - """Inspect a scenario file, validate it, and show key characteristics. + """Load and validate a scenario file, then print a sectioned summary. + + Exits with status 1 when the file is missing or fails to load. Args: path: Scenario YAML file. - detail: Whether to show detailed information including sample node names. + detail: Include full node and link tables, all names, and step + parameters. """ logger.info(f"Inspecting scenario from: {path}") _start_time = perf_counter() try: - # Load and validate scenario yaml_text = path.read_text() - logger.info("โœ“ YAML file loaded successfully") + logger.info("โœ“ YAML file loaded") scenario = Scenario.from_yaml(yaml_text) logger.debug( "Scenario loaded: nodes=%d, links=%d, steps=%d, policies=%d, demand_sets=%d", - len(getattr(scenario.network, "nodes", {})), - len(getattr(scenario.network, "links", {})), + len(scenario.network.nodes), + len(scenario.network.links), len(scenario.workflow), - len(getattr(scenario.failure_policy_set, "policies", {})), - len(getattr(scenario.demand_set, "sets", {})), + len(scenario.failure_policy_set.policies), + len(scenario.demand_set.sets), ) - logger.info("โœ“ Scenario validated and loaded successfully") + logger.info("โœ“ Scenario validated") - # Show scenario metadata print("\n" + "=" * 60) print("NETGRAPH SCENARIO INSPECTION") print("=" * 60) - # Overview: quick summary for fast scanning try: network = scenario.network nodes = network.nodes @@ -899,7 +874,6 @@ def _inspect_scenario(path: Path, detail: bool = False) -> None: sum(float(lk.capacity) for lk in enabled_links) ) - # Demand sets ds = scenario.demand_set set_count = len(ds.sets) total_demands = 0 @@ -915,17 +889,13 @@ def _inspect_scenario(path: Path, detail: bool = False) -> None: else 0.0 ) - # Risk groups quick count - rg_total = ( - len(network.risk_groups) if getattr(network, "risk_groups", None) else 0 - ) + rg_total = len(network.risk_groups) rg_disabled = ( sum(1 for rg in network.risk_groups.values() if rg.disabled) if rg_total else 0 ) - # Workflow steps count wf_steps = len(scenario.workflow) print("\nOVERVIEW") @@ -949,7 +919,7 @@ def _inspect_scenario(path: Path, detail: bool = False) -> None: overview_table = _format_table(["Metric", "Value"], rows, max_col_width=64) print(overview_table) except Exception as exc: - # Non-fatal; proceed with normal sections + # Best effort; the numbered sections below still print. logger.debug("Failed to display overview section: %s", exc) print("\n1. SCENARIO METADATA") @@ -963,31 +933,18 @@ def _inspect_scenario(path: Path, detail: bool = False) -> None: print(" Seed: None (non-deterministic)") print(" Workflow step seeds will be random on each run") - # Network Analysis print("\n2. NETWORK STRUCTURE") print("-" * 30) network = scenario.network total_enabled_link_capacity = _print_network_structure( network, scenario.components_library, detail ) - - # (details printed by helper) - - # Risk Groups Analysis _print_risk_groups(network, detail) - - # Components Library _print_components_library(scenario.components_library, detail) - - # Failure Policies Analysis _print_failure_policies(scenario.failure_policy_set, detail) - - # Demand Sets Analysis _print_demand_sets( network, scenario.demand_set, detail, total_enabled_link_capacity ) - - # Workflow Analysis _print_workflow_steps(scenario, detail, network) print("\n" + "=" * 60) @@ -1001,22 +958,17 @@ def _inspect_scenario(path: Path, detail: bool = False) -> None: print(f"Usage: python -m ngraph run {path}") else: print("\nNo workflow steps defined") - print( - "This scenario can be used for network analysis but has no automated workflow" - ) _elapsed = perf_counter() - _start_time - logger.info( - f"Scenario inspection completed successfully in {_format_duration(_elapsed)}" - ) + logger.info(f"Scenario inspection completed in {_format_duration(_elapsed)}") except FileNotFoundError: - print(f"โŒ ERROR: Scenario file not found: {path}") + print(f"โŒ ERROR: Scenario file not found: {path}", file=sys.stderr) sys.exit(1) except Exception as e: logger.error(f"Failed to inspect scenario: {e}") - print("โŒ ERROR: Failed to inspect scenario") - print(f" {type(e).__name__}: {e}") + print("โŒ ERROR: Failed to inspect scenario", file=sys.stderr) + print(f" {type(e).__name__}: {e}", file=sys.stderr) sys.exit(1) @@ -1032,16 +984,19 @@ def _run_scenario( ) -> None: """Run a scenario file and export results as JSON by default. + Exits with status 1 on any failure. + Args: path: Scenario YAML file. results_override: Optional explicit path for JSON results. When ``None``, the path is derived from the scenario name under ``output_dir``. - no_results: Whether to disable results file generation. - stdout: Whether to also print results to stdout. + no_results: Skip writing the results file. + stdout: Print the results JSON to stdout. keys: Optional list of workflow step names to include. When ``None`` all steps are exported. - profile: Whether to enable performance profiling with CPU analysis. - profile_memory: Whether the profiler also tracks memory usage. + profile: Profile each step with cProfile and print a report to stderr. + profile_memory: Also record peak memory per step with tracemalloc. + Only takes effect with ``profile``. output_dir: Base directory for derived output paths (results, profiles). """ logger.info(f"Loading scenario from: {path}") @@ -1050,6 +1005,14 @@ def _run_scenario( try: yaml_text = path.read_text() scenario = Scenario.from_yaml(yaml_text) + if keys: + step_names = [step.name for step in scenario.workflow] + unknown = sorted(set(keys) - set(step_names)) + if unknown: + raise ValueError( + f"Unknown step name(s) in --keys: {', '.join(unknown)}; " + f"workflow steps: {', '.join(step_names)}" + ) if profile: logger.info("Performance profiling enabled") @@ -1058,7 +1021,7 @@ def _run_scenario( logger.info("Starting scenario execution with profiling") - # Enable worker-thread profiling for parallel workflows + # FailureManager worker threads write per-worker .pstats files here. child_profile_dir = profiles_dir_for_run(path, output_dir) child_profile_dir.mkdir(parents=True, exist_ok=True) prev_profile_dir = os.environ.get("NGRAPH_PROFILE_DIR") @@ -1070,13 +1033,12 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: """Wrap step execution with profiling and worker-profile merge. Worker profiles are merged only after the profiled block exits - cleanly; exceptions raised by the step propagate unchanged and + normally; exceptions raised by the step propagate unchanged and skip the merge. """ step_name = step.name or step.__class__.__name__ with profiler.profile_step(step_name, step.__class__.__name__): yield - # Merge any worker profiles generated by this step if child_profile_dir.exists(): profiler.merge_child_profiles(child_profile_dir, step_name) @@ -1090,12 +1052,12 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: else: os.environ["NGRAPH_PROFILE_DIR"] = prev_profile_dir - logger.info("Scenario execution completed successfully") + logger.info("Scenario execution completed") profiler.end_scenario() profiler.analyze_performance() - # Clean up any remaining worker profile files + # merge_child_profiles deletes the files it merges; remove leftovers. if child_profile_dir.exists(): remaining_files = list(child_profile_dir.glob("*.pstats")) if remaining_files: @@ -1124,10 +1086,8 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: else: logger.info("Starting scenario execution") scenario.run() - logger.info("Scenario execution completed successfully") print("โœ… Scenario execution completed", file=sys.stderr) - # Export JSON results by default unless disabled if not no_results or stdout: logger.info("Serializing results to JSON") results_dict: Dict[str, Any] = scenario.results.to_dict() @@ -1136,14 +1096,13 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: # Filter only the steps subsection; keep workflow/scenario intact steps_map = results_dict.get("steps", {}) filtered_steps: Dict[str, Any] = { - step: steps_map[step] for step in keys if step in steps_map + step: steps_map[step] for step in keys } results_dict["steps"] = filtered_steps - json_str = json.dumps(results_dict, indent=2, default=str) + json_str = json.dumps(results_dict, indent=2) if not no_results: - # Derive default results file path using output directory policy effective_output = results_path_for_run( scenario_path=path, output_dir=output_dir, @@ -1151,19 +1110,14 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: ) ensure_parent_dir(effective_output) - logger.info(f"Writing results to: {effective_output}") effective_output.write_text(json_str) - logger.info("Results written successfully") print(f"โœ… Results written to: {effective_output}", file=sys.stderr) if stdout: print(json_str) - # Final success duration log _elapsed = perf_counter() - _start_time - logger.info( - f"Scenario run completed successfully in {_format_duration(_elapsed)}" - ) + logger.info(f"Scenario run completed in {_format_duration(_elapsed)}") except FileNotFoundError: logger.error(f"Scenario file not found: {path}") @@ -1181,21 +1135,22 @@ def _profile_step_hook(step: WorkflowStep) -> Iterator[None]: def main(argv: Optional[List[str]] = None) -> None: """Entry point for the ``ngraph`` command. + Prints help and exits with status 0 when no arguments are given. + Args: - argv: Optional list of command-line arguments. If ``None``, ``sys.argv`` - is used. + argv: Arguments without the program name. If ``None``, + ``sys.argv[1:]`` is used. """ parser = argparse.ArgumentParser( prog="ngraph", description="Run and analyze network scenarios.", ) - # Global options parser.add_argument( "--verbose", "-v", action="store_true", help="Enable debug logging" ) parser.add_argument( - "--quiet", action="store_true", help="Suppress console output (logs only)" + "--quiet", action="store_true", help="Show only warnings and errors in logs" ) subparsers = parser.add_subparsers( @@ -1206,7 +1161,6 @@ def main(argv: Optional[List[str]] = None) -> None: help="Available commands", ) - # Run command run_parser = subparsers.add_parser("run", help="Run a scenario") run_parser.add_argument("scenario", type=Path, help="Path to scenario YAML") run_parser.add_argument( @@ -1238,7 +1192,7 @@ def main(argv: Optional[List[str]] = None) -> None: run_parser.add_argument( "--profile", action="store_true", - help="Enable performance profiling with CPU analysis and bottleneck detection", + help="Profile the run and print a per-step CPU report to stderr", ) run_parser.add_argument( "--profile-memory", @@ -1246,7 +1200,6 @@ def main(argv: Optional[List[str]] = None) -> None: help="Also track peak memory per step (via tracemalloc)", ) - # Inspect command inspect_parser = subparsers.add_parser( "inspect", help="Inspect and validate a scenario" ) @@ -1257,31 +1210,26 @@ def main(argv: Optional[List[str]] = None) -> None: action="store_true", help="Show detailed information including complete node/link tables and step parameters", ) - # Global output directory for all commands - for p in (run_parser, inspect_parser): - p.add_argument( - "--output", - "-o", - type=Path, - default=None, - help=( - "Output directory for generated artifacts. When provided," - " all files will be written under this folder using a" - " consistent '.' naming convention." - ), - ) + run_parser.add_argument( + "--output", + "-o", + type=Path, + default=None, + help=( + "Directory for the results file and, with --profile, the" + " worker profile directory" + ), + ) - # Determine effective arguments (support both direct calls and module entrypoint) effective_args = sys.argv[1:] if argv is None else argv - - # If no arguments are provided, show help and exit cleanly if not effective_args: parser.print_help() raise SystemExit(0) args = parser.parse_args(effective_args) + if args.command == "run" and args.profile_memory and not args.profile: + parser.error("--profile-memory requires --profile") - # Configure logging based on arguments setup_root_logger() if args.verbose: set_global_log_level(logging.DEBUG) diff --git a/ngraph/dsl/blueprints/__init__.py b/ngraph/dsl/blueprints/__init__.py index 480ff50..b5cf336 100644 --- a/ngraph/dsl/blueprints/__init__.py +++ b/ngraph/dsl/blueprints/__init__.py @@ -1,5 +1,5 @@ """Blueprint DSL types and expansion utilities. -Blueprint structures plus the expansion helpers that turn group and adjacency +Blueprint structures plus the expansion helpers that turn node groups and link patterns into a `ngraph.model.network.Network`. """ diff --git a/ngraph/dsl/blueprints/expand.py b/ngraph/dsl/blueprints/expand.py index 7c399c4..4d986e1 100644 --- a/ngraph/dsl/blueprints/expand.py +++ b/ngraph/dsl/blueprints/expand.py @@ -17,6 +17,7 @@ from ngraph.dsl.expansion import ( ExpansionSpec, expand_block, + expand_name_patterns, expand_risk_group_refs, ) from ngraph.dsl.selectors import normalize_selector @@ -27,6 +28,7 @@ parse_match_spec, select_nodes, ) +from ngraph.utils.yaml_utils import check_no_extra_keys @dataclass @@ -49,13 +51,13 @@ class Blueprint: @dataclass class DSLExpansionContext: - """Carries the blueprint definitions and the final Network instance - to be populated during DSL expansion. + """Blueprint definitions and the Network populated during DSL expansion. Attributes: blueprints: Dictionary of blueprint-name -> Blueprint. network: The Network into which expanded nodes/links are inserted. - pending_bp_links: Deferred blueprint link expansions. + pending_bp_links: (link definition, parent path) pairs for blueprint + links, expanded after node rules run. """ blueprints: Dict[str, Blueprint] @@ -64,7 +66,7 @@ class DSLExpansionContext: def expand_network_dsl(data: Dict[str, Any]) -> Network: - """Expands a combined blueprint + network DSL into a complete Network object. + """Expand a combined blueprint + network DSL into a Network. Overall flow: 1) Parse "blueprints" into Blueprint objects. @@ -80,10 +82,10 @@ def expand_network_dsl(data: Dict[str, Any]) -> Network: 7) Process link rules (in order if multiple rules match). Field validation rules: - - Only certain top-level fields are permitted in each structure. + - Each structure rejects unrecognized keys. - Link properties are flat (capacity, cost, etc. at link level). - - For node definitions: count, template, attrs, disabled, risk_groups, - or blueprint for blueprint-based nodes. + - Node definitions accept count, template, attrs, disabled, risk_groups, + nodes (nested groups), or blueprint and params (blueprint instances). Args: data: The YAML-parsed dictionary containing optional "blueprints" + "network". @@ -101,7 +103,7 @@ def expand_network_dsl(data: Dict[str, Any]) -> Network: raise ValueError( f"Blueprint definition for '{bp_name}' must be a dict." ) - _bp_parse.check_no_extra_keys( + check_no_extra_keys( bp_data, allowed={"nodes", "links"}, context=f"blueprint '{bp_name}'", @@ -231,14 +233,12 @@ def _expand_node_group( group_def: Dict[str, Any], inherited_risk_groups: Optional[Set[str]] = None, ) -> None: - """Expands a single node definition into either: - - Another blueprint's nodes, or - - Nested nodes (inline hierarchy), or - - A direct node group (with count, etc.), - - Possibly replicating itself if group_name has bracket expansions. + """Expand one node group definition into nodes. - A 'blueprint' key expands that blueprint; a 'nodes' key recurses for nested - groups; otherwise nodes are created directly. + A 'blueprint' key instantiates that blueprint, a 'nodes' key recurses into + nested groups, and otherwise nodes are created directly. A group_name with + bracket expressions like "fa[1-2]" replicates group_def once per expanded + name. For blueprint usage: Allowed keys: {"blueprint", "params", "attrs", "disabled", "risk_groups"}. @@ -247,24 +247,22 @@ def _expand_node_group( For nested nodes: Allowed keys: {"nodes", "attrs", "disabled", "risk_groups"}. + The parent's 'attrs', 'disabled', and 'risk_groups' are merged into + each nested definition. For direct node groups (no 'blueprint', no 'nodes'): Allowed keys: {"count", "template", "attrs", "disabled", "risk_groups"}. - If group_name includes bracket expansions like "fa[1-2]", it replicates the - same group_def for each expanded name. - Args: ctx: The context containing blueprint info and the Network. - parent_path: The parent path in the hierarchy. - group_name: The current group's name (may have bracket expansions). + parent_path: Path of the enclosing group ("" at top level). + group_name: The current group's name (may have bracket expressions). group_def: The node definition (count, template, etc.). inherited_risk_groups: Risk groups inherited from a higher-level group. """ if inherited_risk_groups is None: inherited_risk_groups = set() - expanded_names = _bp_parse.expand_name_patterns(group_name) - # If bracket expansions exist, replicate for each expansion + expanded_names = expand_name_patterns(group_name) if len(expanded_names) > 1 or expanded_names[0] != group_name: for expanded_name in expanded_names: _expand_node_group( @@ -272,15 +270,13 @@ def _expand_node_group( ) return - # Compute the full path for this group if parent_path: effective_path = f"{parent_path}/{group_name}" else: effective_path = group_name if "blueprint" in group_def: - # Blueprint usage => recognized keys - _bp_parse.check_no_extra_keys( + check_no_extra_keys( group_def, allowed={"blueprint", "params", "attrs", "disabled", "risk_groups"}, context=f"node '{group_name}' using blueprint", @@ -325,8 +321,6 @@ def _expand_node_group( ) override_to_group[override_key] = matched_group - # For each node in the blueprint, apply param overrides and - # merge parent's attrs/disabled/risk_groups for bp_sub_name, bp_sub_def in bp.nodes.items(): # _apply_parameters returns a deep copy, safe to mutate merged_def = _apply_parameters( @@ -340,7 +334,6 @@ def _expand_node_group( parent_risk_groups, ) - # Recursively expand _expand_node_group( ctx, parent_path=effective_path, @@ -354,8 +347,7 @@ def _expand_node_group( ctx.pending_bp_links.append((link_def, effective_path)) elif "nodes" in group_def: - # Nested nodes => recognized keys - _bp_parse.check_no_extra_keys( + check_no_extra_keys( group_def, allowed={"nodes", "attrs", "disabled", "risk_groups"}, context=f"nested node '{group_name}'", @@ -365,7 +357,6 @@ def _expand_node_group( group_def, group_name, inherited_risk_groups ) - # Recursively process nested nodes nested_nodes = group_def["nodes"] if not isinstance(nested_nodes, dict): raise ValueError(f"'nodes' must be a dict in '{group_name}'.") @@ -393,8 +384,7 @@ def _expand_node_group( ) else: - # Direct node group => recognized keys - _bp_parse.check_no_extra_keys( + check_no_extra_keys( group_def, allowed={"count", "template", "attrs", "disabled", "risk_groups"}, context=f"node '{group_name}'", @@ -403,22 +393,19 @@ def _expand_node_group( group_def, group_name, inherited_risk_groups ) - # Check if this is a simple single node (no count, no template) has_count = "count" in group_def has_template = "template" in group_def if not has_count and not has_template: - # Simple single node - use effective_path as the node name + # Without count or template, the group path is the node name. node = Node( name=effective_path, disabled=group_disabled, attrs=copy.deepcopy(combined_attrs), ) - node.attrs.setdefault("type", "node") node.risk_groups = final_risk_groups.copy() ctx.network.add_node(node) else: - # Node group with count/template - create numbered nodes count = group_def.get("count", 1) template = group_def.get("template", f"{group_name}-{{n}}") if not isinstance(count, int) or count < 1: @@ -433,7 +420,6 @@ def _expand_node_group( disabled=group_disabled, attrs=copy.deepcopy(combined_attrs), ) - node.attrs.setdefault("type", "node") node.risk_groups = final_risk_groups.copy() ctx.network.add_node(node) @@ -445,8 +431,8 @@ def _join_parent(base: str, rel: str) -> str: instantiation path), not a regex, so its metacharacters (e.g. '.' in group names like 'dc.1') must be escaped before the joined path is compiled as a regex. re.escape("") == "" so top-level links (base="") - are unaffected, and "/" is never escaped so multi-level parents join - cleanly. + are unaffected, and re.escape leaves "/" alone, so multi-level parents + still join on "/". Args: base: Literal parent path prefix. @@ -466,7 +452,7 @@ def _normalize_link_selector(sel: Any, base: str) -> Dict[str, Any]: base: Parent path to prepend. Returns: - Normalized selector dict. + Selector dict with the regex-escaped parent prefix joined onto 'path'. """ if isinstance(sel, str): return {"path": _join_parent(base, sel)} @@ -533,9 +519,9 @@ def _expand_link_with_variables( """Expand a link definition once per combination in its 'expand' block. Substitutes $var or ${var} variables in all string fields of the link - definition - source/target selectors (including match condition values), - attrs, and risk_groups - yielding one full link definition per variable - combination, consistent with node_rules/link_rules expansion. + definition (source/target selectors including match condition values, + attrs, and risk_groups), yielding one full link definition per variable + combination, as node_rules/link_rules expansion does. Args: ctx: The DSL expansion context. @@ -566,7 +552,7 @@ def _expand_link_pattern( link_def: Dict[str, Any], count: int = 1, ) -> None: - """Generates Link objects for the chosen link pattern among matched nodes. + """Create links between the matched nodes using the chosen pattern. Supported Patterns: * "mesh": Connect every source node to every target node. @@ -640,10 +626,10 @@ def _expand_link_pattern( def _select_link_nodes(network: Network, selector: Any) -> List[Node]: - """Select nodes for link creation based on selector. + """Select link endpoint nodes. - Uses the unified selector system. For links, active_only defaults - to False (links to disabled nodes are created). + For links, active_only defaults to False (links to disabled nodes are + created). Args: network: The network to select from. @@ -652,7 +638,7 @@ def _select_link_nodes(network: Network, selector: Any) -> List[Node]: Returns: List of matching nodes (flattened from all groups). """ - normalized = normalize_selector(selector, context="adjacency") + normalized = normalize_selector(selector, context="link") groups = select_nodes(network, normalized, default_active_only=False) return [node for nodes in groups.values() for node in nodes] @@ -664,16 +650,14 @@ def _create_link( link_def: Dict[str, Any], count: int = 1, ) -> None: - """Create and add one or more Links to the network. - - Link properties are flat in link_def (capacity, cost, disabled, - risk_groups, attrs). + """Add `count` parallel links between source and target. Args: net: The network to which the new link(s) will be added. source: Source node name for the link. target: Target node name for the link. - link_def: Dict with flat link properties. + link_def: Flat link properties (capacity, cost, disabled, risk_groups, + attrs). Capacity and cost default to 1.0. count: Number of parallel links to create between source and target. """ for _ in range(count): @@ -696,15 +680,15 @@ def _create_link( def _process_node_rules(net: Network, network_data: Dict[str, Any]) -> None: - """Process the 'node_rules' section of the network DSL, updating - existing nodes with new attributes in bulk. Rules are applied in order - if multiple items match the same node. + """Apply the 'node_rules' section of the network DSL to existing nodes. - Each rule must have {"path"} plus optionally {"attrs", "disabled", "risk_groups"}. + Rules apply in list order, so a later rule wins where several match the + same node. Each rule may have {"path", "match", "attrs", "disabled", + "risk_groups", "expand"}; "path" defaults to every node. - "disabled" sets node.disabled. - "risk_groups" *replaces* the node's risk_groups. - - Everything else merges into node.attrs. + - "attrs" merges into node.attrs. Args: net: The Network whose nodes will be updated. @@ -717,7 +701,7 @@ def _process_node_rules(net: Network, network_data: Dict[str, Any]) -> None: for rule in node_rules: if not isinstance(rule, dict): raise ValueError("Each node_rule must be a dict.") - _bp_parse.check_no_extra_keys( + check_no_extra_keys( rule, allowed={"path", "attrs", "disabled", "risk_groups", "match", "expand"}, context="node rule", @@ -748,12 +732,12 @@ def _apply_node_rule(net: Network, rule: Dict[str, Any]) -> None: def _process_link_rules(net: Network, network_data: Dict[str, Any]) -> None: - """Processes the 'link_rules' section of the network DSL, updating - existing links with new parameters. Rules are applied in order if - multiple items match the same link. + """Apply the 'link_rules' section of the network DSL to existing links. - Each rule must contain {"source", "target"} plus optionally - {"bidirectional", "capacity", "cost", "disabled", "risk_groups", "attrs", "expand"}. + Rules apply in list order, so a later rule wins where several match the + same link. Each rule must contain {"source", "target"} plus optionally + {"bidirectional", "capacity", "cost", "disabled", "risk_groups", "attrs", + "expand", "link_match"}. If risk_groups is given, it *replaces* the link's existing risk_groups. @@ -768,7 +752,7 @@ def _process_link_rules(net: Network, network_data: Dict[str, Any]) -> None: for link_rule in link_rules: if not isinstance(link_rule, dict): raise ValueError("Each link_rule must be a dict.") - _bp_parse.check_no_extra_keys( + check_no_extra_keys( link_rule, allowed={ "source", @@ -811,13 +795,12 @@ def _update_links( rule: Dict[str, Any], bidirectional: bool = True, ) -> None: - """Update all Link objects between nodes matching source and target selectors - with new parameters (capacity, cost, disabled, risk_groups, attrs). + """Update links between nodes selected by source and target. - If bidirectional=True, both (source->target) and (target->source) links - are updated if present. - - If risk_groups is given, it *replaces* the link's existing risk_groups. + Sets capacity, cost, disabled, risk_groups, and attrs from the rule. If + bidirectional=True, links in both directions are updated. A 'link_match' + in the rule further filters links by their attributes. If risk_groups is + given, it *replaces* the link's existing risk_groups. Args: net: The network whose links should be updated. @@ -826,8 +809,8 @@ def _update_links( rule: Rule dict with flat link properties. bidirectional: If True, also update reversed direction links. """ - src_sel = normalize_selector(source, context="override") - tgt_sel = normalize_selector(target, context="override") + src_sel = normalize_selector(source, context="rule") + tgt_sel = normalize_selector(target, context="rule") source_node_groups = select_nodes(net, src_sel, default_active_only=False) target_node_groups = select_nodes(net, tgt_sel, default_active_only=False) @@ -845,7 +828,6 @@ def _update_links( new_risk_groups = rule.get("risk_groups", None) new_attrs = rule.get("attrs", {}) - # Parse link_match for filtering by link attributes link_match_raw = rule.get("link_match") link_match = parse_match_spec(link_match_raw) if link_match_raw else None @@ -859,7 +841,6 @@ def _update_links( if not (forward_match or reverse_match): continue - # Apply link_match filter if specified if link_match is not None: link_attrs = flatten_link_attrs(link, link_id) if not evaluate_conditions( @@ -867,7 +848,6 @@ def _update_links( ): continue - # Apply updates if new_capacity is not None: link.capacity = new_capacity if new_cost is not None: @@ -888,25 +868,22 @@ def _update_nodes( disabled_val: Any = None, risk_groups_val: Any = None, ) -> None: - """Updates attributes on all nodes matching a path pattern and optional match conditions. - - - If 'disabled_val' is not None, sets node.disabled to that boolean value. - - If 'risk_groups_val' is not None, *replaces* the node's risk_groups with that new set. - - Everything else in 'attrs' is merged into node.attrs. + """Update nodes matching a path pattern and optional match conditions. Args: net: The network containing the nodes. path: A path pattern identifying which node group(s) to modify. match_spec: Optional match conditions dict (with 'conditions' and 'logic'). - attrs: A dictionary of new attributes to set/merge. - disabled_val: Boolean or None for disabling or enabling nodes. - risk_groups_val: List or set or None for replacing node.risk_groups. + attrs: Attributes merged into node.attrs. + disabled_val: New node.disabled value, or None to leave it unchanged. + risk_groups_val: Names that *replace* node.risk_groups, or None to + leave them unchanged. """ selector_dict: Dict[str, Any] = {"path": path} if match_spec: selector_dict["match"] = match_spec - normalized = normalize_selector(selector_dict, context="override") + normalized = normalize_selector(selector_dict, context="rule") node_groups = select_nodes(net, normalized, default_active_only=False) for _, nodes in node_groups.items(): @@ -943,7 +920,7 @@ def _apply_parameters( params_overrides: Dict[str, Any], override_to_group: Dict[str, str], ) -> Dict[str, Any]: - """Applies user-provided parameter overrides to a blueprint subgroup. + """Apply blueprint `params` overrides to one subgroup definition. Example: If 'spine.count' = 6 is in params_overrides, @@ -976,8 +953,9 @@ def _apply_parameters( def _apply_nested_path( node_def: Dict[str, Any], path_parts: List[str], value: Any ) -> None: - """Recursively applies a path like ["attrs", "role"] to set node_def["attrs"]["role"] = value. - Creates intermediate dicts as needed. + """Set a nested field, e.g. ["attrs", "role"] sets node_def["attrs"]["role"]. + + Creates intermediate dicts as needed, replacing non-dict values on the path. Args: node_def (Dict[str, Any]): The dictionary to update. diff --git a/ngraph/dsl/blueprints/parser.py b/ngraph/dsl/blueprints/parser.py index 3779cba..7c8d069 100644 --- a/ngraph/dsl/blueprints/parser.py +++ b/ngraph/dsl/blueprints/parser.py @@ -8,37 +8,16 @@ from typing import Any, Dict -# Re-export expand_name_patterns from its canonical location -from ngraph.dsl.expansion import expand_name_patterns +from ngraph.utils.yaml_utils import check_no_extra_keys __all__ = [ - "check_no_extra_keys", "check_link_keys", - "expand_name_patterns", "join_paths", ] -def check_no_extra_keys( - data_dict: Dict[str, Any], allowed: set[str], context: str -) -> None: - """Raise if ``data_dict`` contains keys outside ``allowed``. - - Args: - data_dict: The dict to check. - allowed: Set of recognized keys. - context: Short description used in error messages. - """ - extra_keys = set(data_dict.keys()) - allowed - if extra_keys: - raise ValueError( - f"Unrecognized key(s) in {context}: {', '.join(sorted(extra_keys))}. " - f"Allowed keys are: {sorted(allowed)}" - ) - - def check_link_keys(link_def: Dict[str, Any], context: str) -> None: - """Ensure link definitions only contain recognized keys.""" + """Reject unrecognized link keys and require 'source' and 'target'.""" check_no_extra_keys( link_def, allowed={ @@ -62,15 +41,12 @@ def check_link_keys(link_def: Dict[str, Any], context: str) -> None: def join_paths(parent_path: str, rel_path: str) -> str: """Join two path segments according to DSL conventions. - The DSL has no concept of absolute paths. All paths are relative to the - current context (parent_path). A leading "/" on rel_path is stripped and - has no functional effect - it serves only as a visual indicator that the - path starts from the current scope's root. + The DSL has no absolute paths. All paths are relative to the current + context (parent_path). A leading "/" on rel_path is stripped and has no + functional effect; it only marks that the path starts from the current + scope's root. - Behavior: - - Leading "/" on rel_path is stripped (not treated as filesystem root) - - Result is always: "{parent_path}/{stripped_rel_path}" if parent_path is non-empty - - Examples: + Examples: join_paths("", "/leaf") -> "leaf" join_paths("pod1", "/leaf") -> "pod1/leaf" join_paths("pod1", "leaf") -> "pod1/leaf" (same result) @@ -80,14 +56,7 @@ def join_paths(parent_path: str, rel_path: str) -> str: rel_path: Path to join. Leading "/" is stripped if present. Returns: - Combined path string. + "{parent_path}/{rel_path}", or rel_path alone when parent_path is empty. """ - if rel_path.startswith("/"): - rel_path = rel_path[1:] - if parent_path: - return f"{parent_path}/{rel_path}" - return rel_path - - if parent_path: - return f"{parent_path}/{rel_path}" - return rel_path + rel_path = rel_path.removeprefix("/") + return f"{parent_path}/{rel_path}" if parent_path else rel_path diff --git a/ngraph/dsl/expansion/__init__.py b/ngraph/dsl/expansion/__init__.py index 465cf22..81a4c51 100644 --- a/ngraph/dsl/expansion/__init__.py +++ b/ngraph/dsl/expansion/__init__.py @@ -1,7 +1,7 @@ """Variable and pattern expansion for NetGraph DSL. -Template expansion with $var syntax, plus bracket pattern expansion for name -generation. +Template expansion with $var and ${var} syntax, plus bracket pattern expansion +for name generation. Usage: from ngraph.dsl.expansion import expand_block, expand_name_patterns, ExpansionSpec diff --git a/ngraph/dsl/expansion/brackets.py b/ngraph/dsl/expansion/brackets.py index 04d9c82..eab5661 100644 --- a/ngraph/dsl/expansion/brackets.py +++ b/ngraph/dsl/expansion/brackets.py @@ -70,9 +70,6 @@ def expand_risk_group_refs( ) -> Set[str]: """Expand bracket patterns in a list of risk group references. - Takes a list, set, or tuple of risk group names (possibly containing - bracket expressions) and returns a set of all expanded names. - Args: rg_list: List, set, or tuple of risk group name patterns. Other iterables (including bare strings and generators) are rejected. @@ -130,7 +127,6 @@ def _parse_range_expr(expr: str) -> List[str]: for part in parts: if "-" in part: start_str, end_str = part.split("-", 1) - # Validate that both endpoints are numeric try: start = int(start_str) except ValueError: @@ -147,7 +143,6 @@ def _parse_range_expr(expr: str) -> List[str]: f"Ranges only support integers (e.g., [1-3]). " f"For alphabetic values, use comma-separated lists (e.g., [a,b,c])." ) from None - # Validate that range is not inverted if start > end: raise ValueError( f"Invalid range '{part}': start ({start}) is greater than end ({end}). " diff --git a/ngraph/dsl/expansion/variables.py b/ngraph/dsl/expansion/variables.py index abc2945..5ebcd60 100644 --- a/ngraph/dsl/expansion/variables.py +++ b/ngraph/dsl/expansion/variables.py @@ -22,7 +22,7 @@ # Pattern to match $var or ${var} placeholders _VAR_PATTERN = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)") -# Expansion limits +# Maximum number of copies one expand: block may produce MAX_TEMPLATE_EXPANSIONS = 10_000 @@ -55,7 +55,7 @@ def replace(match: re.Match[str]) -> str: def substitute_vars(obj: Any, var_dict: Dict[str, Any]) -> Any: - """Recursively substitute ${var} in all strings within obj. + """Recursively substitute $var and ${var} in all strings within obj. A string consisting of exactly one placeholder (e.g. "${t}") is replaced by the variable's native value, preserving its type. This keeps match @@ -68,8 +68,8 @@ def substitute_vars(obj: Any, var_dict: Dict[str, Any]) -> Any: var_dict: Mapping of variable names to values. Returns: - Object with variables substituted: whole-placeholder strings replaced - by the variable's native value, other strings interpolated as text. + obj with placeholders substituted. Dicts and lists are rebuilt, not + mutated. Raises: KeyError: If a placeholder names a variable absent from var_dict. @@ -114,7 +114,6 @@ def _generate_combinations( combos: Iterator[tuple[Any, ...]] = zip(*var_values, strict=True) expansion_size = lengths[0] if lengths else 0 else: - # Cartesian product expansion_size = 1 for v in var_values: expansion_size *= len(v) diff --git a/ngraph/dsl/loader.py b/ngraph/dsl/loader.py index 1a77510..21583f0 100644 --- a/ngraph/dsl/loader.py +++ b/ngraph/dsl/loader.py @@ -1,8 +1,7 @@ """YAML loader + schema validation for Scenario DSL. -A single entrypoint parses a YAML string, normalizes keys where needed, -validates against the packaged JSON schema, and returns a canonical -dictionary suitable for downstream expansion/parsing. +`load_scenario_yaml` parses a YAML string, validates it against the packaged +JSON schema, and returns the dictionary for expansion and parsing. """ from __future__ import annotations @@ -11,17 +10,22 @@ from importlib import resources from typing import Any, Dict +import jsonschema import yaml -from ngraph.utils.yaml_utils import normalize_yaml_dict_keys - def load_scenario_yaml(yaml_str: str) -> Dict[str, Any]: - """Load, normalize, and validate a Scenario YAML string. + """Load and validate a Scenario YAML string. + + Returns the parsed dictionary with schema shape enforced. Section builders + normalize YAML-specific quirks such as boolean-like keys. - Returns a canonical dictionary representation that downstream parsers can - consume without worrying about YAML-specific quirks (e.g., boolean-like - keys) and with schema shape already enforced. + Raises: + ValueError: If the top level is not a mapping, or a network, link, or + risk group entry has the wrong shape (checked before the schema + for clearer messages). + jsonschema.ValidationError: If the data does not match the packaged + schema, including unrecognized top-level keys. """ data = yaml.safe_load(yaml_str) if data is None: @@ -29,12 +33,6 @@ def load_scenario_yaml(yaml_str: str) -> Dict[str, Any]: if not isinstance(data, dict): raise ValueError("The provided YAML must map to a dictionary at top-level.") - # Normalize known sections that suffer from YAML key ambiguities - if isinstance(data.get("demands"), dict): - data["demands"] = normalize_yaml_dict_keys( - data["demands"] # type: ignore[arg-type] - ) - # Early shape checks give better error messages than schema validation would network_section = data.get("network") if isinstance(network_section, dict): @@ -71,45 +69,12 @@ def load_scenario_yaml(yaml_str: str) -> Dict[str, Any]: "or dict with 'generate' field" ) - # JSON Schema validation - try: - import jsonschema # type: ignore - except Exception as exc: # pragma: no cover - raise RuntimeError( - "jsonschema is required for scenario validation. Install dev extras or add 'jsonschema' to dependencies." - ) from exc - - try: - with ( - resources.files("ngraph.schemas") - .joinpath("scenario.json") - .open("r", encoding="utf-8") - ) as f: # type: ignore[attr-defined] - schema_data = json.load(f) - except Exception as exc: # pragma: no cover - raise RuntimeError( - "Failed to locate packaged NetGraph scenario schema 'ngraph/schemas/scenario.json'." - ) from exc - - jsonschema.validate(data, schema_data) # type: ignore[arg-type] - - # Enforce allowed top-level keys - recognized_keys = { - "vars", - "blueprints", - "components", - "network", - "risk_groups", - "demands", - "failures", - "workflow", - "seed", - } - extra = set(data.keys()) - recognized_keys - if extra: - raise ValueError( - f"Unrecognized top-level key(s) in scenario: {', '.join(sorted(extra))}. " - f"Allowed keys are {sorted(recognized_keys)}" - ) + # JSON Schema validation (also rejects unknown top-level keys) + schema_text = ( + resources.files("ngraph.schemas") + .joinpath("scenario.json") + .read_text(encoding="utf-8") + ) + jsonschema.validate(data, json.loads(schema_text)) return data diff --git a/ngraph/dsl/selectors/__init__.py b/ngraph/dsl/selectors/__init__.py index c11794f..8128418 100644 --- a/ngraph/dsl/selectors/__init__.py +++ b/ngraph/dsl/selectors/__init__.py @@ -1,12 +1,13 @@ -"""Unified node selection for NetGraph DSL. +"""YAML-facing selector parsing for the NetGraph DSL. -Selector parsing for YAML configs: the single abstraction for node selection -used across adjacency, demands, overrides, and workflow steps. The schema -types and the runtime evaluation engine live in `ngraph.model.selectors`; -they are re-exported here for backward compatibility. +`normalize_selector` turns a raw selector from a scenario (a path string or a +selector dict) into a `NodeSelector`. The schema types and the evaluation +engine (`select_nodes`, condition evaluation, attribute flattening) live in +`ngraph.model.selectors`. Usage: - from ngraph.dsl.selectors import normalize_selector, select_nodes, NodeSelector + from ngraph.dsl.selectors import normalize_selector + from ngraph.model.selectors import select_nodes # From YAML config (string or dict) selector = normalize_selector(raw_config["source"], "demand") @@ -15,45 +16,6 @@ groups = select_nodes(network, selector, default_active_only=True) """ -from ngraph.model.selectors import ( - VALID_OPERATORS, - Condition, - EntityScope, - MatchSpec, - NodeSelector, - evaluate_condition, - evaluate_conditions, - flatten_link_attrs, - flatten_node_attrs, - flatten_risk_group_attrs, - link_path_key, - match_entity_ids, - resolve_attr_path, - select_nodes, -) +from .normalize import normalize_selector -from .normalize import normalize_selector, parse_match_spec - -__all__ = [ - # Schema (re-exported from ngraph.model.selectors) - "Condition", - "EntityScope", - "MatchSpec", - "NodeSelector", - "VALID_OPERATORS", - # Parsing - "normalize_selector", - "parse_match_spec", - # Evaluation (re-exported from ngraph.model.selectors) - "select_nodes", - "evaluate_condition", - "evaluate_conditions", - "resolve_attr_path", - # Attribute flattening (re-exported from ngraph.model.selectors) - "flatten_node_attrs", - "flatten_link_attrs", - "flatten_risk_group_attrs", - "link_path_key", - # Entity matching (re-exported from ngraph.model.selectors) - "match_entity_ids", -] +__all__ = ["normalize_selector"] diff --git a/ngraph/dsl/selectors/normalize.py b/ngraph/dsl/selectors/normalize.py index 0526f4c..ef3849e 100644 --- a/ngraph/dsl/selectors/normalize.py +++ b/ngraph/dsl/selectors/normalize.py @@ -1,7 +1,6 @@ """Selector parsing and normalization. -Single entry point for converting raw selector values (strings or dicts) -into NodeSelector objects. +Converts raw selector values (strings or dicts) into NodeSelector objects. """ from __future__ import annotations @@ -10,16 +9,14 @@ from typing import Any, Dict, Union from ngraph.model.selectors import NodeSelector, parse_match_spec +from ngraph.utils.yaml_utils import check_no_extra_keys -__all__ = [ - "normalize_selector", - "parse_match_spec", -] +__all__ = ["normalize_selector"] -# Context-aware defaults for active_only +# Default active_only for each selector usage context _ACTIVE_ONLY_DEFAULTS: Dict[str, bool] = { - "adjacency": False, - "override": False, + "link": False, + "rule": False, "demand": True, "workflow": True, } @@ -35,14 +32,16 @@ def normalize_selector( Args: raw: Either a regex string, selector dict, or existing NodeSelector. - context: Usage context ("adjacency", "demand", "override", "workflow"). + context: Usage context ("link", "rule", "demand", "workflow"). Determines the default for active_only. Returns: - Normalized NodeSelector instance. + NodeSelector with active_only set, from the selector or the context + default. Raises: - ValueError: If selector format is invalid or context is unknown. + ValueError: If the selector is neither a string nor a dict, a selector + dict carries an unrecognized key, or the context is unknown. """ default_active_only = _ACTIVE_ONLY_DEFAULTS.get(context) if default_active_only is None: @@ -71,6 +70,7 @@ def _parse_dict(raw: Dict[str, Any], default_active_only: bool) -> NodeSelector: NodeSelector.__post_init__ validates that at least one selection mechanism (path, group_by, or match) is present. """ + check_no_extra_keys(raw, {"path", "group_by", "match", "active_only"}, "selector") match_spec = None if "match" in raw: match_spec = parse_match_spec(raw["match"]) diff --git a/ngraph/explorer.py b/ngraph/explorer.py index f9ad53d..527239a 100644 --- a/ngraph/explorer.py +++ b/ngraph/explorer.py @@ -1,12 +1,13 @@ """Hierarchical exploration of a Network. Builds a tree of the node-name hierarchy and aggregates per-subtree -statistics โ€” node and link counts, capacity, capex/power, and hardware -bills of materials โ€” in two modes: all nodes, and enabled nodes only. +statistics in two modes (all nodes, and enabled nodes only): node and link +counts, capacity, capex/power, and hardware bills of materials (BOM). """ from __future__ import annotations +import math from dataclasses import dataclass, field from typing import TYPE_CHECKING, Dict, List, Optional, Set @@ -26,12 +27,12 @@ def _node_is_disabled(node: Node) -> bool: - """Return True if the node should be treated as disabled.""" + """Return the node's disabled flag as a bool.""" return bool(node.disabled) def _link_is_disabled(link: Link) -> bool: - """Return True if the link should be treated as disabled.""" + """Return the link's disabled flag as a bool.""" return bool(link.disabled) @@ -59,8 +60,10 @@ class TreeStats: external_link_count (int): Number of external links from this subtree to another. external_link_capacity (float): Sum of capacities for those external links. external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown by other subtree path. - total_capex (float): Cumulative capex (nodes + links). - total_power (float): Cumulative power (nodes + links). + total_capex (float): Total capex (nodes + links). + total_power (float): Total power (nodes + links). + bom (Dict[str, float]): Hardware count per component name. Shared + optics can add fractional counts. """ node_count: int = 0 @@ -77,7 +80,6 @@ class TreeStats: total_capex: float = 0.0 total_power: float = 0.0 - # Hardware BOM aggregation bom: Dict[str, float] = field(default_factory=dict) @@ -123,7 +125,7 @@ class LinkCapacityIssue: target: Target node name. capacity: Configured link capacity. limit: Effective capacity limit from per-end hardware (min of ends). - reason: Brief reason tag. + reason: Machine-readable tag, e.g. ``link_capacity_exceeds_end_hw``. """ source: str @@ -138,14 +140,14 @@ class TreeNode: """A node in the hierarchical tree. Attributes: - name (str): Name/label of this node. - parent (Optional[TreeNode]): Pointer to the parent tree node. + name (str): Hierarchy segment, e.g. "plane1" in "dc1/plane1/ssw". + parent (Optional[TreeNode]): Parent tree node; None for the root. children (Dict[str, TreeNode]): Mapping of child name -> child TreeNode. - subtree_nodes (Set[str]): Node names in the subtree (all nodes, ignoring disabled). + subtree_nodes (Set[str]): Node names in the subtree, disabled included. active_subtree_nodes (Set[str]): Node names in the subtree (only enabled). stats (TreeStats): Aggregated stats for "all" view. active_stats (TreeStats): Aggregated stats for "active" (only enabled) view. - raw_nodes (List[Node]): Direct Node objects at this hierarchy level. + raw_nodes (List[Node]): Network nodes whose full name ends at this tree node. """ name: str @@ -167,7 +169,7 @@ def __hash__(self) -> int: return id(self) def add_child(self, child_name: str) -> TreeNode: - """Ensure a child node named 'child_name' exists and return it.""" + """Return the child named ``child_name``, creating it if missing.""" if child_name not in self.children: child_node = TreeNode(name=child_name, parent=self) self.children[child_name] = child_node @@ -181,8 +183,8 @@ def is_leaf(self) -> bool: class NetworkExplorer: """Hierarchical view of a Network with per-subtree statistics. - Statistics are computed in two modes: 'all' (ignores disabled) and - 'active' (only enabled). + Statistics are computed in two modes: 'all' (disabled nodes and links + included) and 'active' (enabled only). """ def __init__( @@ -197,12 +199,10 @@ def __init__( self.root_node: Optional[TreeNode] = None - # For quick lookups: self._node_map: Dict[str, TreeNode] = {} # node_name -> deepest TreeNode self._path_map: Dict[str, TreeNode] = {} # path -> TreeNode self._node_path_map: Dict[TreeNode, str] = {} # TreeNode -> path - # Cache for ancestor sets: self._ancestors_cache: Dict[TreeNode, Set[TreeNode]] = {} # Validation/utilization artifacts (filled during statistics computation) @@ -216,42 +216,45 @@ def explore_network( components_library: Optional[ComponentsLibrary] = None, strict_validation: bool = True, ) -> NetworkExplorer: - """Build a NetworkExplorer, constructing a tree plus 'all' and 'active' stats. + """Build the hierarchy tree and compute 'all' and 'active' statistics. - The Explorer also constructs hardware Bills-of-Materials (BOM): - - stats.bom: total counts by component for all nodes/links (ignores disabled) - - active_stats.bom: counts by component for enabled topology only - Counts include fractional usage for sharable optics; exclusive endpoints - are rounded up in per-link aggregation. + Statistics include hardware bills of materials (BOM): + + - stats.bom: counts by component for all nodes and links, disabled included + - active_stats.bom: counts by component for the enabled topology only + + Shared optics add fractional counts; exclusive link ends are rounded up + per link. Args: - network: Network model instance. - components_library: Components definition library. + network: Network to explore. + components_library: Component definitions for hardware lookups. + None uses an empty library. strict_validation: When True, raise on capacity/ports violations; when False, - record issues and continue (useful for inspection flows). + record issues and continue (used by ``ngraph inspect``). + + Returns: + The populated explorer. + + Raises: + ValueError: On a node capacity, port, or link capacity violation + when ``strict_validation`` is True. """ instance = cls(network, components_library, strict_validation=strict_validation) - # 1) Build hierarchy instance.root_node = instance._build_hierarchy_tree() - - # 2) Compute subtree sets for "all" (ignoring disabled state) instance._compute_subtree_sets_all(instance.root_node) - - # 3) Compute subtree sets for "active" (excluding disabled) instance._compute_subtree_sets_active(instance.root_node) - - # 4) Build node & path maps + # Statistics read the subtree sets and these maps, so they run last. instance._build_node_map(instance.root_node) instance._build_path_map(instance.root_node) - - # 5) Aggregate statistics (both 'all' and 'active') instance._compute_statistics() return instance def _build_hierarchy_tree(self) -> TreeNode: - """Build a multi-level tree by splitting node names on '/'. + """Build the tree by splitting node names on '/'. + Example: "dc1/plane1/ssw/ssw-1" => root/dc1/plane1/ssw/ssw-1 """ root = TreeNode(name="root") @@ -264,7 +267,7 @@ def _build_hierarchy_tree(self) -> TreeNode: return root def _compute_subtree_sets_all(self, node: TreeNode) -> Set[str]: - """Recursively collect all node names (regardless of disabled) into subtree_nodes.""" + """Fill ``subtree_nodes`` bottom-up with every node name, disabled included.""" collected = set() for child in node.children.values(): collected |= self._compute_subtree_sets_all(child) @@ -274,9 +277,7 @@ def _compute_subtree_sets_all(self, node: TreeNode) -> Set[str]: return collected def _compute_subtree_sets_active(self, node: TreeNode) -> Set[str]: - """Recursively collect enabled node names into active_subtree_nodes. - A node is considered enabled when the disabled flag is False. - """ + """Fill ``active_subtree_nodes`` bottom-up with enabled node names.""" collected = set() for child in node.children.values(): collected |= self._compute_subtree_sets_active(child) @@ -292,11 +293,9 @@ def _build_node_map(self, node: TreeNode) -> None: Walks parents before children so a child TreeNode overrides its parent's claim on a name. """ - # Map the raw_nodes at this level for nd in node.raw_nodes: self._node_map[nd.name] = node - # Then recurse, letting children override deeper nodes for child in node.children.values(): self._build_node_map(child) @@ -335,9 +334,10 @@ def _get_ancestors(self, node: TreeNode) -> Set[TreeNode]: return ancestors def _compute_statistics(self) -> None: - """Populates two stats sets for each TreeNode: - - node.stats (all, ignoring disabled) - - node.active_stats (only enabled nodes/links) + """Fill ``stats`` and ``active_stats`` on every tree node. + + ``stats`` includes disabled nodes and links; ``active_stats`` covers + only the enabled topology. """ self._reset_all_stats() self._compute_node_counts() @@ -399,7 +399,6 @@ def _compute_node_costs_and_utilization(self) -> Dict[str, bool]: (nd.attrs.get("hardware") or {}).get("component"), ) - # Totals with external multiplier if comp is not None: cost_val, power_val, node_comp_capacity = totals_with_multiplier( comp, hw_count @@ -429,7 +428,6 @@ def _compute_node_costs_and_utilization(self) -> Dict[str, bool]: comp.name, 0.0 ) + float(hw_count) - # Validation only if component has a positive capacity and node is enabled if ( comp is not None and node_comp_capacity > 0.0 @@ -453,10 +451,11 @@ def _validate_node_utilization( node_comp_capacity: float, attached_links: List[Link], ) -> None: - """Validate and record node hardware utilization. + """Record a node's hardware utilization and check it against limits. - Checks attached link capacity and port usage against node hardware limits. - Records utilization snapshot and raises if strict_validation is enabled. + Compares attached link capacity with the hardware capacity, and ports + used by link-end optics with the ports available. The NodeUtilization + snapshot is recorded before any error is raised. Args: nd: Node being validated. @@ -464,40 +463,33 @@ def _validate_node_utilization( hw_count: Hardware multiplicity for the node. node_comp_capacity: Total capacity supported by node hardware. attached_links: Enabled links attached to this node. + + Raises: + ValueError: On a capacity or port violation when + ``strict_validation`` is True. """ - # Sum capacities of all enabled links attached to this node attached_capacity = 0.0 - # Track optics usage in "equivalent optics" and ports tally used_ports = 0.0 for lk in attached_links: - # If the opposite endpoint is disabled, skip in active view + # A link to a disabled neighbor is not active. other = lk.target if lk.source == nd.name else lk.source other_node = self.network.nodes.get(other) if other_node is not None and _node_is_disabled(other_node): continue attached_capacity += float(lk.capacity) - # Compute optics usage for this endpoint if per-end hardware is set - (src_end, dst_end, per_end) = resolve_link_end_components( + src_end, dst_end = resolve_link_end_components( lk.attrs, self.components_library ) - if per_end: - end = src_end if lk.source == nd.name else dst_end - end_comp, end_cnt, _end_excl = end - if end_comp is not None: - # Ports used equals count * ports per optic (fractional allowed) - ports_per_optic = float(getattr(end_comp, "ports", 0) or 0) - if ports_per_optic > 0: - used_ports += end_cnt * ports_per_optic - - # Compute ports availability and violations - total_ports_available = float(getattr(comp, "ports", 0) or 0) * float(hw_count) + end_comp, end_cnt, _end_excl = src_end if lk.source == nd.name else dst_end + if end_comp is not None and end_comp.ports > 0: + # Fractional optic counts give fractional port usage. + used_ports += end_cnt * float(end_comp.ports) + + total_ports_available = float(comp.ports) * float(hw_count) capacity_violation = attached_capacity > node_comp_capacity - ports_violation = False - if getattr(comp, "ports", 0) and comp.ports > 0: - ports_violation = used_ports > total_ports_available + 1e-9 + ports_violation = comp.ports > 0 and used_ports > total_ports_available + 1e-9 - # Record per-node utilization snapshot for active topology capacity_utilization = ( (attached_capacity / node_comp_capacity) if node_comp_capacity > 0.0 @@ -520,7 +512,6 @@ def _validate_node_utilization( ports_violation=bool(ports_violation), ) - # Enforce strict behavior after recording if capacity_violation and self.strict_validation: raise ValueError( ( @@ -551,88 +542,74 @@ def _validate_node_utilization( ) def _compute_link_stats(self, node_has_hw: Dict[str, bool]) -> None: - """Accumulate link stats (internal/external + capex/power) and validate.""" + """Accumulate per-link counts, capacity, capex/power, and BOM. + + Also checks link capacity against per-end hardware on active links. + Violations raise in strict mode and go to ``_link_issues`` otherwise. + """ for link in self.network.links.values(): src = link.source dst = link.target - # Resolve per-end link hardware - (src_end, dst_end, per_end) = resolve_link_end_components( + src_end, dst_end = resolve_link_end_components( link.attrs, self.components_library ) - # Inspect provided names for warnings link_comp_capacity = 0.0 + src_comp, src_cnt, src_exclusive = src_end + dst_comp, dst_cnt, dst_exclusive = dst_end + + # Raw component names, so warnings can name unknown components. + hw_struct = link.attrs.get("hardware") + src_name = None + dst_name = None + if isinstance(hw_struct, dict): + src_map = hw_struct.get("source", {}) + dst_map = hw_struct.get("target", {}) + src_name = src_map.get("component") + dst_name = dst_map.get("component") + + if src_comp is None and src_name: + logger.warning( + "Link '%s->%s' unknown src hardware component '%s'.", + src, + dst, + src_name, + ) + if dst_comp is None and dst_name: + logger.warning( + "Link '%s->%s' unknown dst hardware component '%s'.", + src, + dst, + dst_name, + ) - # Initialize defaults for cost/power even when no per-end hardware - src_cost = 0.0 - src_power = 0.0 - dst_cost = 0.0 - dst_power = 0.0 - src_comp = None - dst_comp = None - src_cnt_bom = 0.0 - dst_cnt_bom = 0.0 - - if per_end: - src_comp, src_cnt, src_exclusive = src_end - dst_comp, dst_cnt, dst_exclusive = dst_end - - # Unknown component warnings with names - hw_struct = link.attrs.get("hardware") - src_name = None - dst_name = None - if isinstance(hw_struct, dict): - src_map = hw_struct.get("source", {}) - dst_map = hw_struct.get("target", {}) - src_name = src_map.get("component") - dst_name = dst_map.get("component") - - if src_comp is None and src_name: - logger.warning( - "Link '%s->%s' unknown src hardware component '%s'.", - src, - dst, - src_name, - ) - if dst_comp is None and dst_name: - logger.warning( - "Link '%s->%s' unknown dst hardware component '%s'.", - src, - dst, - dst_name, - ) - - # Optics contribute only if the endpoint node has hardware - src_endpoint_has_hw = node_has_hw.get(src, False) - if src_comp is not None and src_endpoint_has_hw: - # For BOM, apply ceiling for exclusive use - src_cnt_bom = float(int(src_cnt) if src_exclusive else src_cnt) - src_cost, src_power, src_cap = totals_with_multiplier( - src_comp, src_cnt - ) - else: - src_cost, src_power, src_cap = 0.0, 0.0, 0.0 - src_cnt_bom = 0.0 - # Prevent BOM accumulation below - if not src_endpoint_has_hw: - src_comp = None - - dst_endpoint_has_hw = node_has_hw.get(dst, False) - if dst_comp is not None and dst_endpoint_has_hw: - dst_cnt_bom = float(int(dst_cnt) if dst_exclusive else dst_cnt) - dst_cost, dst_power, dst_cap = totals_with_multiplier( - dst_comp, dst_cnt - ) - else: - dst_cost, dst_power, dst_cap = 0.0, 0.0, 0.0 - dst_cnt_bom = 0.0 - if not dst_endpoint_has_hw: - dst_comp = None - - # Capacity limit: only enforce if both ends specify positive capacity - if src_cap > 0.0 and dst_cap > 0.0: - link_comp_capacity = min(src_cap, dst_cap) + # Optics contribute only if the endpoint node has hardware + src_endpoint_has_hw = node_has_hw.get(src, False) + if src_comp is not None and src_endpoint_has_hw: + # For BOM, apply ceiling for exclusive use + src_cnt_bom = float(math.ceil(src_cnt) if src_exclusive else src_cnt) + src_cost, src_power, src_cap = totals_with_multiplier(src_comp, src_cnt) + else: + src_cost, src_power, src_cap = 0.0, 0.0, 0.0 + src_cnt_bom = 0.0 + # Prevent BOM accumulation below + if not src_endpoint_has_hw: + src_comp = None + + dst_endpoint_has_hw = node_has_hw.get(dst, False) + if dst_comp is not None and dst_endpoint_has_hw: + dst_cnt_bom = float(math.ceil(dst_cnt) if dst_exclusive else dst_cnt) + dst_cost, dst_power, dst_cap = totals_with_multiplier(dst_comp, dst_cnt) + else: + dst_cost, dst_power, dst_cap = 0.0, 0.0, 0.0 + dst_cnt_bom = 0.0 + if not dst_endpoint_has_hw: + dst_comp = None + + # Capacity limit: only enforce if both ends specify positive capacity + if src_cap > 0.0 and dst_cap > 0.0: + link_comp_capacity = min(src_cap, dst_cap) cap = link.capacity src_node = self._node_map[src] @@ -663,20 +640,19 @@ def _compute_link_stats(self, node_has_hw: Dict[str, bool]) -> None: for an in A_src: an.stats.total_capex += src_cost an.stats.total_power += src_power - if per_end and src_comp is not None: + if src_comp is not None: an.stats.bom[src_comp.name] = ( an.stats.bom.get(src_comp.name, 0.0) + src_cnt_bom ) for an in A_dst: an.stats.total_capex += dst_cost an.stats.total_power += dst_power - if per_end and dst_comp is not None: + if dst_comp is not None: an.stats.bom[dst_comp.name] = ( an.stats.bom.get(dst_comp.name, 0.0) + dst_cnt_bom ) # ----- "ACTIVE" stats and validations ----- - # If link or either endpoint is disabled, skip if _link_is_disabled(link): continue if _node_is_disabled(self.network.nodes[src]): @@ -684,7 +660,6 @@ def _compute_link_stats(self, node_has_hw: Dict[str, bool]) -> None: if _node_is_disabled(self.network.nodes[dst]): continue - # Validation: if both ends provide capacity, enforce min-end capacity if link_comp_capacity > 0.0: if float(cap) > link_comp_capacity: if self.strict_validation: @@ -728,14 +703,14 @@ def _compute_link_stats(self, node_has_hw: Dict[str, bool]) -> None: for an in A_src: an.active_stats.total_capex += src_cost an.active_stats.total_power += src_power - if per_end and src_comp is not None: + if src_comp is not None: an.active_stats.bom[src_comp.name] = ( an.active_stats.bom.get(src_comp.name, 0.0) + src_cnt_bom ) for an in A_dst: an.active_stats.total_capex += dst_cost an.active_stats.total_power += dst_power - if per_end and dst_comp is not None: + if dst_comp is not None: an.active_stats.bom[dst_comp.name] = ( an.active_stats.bom.get(dst_comp.name, 0.0) + dst_cnt_bom ) @@ -754,13 +729,19 @@ def print_tree( """Print the hierarchy from 'node' down (default: root). Args: - node (TreeNode): subtree to print, or root if None - indent (int): indentation level - max_depth (int): if set, limit display depth - skip_leaves (bool): if True, skip leaf subtrees - detailed (bool): if True, print link capacity breakdowns - include_disabled (bool): If False, show stats only for enabled nodes/links. - Subtrees with zero active nodes are omitted. + node: Subtree to print; the root when None. + indent: Depth of ``node``, used for indentation and the + ``max_depth`` check. + max_depth: Deepest level to print; None prints all levels. + skip_leaves: Hide leaf subtrees and roll external-link targets + that are leaves up to their parent. + detailed: Add internal/external capacity and one line per + external destination. + include_disabled: If False, show stats only for enabled nodes/links. + Subtrees with zero active nodes are omitted. + max_external_lines: Cap on external destination lines per tree + node; None prints all. + line_prefix: String prepended to every printed line. """ if node is None: node = self.root_node @@ -771,19 +752,15 @@ def print_tree( if max_depth is not None and indent > max_depth: return - # Pick which stats to display stats = node.stats if include_disabled else node.active_stats - # If 'active' mode and this node has 0 nodes, omit it (unless it's the root) if not include_disabled and stats.node_count == 0 and node.parent is not None: return - # Possibly skip leaves if skip_leaves and node.is_leaf() and node.parent is not None: return total_links = stats.internal_link_count + stats.external_link_count - # Format numbers with separators; keep one decimal for capacities line = ( f"{' ' * indent}- {node.name or 'root'} | " f"Nodes={stats.node_count:,}, Links={total_links:,}, " @@ -797,19 +774,16 @@ def print_tree( print(f"{line_prefix}{line}") - # If detailed, show external link breakdown if detailed and stats.external_link_details: rolled_map: Dict[str, ExternalLinkBreakdown] = {} for other_path, info in stats.external_link_details.items(): rolled_path = other_path if skip_leaves: - # If that path is a leaf, roll up rolled_path = self._roll_up_if_leaf(rolled_path) accum = rolled_map.setdefault(rolled_path, ExternalLinkBreakdown()) accum.link_count += info.link_count accum.link_capacity += info.link_capacity - # Sort by descending capacity, then path items = sorted( rolled_map.items(), key=lambda kv: (-kv[1].link_capacity, kv[0]), @@ -831,7 +805,6 @@ def print_tree( ) break - # Recurse on children for child in node.children.values(): self.print_tree( node=child, @@ -845,7 +818,10 @@ def print_tree( ) def _roll_up_if_leaf(self, path: str) -> str: - """If 'path' is a leaf node's path, climb up until a non-leaf or root is found.""" + """Map a leaf's path to its parent's path. + + Non-leaf paths, top-level leaves, and unknown paths are returned unchanged. + """ node = self._path_map.get(path) if not node: return path @@ -932,5 +908,5 @@ def get_node_utilization(self) -> List[NodeUtilization]: return list(self._node_utilization.values()) def get_link_issues(self) -> List[LinkCapacityIssue]: - """Return recorded link capacity issues discovered in non-strict mode.""" + """Return link capacity violations recorded in non-strict mode.""" return list(self._link_issues) diff --git a/ngraph/lib/__init__.py b/ngraph/lib/__init__.py index 9e88a90..a0d289f 100644 --- a/ngraph/lib/__init__.py +++ b/ngraph/lib/__init__.py @@ -1,4 +1,4 @@ -"""Integration modules for external libraries (currently NetworkX).""" +"""Conversion between NetworkX graphs and NetGraph-Core graphs.""" from ngraph.lib.nx import EdgeMap, NodeMap, from_networkx, to_networkx diff --git a/ngraph/lib/nx.py b/ngraph/lib/nx.py index e4c2e2d..e344ad6 100644 --- a/ngraph/lib/nx.py +++ b/ngraph/lib/nx.py @@ -24,17 +24,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, Hashable, List, Optional, Tuple, Union +from typing import Any, Dict, Hashable, List, Optional, Tuple, Union import netgraph_core +import networkx as nx import numpy as np -if TYPE_CHECKING: - import networkx as nx - - NxGraph = Union[nx.DiGraph, nx.MultiDiGraph, nx.Graph, nx.MultiGraph] -else: - NxGraph = Any +NxGraph = Union[nx.DiGraph, nx.MultiDiGraph, nx.Graph, nx.MultiGraph] @dataclass @@ -43,8 +39,7 @@ class NodeMap: When converting a NetworkX graph to the internal representation, node names (which can be any hashable type) are mapped to contiguous integer indices - starting from 0. This class preserves the mapping for result interpretation - and back-conversion. + starting from 0. Keep the map to interpret results and convert back. Attributes: to_index: Maps original node names to integer indices @@ -90,8 +85,8 @@ class EdgeMap: """Bidirectional mapping between internal edge IDs and original edge references. When converting a NetworkX graph, each edge is assigned an internal integer ID - (ext_edge_id). This class preserves the mapping for interpreting algorithm - results and updating the original graph. + (ext_edge_id). Keep the map to interpret algorithm results and write them + back to the original graph. Attributes: to_ref: Maps internal edge ID to original (source, target, key) tuple @@ -170,8 +165,6 @@ def from_networkx( >>> edge_map.to_ref[0] # edge refs preserve original (u, v, key) ('src', 'dst', 0) """ - import networkx as nx - if not isinstance(G, (nx.DiGraph, nx.MultiDiGraph, nx.Graph, nx.MultiGraph)): raise TypeError( f"Expected NetworkX graph (DiGraph, MultiDiGraph, Graph, MultiGraph), " @@ -190,7 +183,6 @@ def from_networkx( node_map = NodeMap.from_names(node_names) num_nodes = len(node_names) - # Collect edges and build edge mapping src_list: List[int] = [] dst_list: List[int] = [] capacity_list: List[float] = [] @@ -203,7 +195,7 @@ def from_networkx( edge_id = 0 is_multigraph = isinstance(G, (nx.MultiDiGraph, nx.MultiGraph)) - # Iterate edges based on graph type + # Simple graphs have no edge keys; key 0 keeps every ref a (u, v, key) triple. if is_multigraph: edges_iter = G.edges(keys=True, data=True) else: @@ -225,7 +217,6 @@ def from_networkx( cst = int(cost_f) edge_ref: NxEdgeTuple = (u, v, key) - # Forward edge src_list.append(src_idx) dst_list.append(dst_idx) capacity_list.append(cap) @@ -235,7 +226,6 @@ def from_networkx( ref_to_edges.setdefault(edge_ref, []).append(edge_id) edge_id += 1 - # Reverse edge (if bidirectional) if bidirectional: src_list.append(dst_idx) dst_list.append(src_idx) @@ -249,27 +239,13 @@ def from_networkx( edge_map = EdgeMap(to_ref=edge_to_ref, from_ref=ref_to_edges) - # Graphs with nodes but no edges still need correctly typed empty arrays. - if not src_list: - src_arr = np.array([], dtype=np.int32) - dst_arr = np.array([], dtype=np.int32) - capacity_arr = np.array([], dtype=np.float64) - cost_arr = np.array([], dtype=np.int64) - ext_id_arr = np.array([], dtype=np.int64) - else: - src_arr = np.array(src_list, dtype=np.int32) - dst_arr = np.array(dst_list, dtype=np.int32) - capacity_arr = np.array(capacity_list, dtype=np.float64) - cost_arr = np.array(cost_list, dtype=np.int64) - ext_id_arr = np.array(ext_id_list, dtype=np.int64) - graph = netgraph_core.StrictMultiDiGraph.from_arrays( num_nodes=num_nodes, - src=src_arr, - dst=dst_arr, - capacity=capacity_arr, - cost=cost_arr, - ext_edge_ids=ext_id_arr, + src=np.array(src_list, dtype=np.int32), + dst=np.array(dst_list, dtype=np.int32), + capacity=np.array(capacity_list, dtype=np.float64), + cost=np.array(cost_list, dtype=np.int64), + ext_edge_ids=np.array(ext_id_list, dtype=np.int64), ) return graph, node_map, edge_map @@ -281,12 +257,12 @@ def to_networkx( *, capacity_attr: str = "capacity", cost_attr: str = "cost", -) -> "nx.MultiDiGraph": +) -> nx.MultiDiGraph: """Convert ngraph's internal graph format back to NetworkX MultiDiGraph. - Reconstructs a NetworkX graph from the internal representation. If a - NodeMap is provided, original node names are restored; otherwise, nodes - are labeled with integer indices. + Each Core edge becomes one NetworkX edge carrying only capacity and cost, + so reverse arcs added by ``from_networkx(bidirectional=True)`` come back as + separate edges. Args: graph: netgraph_core.StrictMultiDiGraph to convert @@ -298,6 +274,9 @@ def to_networkx( Returns: nx.MultiDiGraph with edges and attributes from the internal graph + Raises: + KeyError: If ``node_map`` lacks an entry for a node index of ``graph`` + Example: >>> graph, node_map, edge_map = from_networkx(G) >>> # ... run algorithms ... @@ -305,20 +284,15 @@ def to_networkx( >>> list(G_out.nodes()) ['A', 'B', 'C'] """ - import networkx as nx - G = nx.MultiDiGraph() num_nodes = graph.num_nodes() - # Add nodes with original names if available if node_map is not None: for idx in range(num_nodes): - name = node_map.to_name.get(idx, idx) - G.add_node(name) + G.add_node(node_map.to_name[idx]) else: G.add_nodes_from(range(num_nodes)) - # Extract edge data from graph views src_arr = graph.edge_src_view() dst_arr = graph.edge_dst_view() capacity_arr = graph.capacity_view() @@ -330,8 +304,8 @@ def to_networkx( dst_idx = int(dst_arr[i]) if node_map is not None: - src_name = node_map.to_name.get(src_idx, src_idx) - dst_name = node_map.to_name.get(dst_idx, dst_idx) + src_name = node_map.to_name[src_idx] + dst_name = node_map.to_name[dst_idx] else: src_name = src_idx dst_name = dst_idx diff --git a/ngraph/logging.py b/ngraph/logging.py index 498de37..6f2ba42 100644 --- a/ngraph/logging.py +++ b/ngraph/logging.py @@ -1,4 +1,4 @@ -"""Centralized logging configuration for NetGraph. +"""Logging configuration for NetGraph. Follows the standard library pattern: importing the package attaches only a ``logging.NullHandler`` to the root ``ngraph`` logger and never installs @@ -13,7 +13,7 @@ import sys from typing import Optional -# Flag to track if we've already set up the root logger +# Set by setup_root_logger(), cleared by reset_logging() _ROOT_LOGGER_CONFIGURED = False @@ -42,7 +42,6 @@ def setup_root_logger( # Replace the import-time NullHandler (and any stale handlers) root_logger.handlers.clear() - # Default format with timestamps, level, logger name, and message if format_string is None: format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -78,7 +77,7 @@ def get_logger(name: str) -> logging.Logger: """ logger = logging.getLogger(name) - # Don't add handlers to child loggers - they inherit from root + # Child loggers get no handlers; they inherit from the ngraph logger. logger.setLevel(logging.NOTSET) # Inherit from parent return logger @@ -94,10 +93,9 @@ def set_global_log_level(level: int) -> None: Args: level: Logging level (e.g., logging.DEBUG, logging.INFO). """ - # Ensure a console handler exists for applications that only call this + # Install the console handler for applications that only call this setup_root_logger(level=level) - # Set the root level for all ngraph loggers root_logger = logging.getLogger("ngraph") root_logger.setLevel(level) @@ -106,16 +104,6 @@ def set_global_log_level(level: int) -> None: handler.setLevel(level) -def enable_debug_logging() -> None: - """Enable debug logging for the entire package.""" - set_global_log_level(logging.DEBUG) - - -def disable_debug_logging() -> None: - """Disable debug logging, set to INFO level.""" - set_global_log_level(logging.INFO) - - def reset_logging() -> None: """Reset logging configuration (mainly for testing).""" global _ROOT_LOGGER_CONFIGURED diff --git a/ngraph/model/components.py b/ngraph/model/components.py index f4f42d2..8c6ed04 100644 --- a/ngraph/model/components.py +++ b/ngraph/model/components.py @@ -2,27 +2,27 @@ from __future__ import annotations +import math +import numbers from copy import deepcopy from dataclasses import dataclass, field from typing import Any, Dict, Optional, Tuple import yaml -from ngraph.logging import get_logger from ngraph.utils.yaml_utils import normalize_yaml_dict_keys -LOGGER = get_logger(__name__) - @dataclass class Component: - """A generic component that can represent chassis, line cards, optics, etc. - Components can have nested children, each with their own capex, power, etc. + """Hardware component such as a chassis, line card, or optic. + + Components can nest children, each with its own capex, power, and count. Attributes: name (str): Name of the component (e.g., "SpineChassis" or "400G-LR4"). - component_type (str): A string label (e.g., "chassis", "linecard", "optic"). - description (str): A human-readable description of this component. + component_type (str): Free-form label (e.g., "chassis", "linecard", "optic"). + description (str): Free-form human-readable description. capex (float): Monetary capex of a single instance of this component. power_watts (float): Typical/nominal power usage (watts) for one instance. power_watts_max (float): Maximum/peak power usage (watts) for one instance. @@ -39,8 +39,8 @@ class Component: description: str = "" capex: float = 0.0 - power_watts: float = 0.0 # Typical power usage - power_watts_max: float = 0.0 # Peak power usage + power_watts: float = 0.0 + power_watts_max: float = 0.0 capacity: float = 0.0 ports: int = 0 @@ -50,7 +50,7 @@ class Component: children: Dict[str, Component] = field(default_factory=dict) def total_capex(self) -> float: - """Computes total capex including children, multiplied by count.""" + """Return capex of this component and all descendants, times ``count``.""" single_instance_capex = self.capex for child in self.children.values(): single_instance_capex += child.total_capex() @@ -112,7 +112,7 @@ def as_dict(self, include_children: bool = True) -> Dict[str, Any]: "capacity": self.capacity, "ports": self.ports, "count": self.count, - "attrs": dict(self.attrs), # shallow copy + "attrs": dict(self.attrs), } if include_children: data["children"] = { @@ -124,8 +124,9 @@ def as_dict(self, include_children: bool = True) -> Dict[str, Any]: @dataclass class ComponentsLibrary: - """Holds a collection of named Components. Each entry is a top-level "template" - that can be referenced for cost/power/capacity lookups, possibly with nested children. + """Named Component templates for capex, power, and capacity lookups. + + Each entry is a top-level template and may have nested children. Example (YAML-like): components: @@ -187,7 +188,8 @@ def clone(self) -> ComponentsLibrary: """Creates a deep copy of this ComponentsLibrary. Returns: - ComponentsLibrary: A new, cloned library instance. + ComponentsLibrary: Independent copy; changes to it do not affect + this library. """ return ComponentsLibrary(components=deepcopy(self.components)) @@ -200,6 +202,10 @@ def from_dict(cls, data: Dict[str, Any]) -> ComponentsLibrary: Returns: ComponentsLibrary: A newly constructed library. + + Raises: + ValueError: If a definition has an unrecognized key or a ``name`` + that disagrees with its mapping key. """ # Normalize dictionary keys to handle YAML boolean keys normalized_data = normalize_yaml_dict_keys(data) @@ -214,11 +220,15 @@ def _build_component(cls, name: str, definition_data: Dict[str, Any]) -> Compone Args: name (str): Name to give the constructed component. - definition_data (Dict[str, Any]): Component definition. Recognized - keys map to fields; anything else is folded into ``attrs``. + definition_data (Dict[str, Any]): Component definition; custom + data belongs under ``attrs``. Returns: Component: The constructed Component instance. + + Raises: + ValueError: If the definition has keys other than the component + fields. """ comp_type = definition_data.get("component_type", "generic") capex = float(definition_data.get("capex", 0.0)) @@ -236,6 +246,7 @@ def _build_component(cls, name: str, definition_data: Dict[str, Any]) -> Compone children_map[child_name] = cls._build_component(child_name, child_data) recognized_keys = { + "name", "component_type", "capex", "power_watts", @@ -247,24 +258,21 @@ def _build_component(cls, name: str, definition_data: Dict[str, Any]) -> Compone "count", "description", } - attrs: Dict[str, Any] = dict(definition_data.get("attrs", {})) - # Normalize attrs keys to handle YAML boolean keys - attrs = normalize_yaml_dict_keys(attrs) - leftover_keys = { - k: v for k, v in definition_data.items() if k not in recognized_keys - } - # Normalize leftover keys too - leftover_keys = normalize_yaml_dict_keys(leftover_keys) - if "cost" in leftover_keys: - # Likely confusion with link 'cost'; without 'capex' the component - # contributes 0 to capex totals. - LOGGER.warning( - "Component '%s' defines unrecognized key 'cost'; it is stored " - "in attrs and ignored by capex calculations. Use 'capex' for " - "monetary cost.", - name, + # ``as_dict`` output carries the name; it must agree with the mapping key. + if definition_data.get("name", name) != name: + raise ValueError( + f"Component '{name}' has mismatched name " + f"'{definition_data['name']}' in its definition" + ) + unknown = sorted(str(k) for k in definition_data if k not in recognized_keys) + if unknown: + hint = " Use 'capex' for monetary cost." if "cost" in unknown else "" + raise ValueError( + f"Component '{name}' has unrecognized key(s): {', '.join(unknown)}. " + f"Put custom data under 'attrs'.{hint}" ) - attrs.update(leftover_keys) + # Normalize attrs keys to handle YAML boolean keys + attrs = normalize_yaml_dict_keys(dict(definition_data.get("attrs", {}))) return Component( name=name, @@ -282,9 +290,10 @@ def _build_component(cls, name: str, definition_data: Dict[str, Any]) -> Compone @classmethod def from_yaml(cls, yaml_str: str) -> ComponentsLibrary: - """Constructs a ComponentsLibrary from a YAML string. If the YAML contains - a top-level 'components' key, that key is used; otherwise the entire - top-level is treated as component definitions. + """Constructs a ComponentsLibrary from a YAML string. + + If the YAML has a top-level 'components' key, its value is used; + otherwise the whole top level is treated as component definitions. Args: yaml_str (str): A YAML-formatted string of component definitions. @@ -313,14 +322,32 @@ def from_yaml(cls, yaml_str: str) -> ComponentsLibrary: # ----------------------------- Helper utilities ----------------------------- +def _positive_count(value: Any) -> float: + """Return a hardware ``count`` as a finite positive float. + + Raises: + ValueError: If ``value`` is not a finite number greater than zero. + """ + count = ( + float(value) + if isinstance(value, numbers.Real) and not isinstance(value, bool) + else float("nan") + ) + if not (count > 0 and math.isfinite(count)): + raise ValueError( + f"Hardware 'count' must be a finite positive number, got {value!r}" + ) + return count + + def resolve_node_hardware( attrs: Dict[str, Any], library: ComponentsLibrary ) -> Tuple[Optional[Component], float]: """Resolve node hardware from ``attrs['hardware']``. Expects the mapping: ``{"hardware": {"component": NAME, "count": N}}``. - ``count`` defaults to 1 if missing or invalid. If ``component`` is missing - or unknown, returns ``(None, 1.0)``. + ``count`` defaults to 1. If ``component`` is missing or unknown, the + component is None. Args: attrs: Node attributes mapping. @@ -328,25 +355,17 @@ def resolve_node_hardware( Returns: Tuple of (component or None, positive multiplier). + + Raises: + ValueError: If ``count`` is not a finite positive number. """ hw = attrs.get("hardware") if not isinstance(hw, dict): return None, 1.0 name_raw = hw.get("component") - name = str(name_raw) if name_raw is not None else None - raw_count = hw.get("count", 1) - - try: - hw_count = float(raw_count) - except Exception: - hw_count = 1.0 - - if hw_count <= 0: - hw_count = 1.0 - - comp = library.get(name) if name else None - return comp, hw_count + comp = library.get(str(name_raw)) if name_raw else None + return comp, _positive_count(hw.get("count", 1)) def totals_with_multiplier( @@ -367,32 +386,12 @@ def totals_with_multiplier( return capex, power, capacity -# ------------------------- Link endpoint HW helpers ------------------------- -def _coerce_positive_float(value: Any, default: float = 1.0) -> float: - """Return ``value`` coerced to a positive float, else ``default``. - - Args: - value: Arbitrary value to parse as float. - default: Returned when parsing fails or the result is <= 0. - - Returns: - The parsed float when it is > 0, otherwise ``default`` (so the result - is strictly positive only when ``default`` is). - """ - try: - out = float(value) - except Exception: - return default - return out if out > 0 else default - - def resolve_link_end_components( attrs: Dict[str, Any], library: ComponentsLibrary, ) -> tuple[ tuple[Optional[Component], float, bool], tuple[Optional[Component], float, bool], - bool, ]: """Resolve per-end hardware components for a link. @@ -400,17 +399,20 @@ def resolve_link_end_components( ``hardware`` key only: ``{"hardware": {"source": {"component": NAME, "count": N}, "target": {"component": NAME, "count": N}}}`` - An optional ``exclusive: true`` per end indicates unsharable usage; for - exclusive ends, validation and BOM counting round counts up to integers. + An optional ``exclusive: true`` per end indicates unsharable usage; BOM + counting rounds the count of an exclusive end up to an integer. Args: attrs: Link attributes mapping. library: Components library for lookups. Returns: - ((src_comp, src_count, src_exclusive), (dst_comp, dst_count, dst_exclusive), per_end_specified) - where components may be ``None`` if name is absent/unknown. ``per_end_specified`` - is True when a structured per-end mapping is present. + ((src_comp, src_count, src_exclusive), (dst_comp, dst_count, dst_exclusive)) + where a component is ``None`` when the end or its name is absent or + unknown. + + Raises: + ValueError: If an end's ``count`` is not a finite positive number. """ def _from_mapping( @@ -420,14 +422,12 @@ def _from_mapping( count_val = mapping.get("count", 1) exclusive = bool(mapping.get("exclusive", False)) comp = library.get(str(comp_name)) if comp_name is not None else None - return comp, _coerce_positive_float(count_val, 1.0), exclusive + return comp, _positive_count(count_val), exclusive - # 1) Structured under "hardware": {source: {...}, target: {...}} hw_struct = attrs.get("hardware") - if isinstance(hw_struct, dict): - src_map = hw_struct.get("source", {}) - dst_map = hw_struct.get("target", {}) - return _from_mapping(src_map), _from_mapping(dst_map), True - - # Only structured source/target format is supported. - return (None, 1.0, False), (None, 1.0, False), False + if not isinstance(hw_struct, dict): + return (None, 1.0, False), (None, 1.0, False) + return ( + _from_mapping(hw_struct.get("source", {})), + _from_mapping(hw_struct.get("target", {})), + ) diff --git a/ngraph/model/demand/builder.py b/ngraph/model/demand/builder.py index f2e2c02..8ec1c6e 100644 --- a/ngraph/model/demand/builder.py +++ b/ngraph/model/demand/builder.py @@ -11,7 +11,7 @@ from ngraph.model.demand.matrix import DemandSet from ngraph.model.demand.spec import StaticPath, TrafficDemand from ngraph.model.flow.policy_config import FlowPolicyPreset -from ngraph.utils.yaml_utils import normalize_yaml_dict_keys +from ngraph.utils.yaml_utils import check_no_extra_keys, normalize_yaml_dict_keys def build_demand_set(raw: Dict[str, List[dict]]) -> DemandSet: @@ -22,11 +22,14 @@ def build_demand_set(raw: Dict[str, List[dict]]) -> DemandSet: dictionaries with `TrafficDemand` constructor fields. Returns: - Initialized `DemandSet` with constructed `TrafficDemand` objects. + `DemandSet` with one entry per input set. An entry with an `expand` + block contributes one demand per expansion. Raises: - ValueError: If ``raw`` is not a mapping of name -> list[dict], - or if required fields are missing. + ValueError: If ``raw`` is not a mapping of name -> list[dict], a demand + is missing ``source``/``target`` or carries an unrecognized key, + ``flow_policy`` is not a preset name, or ``static_paths`` is + malformed. """ if not isinstance(raw, dict): raise ValueError("'demands' must be a mapping of name -> list[TrafficDemand]") @@ -46,7 +49,6 @@ def build_demand_set(raw: Dict[str, List[dict]]) -> DemandSet: f"got {type(d).__name__}" ) - # Handle expand block expand_spec = ExpansionSpec.from_dict(d) if expand_spec and not expand_spec.is_empty(): for expanded in expand_block(d, expand_spec): @@ -59,9 +61,26 @@ def build_demand_set(raw: Dict[str, List[dict]]) -> DemandSet: return ds +_DEMAND_KEYS = frozenset( + { + "id", + "source", + "target", + "volume", + "priority", + "mode", + "group_mode", + "flow_policy", + "static_paths", + "attrs", + "expand", + } +) + + def _build_demand(d: Dict[str, Any], set_name: str) -> TrafficDemand: """Build a single TrafficDemand from a dict.""" - # Validate required fields + check_no_extra_keys(d, _DEMAND_KEYS, f"demand in set '{set_name}'") if "source" not in d or "target" not in d: raise ValueError( f"Each demand in set '{set_name}' requires 'source' and 'target' fields" @@ -73,7 +92,6 @@ def _build_demand(d: Dict[str, Any], set_name: str) -> TrafficDemand: f"selector dict, got {type(d[fld]).__name__}" ) - # Build normalized dict for TrafficDemand constructor td_kwargs: Dict[str, Any] = { "source": d["source"], "target": d["target"], @@ -84,11 +102,9 @@ def _build_demand(d: Dict[str, Any], set_name: str) -> TrafficDemand: "attrs": d.get("attrs", {}), } - # Optional id if "id" in d: td_kwargs["id"] = d["id"] - # Coerce flow_policy into FlowPolicyPreset enum when provided if "flow_policy" in d: td_kwargs["flow_policy"] = coerce_flow_policy(d["flow_policy"]) @@ -146,47 +162,21 @@ def _names(values: Any, kind: str) -> Tuple[str, ...]: def coerce_flow_policy(value: Any) -> Optional[FlowPolicyPreset]: - """Return a FlowPolicyPreset from various user-friendly forms. + """Return the FlowPolicyPreset a demand's `flow_policy` value names. - Accepts: - - None: returns None - - FlowPolicyPreset: returned as-is - - int: mapped by value (e.g., 1 -> SHORTEST_PATHS_ECMP); bools are - rejected (True/False are not presets 1/0) - - str: name of enum (case-insensitive); numeric strings are allowed + Accepts None, a FlowPolicyPreset, or a preset name (case-insensitive). Raises: - ValueError: If the value is not one of the accepted forms (including - bool and dict/object configs, which are not supported). + ValueError: For any other value, including integers and unknown names. """ - if value is None: - return None - if isinstance(value, FlowPolicyPreset): + if value is None or isinstance(value, FlowPolicyPreset): return value - # bool is a subclass of int; True/False must not coerce to presets 1/0. - if isinstance(value, int) and not isinstance(value, bool): - try: - return FlowPolicyPreset(value) - except Exception as exc: - raise ValueError(f"Unknown flow policy value: {value}") from exc if isinstance(value, str): - s = value.strip() - if not s: - return None - # Allow numeric strings - if s.isdigit(): - try: - return FlowPolicyPreset(int(s)) - except Exception as exc: - raise ValueError(f"Unknown flow policy value: {s}") from exc - # Enum name lookup (case-insensitive) try: - return FlowPolicyPreset[s.upper()] - except KeyError as exc: - raise ValueError(f"Unknown flow policy: {value}") from exc - + return FlowPolicyPreset[value.strip().upper()] + except KeyError: + pass valid = ", ".join(p.name for p in FlowPolicyPreset) raise ValueError( - f"Invalid flow_policy: {value!r}; expected a FlowPolicyPreset name " - f"or integer (one of: {valid})" + f"Invalid flow_policy: {value!r}; expected a preset name (one of: {valid})" ) diff --git a/ngraph/model/demand/matrix.py b/ngraph/model/demand/matrix.py index e959ac2..cca430c 100644 --- a/ngraph/model/demand/matrix.py +++ b/ngraph/model/demand/matrix.py @@ -45,32 +45,6 @@ def get_set(self, name: str) -> list[TrafficDemand]: """ return self.sets[name] - def get_default_set(self) -> list[TrafficDemand]: - """Get default demand set. - - Prefers the set named 'default'. Falls back to the sole set when - exactly one exists, and to an empty list when there are none. - - Returns: - List of TrafficDemand objects for the default set. - - Raises: - ValueError: If multiple sets exist without a 'default' set. - """ - if not self.sets: - return [] - - if "default" in self.sets: - return self.sets["default"] - - if len(self.sets) == 1: - return next(iter(self.sets.values())) - - raise ValueError( - f"Multiple demand sets exist ({list(self.sets.keys())}) but no 'default' set. " - f"Please specify which set to use or add a 'default' set." - ) - def get_all_demands(self) -> list[TrafficDemand]: """Get all traffic demands from all sets combined. diff --git a/ngraph/model/demand/spec.py b/ngraph/model/demand/spec.py index 2f3d5a5..1853db2 100644 --- a/ngraph/model/demand/spec.py +++ b/ngraph/model/demand/spec.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, Optional, Tuple, Union -from ngraph.model.flow.policy_config import FlowPolicyPreset, serialize_policy_preset +from ngraph.model.flow.policy_config import FlowPolicyPreset from ngraph.types.base import Mode from ngraph.utils.ids import new_base64_uuid @@ -47,7 +47,7 @@ def __post_init__(self) -> None: @dataclass class TrafficDemand: - """Traffic demand specification using unified selectors. + """Traffic demand between selector-matched source and target nodes. Attributes: source: Source node selector (string path or selector dict). @@ -57,7 +57,8 @@ class TrafficDemand: mode: Node pairing mode ("combine" or "pairwise"). group_mode: How grouped nodes produce demands ("flatten", "per_group", "group_pairwise"). - flow_policy: Policy preset for routing. + flow_policy: Routing preset; None uses ``DEFAULT_PRESET`` + (SHORTEST_PATHS_ECMP). 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. @@ -78,7 +79,13 @@ class TrafficDemand: id: str = "" def __post_init__(self) -> None: - """Validate mode fields and generate id if not provided.""" + """Validate field values and generate an id if not provided. + + Raises: + ValueError: If ``mode`` or ``group_mode`` is not a known value, + ``flow_policy`` is not a FlowPolicyPreset or None, or a + ``static_paths`` entry is not a StaticPath. + """ if self.mode not in _VALID_MODES: raise ValueError( f"Unknown demand mode '{self.mode}'. " @@ -89,6 +96,13 @@ def __post_init__(self) -> None: f"Unknown demand group_mode '{self.group_mode}'. " f"Expected one of: {', '.join(_VALID_GROUP_MODES)}" ) + if self.flow_policy is not None and not isinstance( + self.flow_policy, FlowPolicyPreset + ): + raise ValueError( + f"flow_policy must be a FlowPolicyPreset or None, got " + f"{self.flow_policy!r}" + ) for path in self.static_paths: if not isinstance(path, StaticPath): raise ValueError( @@ -96,17 +110,17 @@ def __post_init__(self) -> None: f"{type(path).__name__}" ) if not self.id: - # Build a stable identifier from source/target + # Source/target prefix for readability; the UUID suffix keeps ids + # unique across demands with the same selectors. src_key = self.source if isinstance(self.source, str) else str(self.source) tgt_key = self.target if isinstance(self.target, str) else str(self.target) self.id = f"{src_key}|{tgt_key}|{new_base64_uuid()}" def to_dict(self) -> Dict[str, Any]: - """Return the canonical serialized form (results output, snapshots). + """Return the canonical serialized form. - The flow policy is serialized to its preset name; use the raw - `flow_policy` attribute for analysis wire formats that expect the - preset object. + Used for results output, snapshots, and the demand configs analysis + functions accept. The flow policy is serialized to its preset name. """ return { "id": self.id, @@ -116,7 +130,9 @@ def to_dict(self) -> Dict[str, Any]: "priority": int(self.priority), "mode": self.mode, "group_mode": self.group_mode, - "flow_policy": serialize_policy_preset(self.flow_policy), + "flow_policy": ( + self.flow_policy.name if self.flow_policy is not None else None + ), "static_paths": [ {"nodes": list(p.nodes)} if p.nodes else {"links": list(p.links)} for p in self.static_paths diff --git a/ngraph/model/failure/__init__.py b/ngraph/model/failure/__init__.py index d23cb00..46aebf7 100644 --- a/ngraph/model/failure/__init__.py +++ b/ngraph/model/failure/__init__.py @@ -1,8 +1,7 @@ """Failure modeling package. -Provides primitives to define failure selection rules for Monte Carlo -failure analyses. The `policy` module defines data classes for expressing -selection logic over nodes, links, and risk groups. +Rules that select which nodes, links, and risk groups fail in Monte Carlo +failure analyses. Public entry points: diff --git a/ngraph/model/failure/generate.py b/ngraph/model/failure/generate.py index a053303..c7469f6 100644 --- a/ngraph/model/failure/generate.py +++ b/ngraph/model/failure/generate.py @@ -18,6 +18,7 @@ link_path_key, resolve_attr_path, ) +from ngraph.utils.yaml_utils import check_no_extra_keys if TYPE_CHECKING: from ngraph.model.network import Network @@ -49,7 +50,8 @@ def generate_risk_groups(network: "Network", spec: GenerateSpec) -> List[RiskGro """Generate risk groups from unique attribute values. For each unique value of the specified attribute, creates a new risk - group and adds all matching entities to it. + group and adds its name to the `risk_groups` set of every matching + entity, in place. The caller registers the returned groups. Args: network: Network with nodes and links populated. @@ -61,9 +63,6 @@ def generate_risk_groups(network: "Network", spec: GenerateSpec) -> List[RiskGro Raises: ValueError: If `group_by` resolves to an unhashable value, or if the name template renders the same group name for two distinct values. - - Note: - Modifies entity risk_groups sets in place. """ path_pattern = re.compile(spec.path) if spec.path else None @@ -79,7 +78,7 @@ def generate_risk_groups(network: "Network", spec: GenerateSpec) -> List[RiskGro for link_id, link in network.links.items() ] - # Apply path filter if specified + # Nodes match `path` against the node name, links against link_path_key. if path_pattern: if spec.scope == "node": entities = [ @@ -94,7 +93,7 @@ def generate_risk_groups(network: "Network", spec: GenerateSpec) -> List[RiskGro if path_pattern.match(link_path_key(attrs)) ] - # Group by attribute value + # Entities lacking the attribute, or holding None, join no group. groups: Dict[Any, List] = defaultdict(list) for entity_id, entity, attrs in entities: found, value = resolve_attr_path(attrs, spec.group_by) @@ -108,7 +107,6 @@ def generate_risk_groups(network: "Network", spec: GenerateSpec) -> List[RiskGro f"'{entity_id}'; group_by requires scalar attribute values" ) from exc - # Create risk groups result: List[RiskGroup] = [] seen_names: Dict[str, Any] = {} for value, members in groups.items(): @@ -151,10 +149,13 @@ def parse_generate_spec(raw: Dict[str, Any]) -> GenerateSpec: Parsed GenerateSpec. Raises: - ValueError: If 'scope' is missing or is neither 'node' nor 'link', if - 'group_by' or 'name' is missing, or if 'name' omits the '${value}' - placeholder. + ValueError: If the block carries an unrecognized key, 'scope' is + missing or is neither 'node' nor 'link', 'group_by' or 'name' is + missing, or 'name' omits the '${value}' placeholder. """ + check_no_extra_keys( + raw, {"scope", "path", "group_by", "name", "attrs"}, "generate block" + ) scope = raw.get("scope") if not scope: raise ValueError("generate requires 'scope' field (node or link)") diff --git a/ngraph/model/failure/membership.py b/ngraph/model/failure/membership.py index d7e9997..05a7cd4 100644 --- a/ngraph/model/failure/membership.py +++ b/ngraph/model/failure/membership.py @@ -21,6 +21,7 @@ match_entity_ids, parse_match_spec, ) +from ngraph.utils.yaml_utils import check_no_extra_keys if TYPE_CHECKING: from ngraph.model.network import Link, Network, Node, RiskGroup @@ -54,12 +55,11 @@ def resolve_membership_rules(network: "Network") -> None: - If scope is "risk_group": adds matched risk groups as children of this risk group (hierarchical membership). + Modifies entities in place. Call after all risk groups are registered + but before validation. + Args: network: Network with risk_groups, nodes, and links populated. - - Note: - Modifies entities in place. Call after all risk groups are registered - but before validation. """ # Flattened attribute maps are shared by all membership rules; build each # lazily once instead of re-flattening every entity per rule. @@ -99,17 +99,14 @@ def _flat(scope: str) -> Dict[str, Dict[str, Any]]: matched_count = 0 if spec.scope == "risk_group": - # Hierarchical: add matched groups as children matched_rgs = _select_risk_groups(network, spec, _flat("risk_group")) for matched_rg in matched_rgs: - # Don't add self-reference + # A group never becomes its own child (a one-node cycle). if matched_rg.name != rg_name: - # Avoid duplicates if matched_rg not in rg.children: rg.children.append(matched_rg) matched_count += 1 else: - # Add rg_name to each matched entity's risk_groups matched_entities = _select_entities(network, spec, _flat(spec.scope)) matched_count = len(matched_entities) for entity in matched_entities: @@ -133,9 +130,11 @@ def _parse_membership_spec(raw: Dict[str, Any]) -> MembershipSpec: Parsed MembershipSpec. Raises: - ValueError: If 'scope' is missing or is not one of node/link/ - risk_group, or if neither 'path' nor 'match' is given. + ValueError: If the rule carries an unrecognized key, 'scope' is + missing or is not one of node/link/risk_group, or neither 'path' + nor 'match' is given. """ + check_no_extra_keys(raw, {"scope", "path", "match"}, "membership rule") scope = raw.get("scope") if not scope: raise ValueError( @@ -154,7 +153,8 @@ def _parse_membership_spec(raw: Dict[str, Any]) -> MembershipSpec: match_spec = None if match_raw is not None: - # Use unified parser with membership-specific defaults + # Unlike failure rules, membership rules default to "and" logic and + # need at least one condition. match_spec = parse_match_spec( match_raw, default_logic="and", diff --git a/ngraph/model/failure/parser.py b/ngraph/model/failure/parser.py index 62cd31b..53fbb0c 100644 --- a/ngraph/model/failure/parser.py +++ b/ngraph/model/failure/parser.py @@ -4,7 +4,6 @@ from typing import Any, Callable, Dict, List, Optional -from ngraph.logging import get_logger from ngraph.model.failure.policy import ( FailureMode, FailurePolicy, @@ -13,9 +12,10 @@ from ngraph.model.failure.policy_set import FailurePolicySet from ngraph.model.network import RiskGroup from ngraph.model.selectors import parse_match_spec -from ngraph.utils.yaml_utils import normalize_yaml_dict_keys +from ngraph.utils.yaml_utils import check_no_extra_keys, normalize_yaml_dict_keys -_logger = get_logger(__name__) +_RG_KEYS = frozenset({"name", "attrs", "children", "disabled", "membership"}) +_RG_CHILD_KEYS = frozenset({"name", "attrs", "children"}) def build_risk_groups( @@ -41,6 +41,11 @@ def build_risk_groups( Tuple of (explicit_risk_groups, generate_specs_raw): - explicit_risk_groups: List of RiskGroup objects with names expanded. - generate_specs_raw: List of raw generate block dicts for deferred processing. + + Raises: + ValueError: If an entry is neither a string nor a dict, lacks a name, + carries an unrecognized key, or is a child entry with + 'membership', 'disabled' or 'generate'. """ from ngraph.dsl.expansion import expand_name_patterns @@ -60,11 +65,10 @@ def build_one(d: Dict[str, Any]) -> RiskGroup: if not name: raise ValueError("RiskGroup entry missing 'name' field.") disabled = d.get("disabled", False) - # Recursively expand and build children children_list = d.get("children", []) child_objs = expand_and_build(children_list, in_children=True) attrs = normalize_yaml_dict_keys(d.get("attrs", {})) - # Extract membership rule for deferred resolution + # Resolved later by resolve_membership_rules, once all groups exist. membership_raw = d.get("membership") return RiskGroup( name=name, @@ -81,7 +85,6 @@ def expand_and_build( result: List[RiskGroup] = [] for entry in entries: normalized = normalize_entry(entry) - # Reject generate blocks in children (not supported) if "generate" in normalized: raise ValueError("'generate' blocks not allowed in children") if in_children: @@ -101,6 +104,11 @@ def expand_and_build( name = normalized.get("name", "") if not name: raise ValueError("RiskGroup entry missing 'name' field.") + check_no_extra_keys( + normalized, + _RG_CHILD_KEYS if in_children else _RG_KEYS, + f"risk group '{name}'", + ) expanded_names = expand_name_patterns(name) for exp_name in expanded_names: modified = dict(normalized) @@ -108,12 +116,13 @@ def expand_and_build( result.append(build_one(modified)) return result - # Separate generate blocks from explicit risk groups + # Generate blocks need the built network, so they are returned unprocessed. explicit_entries: List[Any] = [] generate_specs: List[Dict[str, Any]] = [] for entry in rg_data: if isinstance(entry, dict) and "generate" in entry: + check_no_extra_keys(entry, {"generate"}, "risk group generate entry") generate_specs.append(entry["generate"]) else: explicit_entries.append(entry) @@ -121,6 +130,13 @@ def expand_and_build( return expand_and_build(explicit_entries), generate_specs +_POLICY_KEYS = frozenset({"modes", "attrs", "expand_groups"}) +_MODE_KEYS = frozenset({"weight", "rules", "attrs"}) +_RULE_KEYS = frozenset( + {"scope", "match", "mode", "probability", "count", "weight_by", "path"} +) + + def build_failure_policy( fp_data: Dict[str, Any], *, @@ -139,20 +155,21 @@ def build_failure_policy( FailurePolicy: Configured policy with parsed modes and rules. Raises: - ValueError: If modes is empty or malformed, if rules are invalid, or - if no mode has positive weight. + ValueError: If modes is empty or malformed, if rules are invalid, if + no mode has positive weight, or if the policy, a mode or a rule + carries an unrecognized key. """ def build_rules(rule_dicts: List[Dict[str, Any]]) -> List[FailureRule]: out: List[FailureRule] = [] for rule_dict in rule_dicts: + check_no_extra_keys(rule_dict, _RULE_KEYS, "failure rule") scope = rule_dict.get("scope") if not scope: raise ValueError( "failure rule requires 'scope' field (node, link, or risk_group)" ) - # Parse the match block with the unified parser match_spec = parse_match_spec( rule_dict.get("match", {}), context="failure rule" ) @@ -170,6 +187,7 @@ def build_rules(rule_dicts: List[Dict[str, Any]]) -> List[FailureRule]: ) return out + check_no_extra_keys(fp_data, _POLICY_KEYS, f"failure policy '{policy_name}'") expand_groups = fp_data.get("expand_groups", False) attrs = normalize_yaml_dict_keys(fp_data.get("attrs", {})) @@ -180,6 +198,7 @@ def build_rules(rule_dicts: List[Dict[str, Any]]) -> List[FailureRule]: for m in modes_data: if not isinstance(m, dict): raise ValueError("Each mode must be a mapping.") + check_no_extra_keys(m, _MODE_KEYS, "failure mode") weight = float(m.get("weight", 0.0)) mode_rules_data = m.get("rules", []) if not isinstance(mode_rules_data, list): @@ -223,7 +242,7 @@ def build_failure_policy_set( """ if not isinstance(raw, dict): raise ValueError( - "'failure_policy_set' must be a mapping of name -> FailurePolicy definition" + "'failures' must be a mapping of name -> FailurePolicy definition" ) normalized_fps = normalize_yaml_dict_keys(raw) diff --git a/ngraph/model/failure/policy.py b/ngraph/model/failure/policy.py index f1fad47..bf46dcc 100644 --- a/ngraph/model/failure/policy.py +++ b/ngraph/model/failure/policy.py @@ -83,7 +83,7 @@ def __post_init__(self) -> None: @dataclass class FailureMode: - """A weighted mode that encapsulates a set of rules applied together. + """Weighted set of rules applied together. Exactly one mode is selected per failure iteration according to the mode weights. Within a mode, all contained rules are applied and their @@ -103,7 +103,7 @@ class FailureMode: @dataclass class FailurePolicy: - """A container for failure modes plus optional metadata in `attrs`. + """Weighted failure modes plus optional metadata in `attrs`. The main entry point is `apply_failures_typed`, which: 1) Builds a single RNG for the entire call (from `seed` or `self.seed`). @@ -148,11 +148,11 @@ def apply_failures_typed( """Identify which entities fail for this iteration, typed by scope. A single ``random.Random`` instance is created from the effective seed - (``seed`` if given, else ``self.seed``). All random draws -- mode - selection followed by per-rule entity selection -- are sequential from - this one stream, ensuring that rules are statistically independent. - When no seed is available, an unseeded ``Random()`` instance is used so - that results are isolated from the global ``random`` module state. + (``seed`` if given, else ``self.seed``). All random draws (mode + selection, then per-rule entity selection) come in sequence from this + one stream, so rules are statistically independent. When no seed is + available, an unseeded ``Random()`` instance is used so that results + are isolated from the global ``random`` module state. Args: network_nodes: Mapping of node_id -> flattened attribute dict. @@ -161,7 +161,7 @@ def apply_failures_typed( seed: Optional deterministic seed for selection. Overrides ``self.seed`` when provided. failure_trace: Optional dict to populate with trace data (mode selection, - rule selections, expansion). If provided, will be mutated in-place. + rule selections, expansion). Mutated in place when given. prepared_matches: Optional mapping from ``id(rule)`` to already-sorted candidate IDs. Used by FailureManager to avoid repeated matching. prepared_weights: Optional per-rule weight splits from @@ -190,20 +190,19 @@ def apply_failures_typed( failed_links: Set[str] = set() failed_risk_groups: Set[str] = set() - # Initialize trace structure if requested if failure_trace is not None: failure_trace.update( { "mode_index": None, "mode_attrs": {}, "selections": [], - "expansion": {"nodes": [], "links": [], "risk_groups": []}, + "expansion": {"nodes": [], "links": []}, } ) - # Build a single RNG for this entire apply_failures call. - # All random draws (mode selection, entity selection across rules) - # come from this one stream, ensuring statistical independence. + # One RNG for the whole call. All random draws (mode selection, then + # entity selection for each rule) come from this one stream, which + # keeps rules statistically independent. effective_seed = seed if seed is not None else self.seed rng = ( _random.Random(effective_seed) @@ -222,7 +221,6 @@ def apply_failures_typed( failure_trace["mode_index"] = mode_index failure_trace["mode_attrs"] = dict(self.modes[mode_index].attrs) - # Collect matched from each rule, then select for idx, rule in enumerate(rules_to_apply): matched_ids: Sequence[str] | Set[str] if prepared_matches is not None and id(rule) in prepared_matches: @@ -251,7 +249,6 @@ def apply_failures_typed( weight_split=weight_split, ) - # Record selection in trace if non-empty if failure_trace is not None and selected: failure_trace["selections"].append( { @@ -292,13 +289,10 @@ def apply_failures_typed( rg_members=prepared_rg_members, ) - # Capture expansion in trace if failure_trace is not None: failure_trace["expansion"] = { "nodes": sorted(failed_nodes - pre_nodes), "links": sorted(failed_links - pre_links), - # Expansion adds member nodes/links; the group set never grows. - "risk_groups": [], } return failed_nodes, failed_links, failed_risk_groups @@ -349,7 +343,7 @@ def prepare_matches( """Prepare stable ordered candidate pools for all rules in this policy. Pre-computes the set of matching entity IDs for each rule so that - ``apply_failures`` can skip per-iteration condition evaluation. + ``apply_failures_typed`` can skip per-iteration condition evaluation. Args: network_nodes: Mapping of node_id -> flattened attribute dict. @@ -425,18 +419,9 @@ def prepare_weights( network_links if rule.scope == "link" else network_risk_groups ) ) - positives: Dict[str, float] = {} - zeros: list[str] = [] - for eid in prepared_matches[rule_key]: - w = FailurePolicy._extract_weight( - entity_map.get(eid), rule.weight_by - ) - w = float(w) if isinstance(w, (int, float)) else 0.0 - if w <= 0.0: - zeros.append(eid) - else: - positives[eid] = w - prepared[rule_key] = (positives, tuple(zeros)) + prepared[rule_key] = FailurePolicy._split_by_weight( + prepared_matches[rule_key], entity_map, rule.weight_by + ) return prepared def _match_scope( @@ -485,8 +470,8 @@ def _select_entities( """Select entities for failure per rule. For mode="choice" and rule.weight_by set, perform weighted sampling - without replacement according to the specified attribute. If all weights - are non-positive or missing, fallback to uniform sampling. + without replacement according to the specified attribute. Entities + with zero or missing weight fill any remaining picks uniformly. Args: entity_ids: Candidate entity IDs. Accepts a pre-sorted sequence @@ -501,9 +486,9 @@ def _select_entities( if not entity_ids: return set() - # Ensure deterministic mapping from RNG draws to entity IDs. Prepared - # matches are already ordered (used as-is, no copy); sets must still - # be sorted here. + # Sorted order makes the mapping from RNG draws to entity IDs + # deterministic. Prepared matches are already ordered (used as-is, no + # copy); sets are sorted here. ordered_ids: Sequence[str] = ( entity_ids if isinstance(entity_ids, (tuple, list)) else sorted(entity_ids) ) @@ -527,21 +512,13 @@ def _select_entities( # positive/zero split is static per rule; use the precomputed one # (see prepare_weights) when the caller supplies it. if rule.weight_by: - if weight_split is not None: - positives, zeros = weight_split - else: - positives = {} - zeros_list: list[str] = [] - for eid in ordered_ids: - w = FailurePolicy._extract_weight( - entity_map.get(eid), rule.weight_by - ) - w = float(w) if isinstance(w, (int, float)) else 0.0 - if w <= 0.0: - zeros_list.append(eid) - else: - positives[eid] = w - zeros = tuple(zeros_list) + positives, zeros = ( + weight_split + if weight_split is not None + else FailurePolicy._split_by_weight( + ordered_ids, entity_map, rule.weight_by + ) + ) selected: set[str] = set() if positives: @@ -549,7 +526,7 @@ def _select_entities( selected |= FailurePolicy._weighted_sample_without_replacement( positives, k, rng ) - # If we still need more picks, fill uniformly from zero-weight items + # Fill any remaining picks uniformly from zero-weight items. remaining = count - len(selected) if remaining > 0 and zeros: # zeros already follow ordered_ids order; preserve that @@ -558,12 +535,29 @@ def _select_entities( selected |= set(rng.sample(pool, k=min(remaining, len(pool)))) return selected - # Uniform sampling when no weighting is requested return set(rng.sample(ordered_ids, k=count)) # mode == "all" (validated in FailureRule.__post_init__) return set(ordered_ids) + @staticmethod + def _split_by_weight( + entity_ids: Sequence[str], entity_map: Dict[str, Any], weight_by: str + ) -> Tuple[Dict[str, float], Tuple[str, ...]]: + """Split ids into positive weights (id -> weight) and zero/missing ones. + + Both parts keep the order of ``entity_ids``. + """ + positives: Dict[str, float] = {} + zeros: List[str] = [] + for eid in entity_ids: + w = FailurePolicy._extract_weight(entity_map.get(eid), weight_by) + if w <= 0.0: + zeros.append(eid) + else: + positives[eid] = w + return positives, tuple(zeros) + @staticmethod def _extract_weight(entity: Optional[Dict[str, Any]], attr_name: str) -> float: """Extract a numeric weight from a flattened attribute dict. @@ -595,7 +589,7 @@ def _weighted_sample_without_replacement( Returns: Set of selected item ids. """ - # Sort by item id to ensure a stable order of RNG draws per item + # Sort by item id so RNG draws map to items in a stable order. positive_items: List[Tuple[str, float]] = sorted( [(k, w) for k, w in weights.items() if w > 0.0], key=lambda x: x[0] ) @@ -606,12 +600,12 @@ def _weighted_sample_without_replacement( # monotone transform of u ** (1/w), so the ranking is identical where # the linear form is exact, but the log form neither underflows to 0.0 # for tiny weights (~1e-5, e.g. per-hour failure rates) nor saturates - # to 1.0 for huge ones -- both of which silently degenerated selection - # into descending-id order regardless of weights. + # to 1.0 for huge ones. Either would degenerate selection into + # descending-id order regardless of weights. scored: List[Tuple[float, str]] = [] for item_id, w in positive_items: u = rng.random() - # Guard against u=0.0 -> use minimal positive number + # random() can return 0.0, and log(0.0) is undefined. if u <= 0.0: u = 1e-12 scored.append((math.log(u) / w, item_id)) @@ -638,7 +632,6 @@ def _select_mode_index( if float(m.weight) > 0.0 ] if not effective: - # Degenerate: no positive weights -> no mode is selected return None total = sum(w for _, w in effective) r = rng.random() * total @@ -690,8 +683,9 @@ def _expand_risk_groups( network_risk_groups: Optional[Dict[str, Any]] = None, rg_members: Optional[Dict[str, Tuple[frozenset, frozenset]]] = None, ) -> None: - """Expand failures among any node/link that shares a risk group - with a failed entity. BFS until no new failures. + """Fail every node or link that shares a risk group with a failed entity. + + Runs breadth-first until no new failures appear. When ``failed_risk_groups`` is given, the members of those groups (transitively through child groups) seed the expansion as well, so a diff --git a/ngraph/model/failure/policy_set.py b/ngraph/model/failure/policy_set.py index fdea06e..c3d6fd4 100644 --- a/ngraph/model/failure/policy_set.py +++ b/ngraph/model/failure/policy_set.py @@ -1,8 +1,8 @@ """Failure policy containers. -Provides `FailurePolicySet`, a named collection of `FailurePolicy` objects -used as input to failure analysis workflows. This module contains input -containers, not analysis results. +`FailurePolicySet` is a named collection of `FailurePolicy` objects used as +input to failure analysis workflows. These are input containers, not analysis +results. """ from __future__ import annotations diff --git a/ngraph/model/failure/validation.py b/ngraph/model/failure/validation.py index 9dbe384..4b31fa4 100644 --- a/ngraph/model/failure/validation.py +++ b/ngraph/model/failure/validation.py @@ -1,9 +1,8 @@ """Risk group reference validation. Validates that all risk group references in nodes and links resolve to -defined risk groups. Catches typos and missing definitions early. - -Also provides cycle detection for risk group hierarchies. +defined risk groups, catching typos and missing definitions early, and +detects cycles in risk group hierarchies. """ from __future__ import annotations @@ -15,7 +14,7 @@ def validate_risk_group_references(network: "Network") -> None: - """Ensure every risk group named by a node or link is defined. + """Check that every risk group named by a node or link is defined. Names are checked against network.risk_groups; typos and missing definitions would otherwise cause silent failures in simulations. @@ -64,7 +63,6 @@ def validate_risk_group_hierarchy(network: "Network") -> None: Raises: ValueError: If a cycle is detected, with details about the cycle path. """ - # Build adjacency from parent -> children names children_map: Dict[str, List[str]] = {} for rg_name, rg in network.risk_groups.items(): children_map[rg_name] = [child.name for child in rg.children] @@ -80,10 +78,10 @@ def dfs(node: str) -> List[str]: color[node] = GRAY for child in children_map.get(node, []): if child not in color: - # Child not in risk_groups (shouldn't happen after validation) + # Nested groups are not registered top-level; nothing to visit continue if color[child] == GRAY: - # Found cycle - reconstruct path + # A GRAY child is a back edge; walk parents to rebuild the cycle. cycle = [child, node] current = node while parent.get(current) and parent[current] != child: @@ -106,10 +104,7 @@ def dfs(node: str) -> List[str]: if cycle: cycle_str = " -> ".join(cycle) + f" -> {cycle[0]}" raise ValueError( - f"Circular reference detected in risk group hierarchy:\n" - f" {cycle_str}\n\n" - f"Risk groups cannot form cycles in their parent-child relationships. " - f"This may be caused by membership rules with scope='risk_group' " - f"that create mutual parent-child relationships. Review the membership " - f"rules for these groups and adjust conditions to break the cycle." + f"Circular reference in risk group hierarchy: {cycle_str}. " + "Check the children lists and any membership rules with " + "scope 'risk_group' that add these groups to each other." ) diff --git a/ngraph/model/flow/__init__.py b/ngraph/model/flow/__init__.py index d910de9..5032f7e 100644 --- a/ngraph/model/flow/__init__.py +++ b/ngraph/model/flow/__init__.py @@ -6,17 +6,14 @@ Public API: FlowPolicyPreset: Enum of common flow policy configurations create_flow_policy: Factory function to create FlowPolicy instances - serialize_policy_preset: Serialize preset to string for JSON storage """ from ngraph.model.flow.policy_config import ( FlowPolicyPreset, create_flow_policy, - serialize_policy_preset, ) __all__ = [ "FlowPolicyPreset", "create_flow_policy", - "serialize_policy_preset", ] diff --git a/ngraph/model/flow/policy_config.py b/ngraph/model/flow/policy_config.py index b9d48db..917466a 100644 --- a/ngraph/model/flow/policy_config.py +++ b/ngraph/model/flow/policy_config.py @@ -10,25 +10,16 @@ from __future__ import annotations from enum import IntEnum -from typing import Any, Optional +from typing import Optional -from ngraph.logging import get_logger - -try: - import netgraph_core -except ImportError as e: - raise ImportError( - "netgraph_core module not found. Ensure NetGraph-Core is installed." - ) from e - -logger = get_logger(__name__) +import netgraph_core class FlowPolicyPreset(IntEnum): - """Enumerates common flow policy presets for traffic routing. + """Named 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. + Each preset maps to a combination of path algorithm, flow placement + strategy, and edge selection mode in NetGraph-Core. The ``SHORTEST_PATHS_*`` presets model hop-by-hop IP/IGP forwarding: routes follow link costs alone and each demand is placed in one pass on the @@ -67,9 +58,9 @@ class FlowPolicyPreset(IntEnum): Each LSP is a distinct tunnel using a single path (MPLS LSP semantics). Multiple LSPs can share the same path. With N LSPs and M paths where N > M, LSPs are distributed - across paths (~N/M LSPs per path). ECMP constraint ensures all LSPs carry equal volume. + across paths (~N/M LSPs per path). The ECMP constraint gives all LSPs equal volume. - Configuration: multipath=False ensures tunnel-based ECMP (not hash-based ECMP). + Configuration: multipath=False makes this tunnel-based ECMP, not hash-based ECMP. """ TE_ECMP_16_LSP = 5 @@ -79,14 +70,14 @@ class FlowPolicyPreset(IntEnum): Each LSP is a distinct tunnel using a single path (MPLS LSP semantics). With 16 LSPs and M paths: if M โ‰ฅ 16, one LSP per path; if M < 16, some paths carry multiple LSPs. - ECMP constraint ensures all LSPs carry equal volume. + The ECMP constraint gives all LSPs equal volume. Example: 15 parallel paths (capacity 1.0 each) with 16 LSPs: - 15 paths carry 1 LSP, 1 path carries 2 LSPs - ECMP constraint limits all LSPs to 0.5 units (bottleneck path: 1.0 / 2 = 0.5) - Total: 16 ร— 0.5 = 8.0 units - Configuration: multipath=False ensures tunnel-based ECMP (not hash-based ECMP). + Configuration: multipath=False makes this tunnel-based ECMP, not hash-based ECMP. """ SHORTEST_PATHS_ECMP_LOSSY = 6 @@ -100,6 +91,9 @@ class FlowPolicyPreset(IntEnum): """ +#: Preset for demands that leave ``flow_policy`` unset. +DEFAULT_PRESET = FlowPolicyPreset.SHORTEST_PATHS_ECMP + #: Presets that model hop-by-hop IP/IGP forwarding: cost-only routes, one #: placement pass per demand, no rerouting. In combine mode these presets #: originate an even share of the demand at every source that can reach a @@ -135,7 +129,6 @@ def preset_config(preset: FlowPolicyPreset) -> netgraph_core.FlowPolicyConfig: ValueError: If an unknown FlowPolicyPreset value is provided. """ config = netgraph_core.FlowPolicyConfig() - config.path_alg = netgraph_core.PathAlg.SPF if preset in HOP_BY_HOP_PRESETS: # Hop-by-hop IP/IGP forwarding: cost-only routing, single pass, one @@ -174,7 +167,7 @@ def preset_config(preset: FlowPolicyPreset) -> netgraph_core.FlowPolicyConfig: FlowPolicyPreset.TE_ECMP_16_LSP, ): # TE with ECMP flow placement over single-path tunnels. - # multipath=False ensures each LSP is a single path (MPLS tunnel semantics) + # multipath=False keeps each LSP on one path (MPLS tunnel semantics). config.flow_placement = netgraph_core.FlowPlacement.EQUAL_BALANCED config.selection = netgraph_core.EdgeSelection( multi_edge=False, @@ -207,8 +200,8 @@ def create_flow_policy( Args: algorithms: NetGraph-Core Algorithms instance. graph: NetGraph-Core Graph handle. - preset: Preset whose path algorithm, placement, edge selection, and - flow-count bounds to apply (see ``preset_config``). + preset: Preset whose placement, edge selection, and flow-count bounds + to apply (see ``preset_config``). 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 @@ -240,27 +233,3 @@ def create_flow_policy( return netgraph_core.FlowPolicy( algorithms, graph, config, node_mask=node_mask, edge_mask=edge_mask ) - - -def serialize_policy_preset(cfg: Any) -> Optional[str]: - """Serialize a FlowPolicyPreset to its string name for JSON storage. - - Args: - cfg: FlowPolicyPreset enum, an integer coercible to one, or any other - value. - - Returns: - Preset name (e.g. "SHORTEST_PATHS_ECMP"); None when ``cfg`` is None. - Values that do not map to a preset are logged at debug level and - returned as ``str(cfg)``. - """ - if cfg is None: - return None - if isinstance(cfg, FlowPolicyPreset): - return cfg.name - # Try to coerce integer to enum - try: - return FlowPolicyPreset(int(cfg)).name - except (ValueError, TypeError) as exc: - logger.debug("Unrecognized flow_policy_preset value: %r (%s)", cfg, exc) - return str(cfg) diff --git a/ngraph/model/network.py b/ngraph/model/network.py index 02a9943..40bc470 100644 --- a/ngraph/model/network.py +++ b/ngraph/model/network.py @@ -14,10 +14,7 @@ @dataclass class Node: - """Represents a node in the network. - - Each node is uniquely identified by its name, which is used as - the key in the Network's node dictionary. + """Network node, keyed by its unique name in ``Network.nodes``. Attributes: name (str): Unique identifier for the node. @@ -34,7 +31,7 @@ class Node: @dataclass class Link: - """Represents one directed link between two nodes. + """One directed link between two nodes. The model stores a single direction (``source`` -> ``target``). When the analysis graph is built (via ``AnalysisContext`` / netgraph-core), a reverse @@ -76,7 +73,7 @@ def __post_init__(self) -> None: @dataclass class RiskGroup: - """Represents a shared-risk or failure domain, which may have nested children. + """Shared-risk or failure domain, optionally with nested children. Risk groups model correlated failures: when a risk group fails, all entities (nodes, links) in that group fail together. Hierarchical children enable @@ -105,12 +102,12 @@ class RiskGroup: @dataclass class Network: - """A container for network nodes and links. + """Scenario-level topology: nodes, links, and risk groups. - Network represents the scenario-level topology with persistent state (nodes/links - that are disabled in the scenario configuration). For temporary exclusion of - nodes/links during analysis (e.g., failure simulation), use node_mask and edge_mask - parameters when calling NetGraph-Core algorithms. + Holds persistent state only (nodes and links disabled in the scenario + configuration). For temporary exclusion of nodes or links during analysis + (e.g., failure simulation), pass node_mask and edge_mask to NetGraph-Core + algorithms. Attributes: nodes (Dict[str, Node]): Mapping from node name -> Node object. @@ -194,8 +191,8 @@ def select_node_groups_by_path(self, path: str) -> Dict[str, List[Node]]: - With capturing groups: label is "|"-joined non-None captures. - Without captures: label is the original pattern string. - Note: For attribute-based grouping, use the unified selector system - with ``{"group_by": "attr_name"}`` dict selectors. + For attribute-based grouping, use a selector dict such as + ``{"group_by": "attr_name"}``. Args: path: Regex pattern for node name. @@ -205,8 +202,8 @@ def select_node_groups_by_path(self, path: str) -> Dict[str, List[Node]]: objects themselves are shared, so mutating the returned mapping or lists does not affect the internal selection cache. """ - # Check cache first. A shallow copy protects the cache from caller - # mutation (groups map and lists are fresh; Node objects are shared). + # Cache hits return a shallow copy so callers cannot mutate the cache + # (groups map and lists are fresh; Node objects are shared). cached = self._selection_cache.get(path) if cached is not None: return {label: list(nodes) for label, nodes in cached.items()} @@ -389,12 +386,9 @@ def disable_risk_group(self, name: str, recursive: bool = True) -> None: def enable_risk_group(self, name: str, recursive: bool = True) -> None: """Enable every node/link that has 'name' in its risk_groups. - Unknown group names are ignored. - - Note: - If a node or link is in multiple risk groups, enabling this group - will re-enable that node/link even if other groups containing it - remain disabled. + Unknown group names are ignored. A node or link in several risk + groups is re-enabled even if other groups containing it remain + disabled. Args: name (str): Name of the risk group to enable. diff --git a/ngraph/model/path.py b/ngraph/model/path.py index 48f6320..aaf4df7 100644 --- a/ngraph/model/path.py +++ b/ngraph/model/path.py @@ -1,4 +1,4 @@ -"""Lightweight representation of a single routing path. +"""Representation of a single routing path. ``Path`` stores a sequence of (node, parallel edges) elements plus a numeric cost. Paths sort by cost, compare by structure and cost, and support sub-path @@ -17,7 +17,7 @@ @dataclass class Path: - """Represents a single path in the network. + """Routing path as a sequence of nodes and the parallel edges leaving each. Attributes: path: Sequence of (node_name, (edge_refs...)) tuples representing the path. @@ -47,10 +47,10 @@ def __getitem__(self, idx: int) -> Tuple[str, Tuple[EdgeRef, ...]]: """Return the (node, parallel_edges) tuple at the specified index. Args: - idx: The index of the desired path element. + idx: Element index; negative values count from the end. Returns: - A tuple containing the node name and its associated parallel edge refs. + The node name and the parallel edge refs leaving it. """ return self.path[idx] @@ -109,19 +109,11 @@ def __eq__(self, other: Any) -> bool: return (self.path == other.path) and (self.cost == other.cost) def __hash__(self) -> int: - """Compute a hash based on the (path, cost) tuple. - - Returns: - The hash value of this Path. - """ + """Hash the (path, cost) tuple, consistent with ``__eq__``.""" return hash((self.path, self.cost)) def __repr__(self) -> str: - """Return a string representation of the path including its tuple and cost. - - Returns: - A debug-friendly string representation. - """ + """Return ``Path(, cost=)``.""" return f"Path({self.path}, cost={self.cost})" @cached_property @@ -148,15 +140,15 @@ def nodes_seq(self) -> Tuple[str, ...]: def get_sub_path(self, dst_node: str) -> Path: """Create a sub-path ending at the specified destination node. - The sub-path is formed by truncating the original path at the first occurrence - of `dst_node` and ensuring that the final element has an empty tuple of edges. + The original path is truncated at the first occurrence of `dst_node`, + and the final element gets an empty edge tuple. Args: dst_node: The node at which to truncate the path. Returns: - A new Path instance representing the sub-path from the original source - to `dst_node`. Cost is set to infinity to indicate recalculation needed. + A new Path from the original source to `dst_node`. Its cost is + infinity, signalling that the caller must recompute it. Raises: ValueError: If `dst_node` is not found in the current path. @@ -167,7 +159,6 @@ def get_sub_path(self, dst_node: str) -> Path: for node, parallel_edges in self.path: if node == dst_node: found = True - # Append the target node with an empty edge tuple. new_elements.append((node, ())) break @@ -176,6 +167,6 @@ def get_sub_path(self, dst_node: str) -> Path: if not found: raise ValueError(f"Node '{dst_node}' not found in path.") - # Cost set to infinity to explicitly signal recalculation is needed. - # EdgeRef-based cost calculation requires mapping back to graph edges. + # Infinite cost signals that recalculation is needed: computing cost + # from EdgeRefs requires mapping back to graph edges. return Path(tuple(new_elements), float("inf")) diff --git a/ngraph/model/selectors/conditions.py b/ngraph/model/selectors/conditions.py index 346a043..e8be322 100644 --- a/ngraph/model/selectors/conditions.py +++ b/ngraph/model/selectors/conditions.py @@ -73,7 +73,6 @@ def evaluate_condition(attrs: Dict[str, Any], cond: "Condition") -> bool: op = cond.op expected = cond.value - # Existence operators if op == "exists": return has_attr and attr_value is not None if op == "not_exists": @@ -83,13 +82,12 @@ def evaluate_condition(attrs: Dict[str, Any], cond: "Condition") -> bool: if attr_value is None: return False - # Equality operators if op == "==": return attr_value == expected if op == "!=": return attr_value != expected - # Numeric comparisons + # Ordering compares as floats; non-numeric operands never match. if op in ("<", "<=", ">", ">="): try: left = float(attr_value) @@ -105,7 +103,8 @@ def evaluate_condition(attrs: Dict[str, Any], cond: "Condition") -> bool: if op == ">=": return left >= right - # String/collection containment + # Substring test on strings, membership test on list/tuple/set. Other + # types never "contain", so not_contains is True for them. if op == "contains": if isinstance(attr_value, str): return str(expected) in attr_value @@ -120,7 +119,6 @@ def evaluate_condition(attrs: Dict[str, Any], cond: "Condition") -> bool: return expected not in attr_value return True - # List membership operators if op == "in": if not isinstance(expected, (list, tuple, set)): raise ValueError(f"'in' operator requires list value, got {type(expected)}") diff --git a/ngraph/model/selectors/parse.py b/ngraph/model/selectors/parse.py index 58e0859..61a38b5 100644 --- a/ngraph/model/selectors/parse.py +++ b/ngraph/model/selectors/parse.py @@ -9,6 +9,8 @@ from typing import Any, Dict, Literal +from ngraph.utils.yaml_utils import check_no_extra_keys + from .schema import Condition, MatchSpec @@ -21,7 +23,7 @@ def parse_match_spec( ) -> MatchSpec: """Parse a match specification from raw dict. - Shared by adjacency, demands, membership rules, and failure policies. + Shared by link definitions, demands, membership rules, and failure policies. Args: raw: Dict with 'conditions' list and optional 'logic'. Both keys are @@ -34,10 +36,12 @@ def parse_match_spec( Parsed MatchSpec. Raises: - ValueError: If 'logic' is not 'and'/'or', 'conditions' is not a list, - a condition is not a dict or lacks 'attr'/'op', 'in'/'not_in' is + ValueError: If the spec or a condition carries an unrecognized key, + 'logic' is not 'and'/'or', 'conditions' is not a list, a + condition is not a dict or lacks 'attr'/'op', 'in'/'not_in' is given a non-list value, or conditions are required but empty. """ + check_no_extra_keys(raw, {"conditions", "logic"}, context) logic = raw.get("logic", default_logic) if logic not in ("and", "or"): raise ValueError( @@ -58,6 +62,9 @@ def parse_match_spec( ) if "attr" not in cond_dict or "op" not in cond_dict: raise ValueError(f"Condition in {context} must have 'attr' and 'op'") + check_no_extra_keys( + cond_dict, {"attr", "op", "value"}, f"condition in {context}" + ) if cond_dict["op"] in ("in", "not_in") and not isinstance( cond_dict.get("value"), list ): diff --git a/ngraph/model/selectors/schema.py b/ngraph/model/selectors/schema.py index 3b596cb..1b64b90 100644 --- a/ngraph/model/selectors/schema.py +++ b/ngraph/model/selectors/schema.py @@ -1,6 +1,6 @@ -"""Schema definitions for unified node selection. +"""Selector schema dataclasses. -Dataclasses shared by network rules, demands, and workflow steps. +Shared by network rules, demands, failure policies, and workflow steps. """ from __future__ import annotations @@ -8,7 +8,6 @@ from dataclasses import dataclass, field from typing import Any, List, Literal, Optional, get_args -# Type alias for entity scope used in condition-based selection EntityScope = Literal["node", "link", "risk_group"] """Type of network entity for condition-based selection.""" @@ -60,7 +59,7 @@ def __post_init__(self) -> None: @dataclass class MatchSpec: - """Specification for filtering nodes by attribute conditions. + """Attribute conditions combined with "and" or "or" logic. Attributes: conditions: List of conditions to evaluate. @@ -73,7 +72,7 @@ class MatchSpec: @dataclass class NodeSelector: - """Unified node selection specification. + """Node selection by name regex, attribute conditions, and grouping. Evaluation order: 1. Select nodes matching `path` regex (default ".*" if omitted) diff --git a/ngraph/model/selectors/select.py b/ngraph/model/selectors/select.py index f8f9208..c4a1a9e 100644 --- a/ngraph/model/selectors/select.py +++ b/ngraph/model/selectors/select.py @@ -30,7 +30,7 @@ def select_nodes( selector: NodeSelector, default_active_only: bool, ) -> Dict[str, List["Node"]]: - """Unified entry point for node selection. + """Select and group the nodes a `NodeSelector` matches. Evaluation order: 1. Select nodes matching `path` regex (or all nodes if path is None) @@ -49,29 +49,24 @@ def select_nodes( Dict mapping group labels to lists of nodes. Groups that filter down to nothing are dropped. """ - # Resolve effective active_only flag active_only = ( selector.active_only if selector.active_only is not None else default_active_only ) - # Step 1: Select by path regex (or all nodes). Regex selection delegates - # to Network.select_node_groups_by_path() which provides caching. + # Network.select_node_groups_by_path() caches regex results per pattern. if selector.path is not None: candidates = network.select_node_groups_by_path(selector.path) else: candidates = {"_all_": list(network.nodes.values())} - # Step 2: Apply match conditions if selector.match is not None: candidates = _filter_by_match(candidates, selector.match) - # Step 3: Filter active only if active_only: candidates = _filter_active(candidates) - # Step 4: Apply grouping (overrides regex capture grouping) if selector.group_by is not None: return _group_by_attribute(candidates, selector.group_by) @@ -115,7 +110,6 @@ def flatten_node_attrs(node: "Node") -> Dict[str, Any]: # Sorted for deterministic group_by labels and ==/in comparisons. "risk_groups": sorted(node.risk_groups), } - # Add user attrs, but don't overwrite top-level fields attrs.update({k: v for k, v in node.attrs.items() if k not in attrs}) return attrs @@ -245,9 +239,8 @@ def _group_by_attribute( Supports both top-level fields (name, disabled, risk_groups) and custom attrs, consistent with match condition evaluation. Nodes lacking the - attribute are dropped. - - Note: This discards any existing grouping (including regex captures). + attribute are dropped. Any existing grouping, including regex captures, + is discarded. """ result: Dict[str, List["Node"]] = {} for nodes in groups.values(): diff --git a/ngraph/profiling/profiler.py b/ngraph/profiling/profiler.py index 28dca3e..141cd97 100644 --- a/ngraph/profiling/profiler.py +++ b/ngraph/profiling/profiler.py @@ -1,8 +1,8 @@ """Profiling for NetGraph workflow execution. -Provides CPU and wall-clock timing per workflow step using ``cProfile`` and -optionally peak memory via ``tracemalloc``. Aggregates results into structured -summaries and identifies time-dominant steps (bottlenecks). +Times each workflow step (CPU and wall clock) with ``cProfile`` and can record +peak memory with ``tracemalloc``. Steps that take more than 10% of total wall +time are reported as bottlenecks. """ from __future__ import annotations @@ -22,18 +22,33 @@ logger = get_logger(__name__) +def _top_functions(stats: pstats.Stats, limit: int) -> List[Tuple[str, float, int]]: + """Return (function, total_time, call_count) for the `limit` costliest functions. + + Reads the `stats` mapping of pstats (values are (cc, nc, tt, ct, callers)), + which typeshed does not declare, hence the getattr. + """ + stats_data = getattr(stats, "stats", {}) + ranked = sorted(stats_data.items(), key=lambda item: item[1][2], reverse=True) + return [ + (f"{func[0]}:{func[1]}({func[2]})", stat[2], stat[0]) + for func, stat in ranked[:limit] + ] + + @dataclass class StepProfile: """Performance profile data for a single workflow step. Attributes: step_name: Name of the workflow step. - step_type: Type/class name of the workflow step. + step_type: Class name of the workflow step. wall_time: Total wall-clock time in seconds. - cpu_time: CPU time spent in step execution. + cpu_time: CPU time in seconds (sum of cProfile internal times). function_calls: Number of function calls during execution. - memory_peak: Peak memory usage during step in bytes (if available). - cprofile_stats: Detailed cProfile statistics object. + memory_peak: Peak traced memory in bytes; None unless memory + tracking ran for this step. + cprofile_stats: cProfile statistics, including merged worker profiles. worker_profiles_merged: Number of worker profiles merged into this step. """ @@ -57,7 +72,7 @@ class ProfileResults: total_cpu_time: Total CPU time across all steps. total_function_calls: Total function calls across all steps. bottlenecks: List of performance bottlenecks (>10% execution time). - analysis_summary: Performance metrics and statistics. + analysis_summary: Aggregate metrics computed by ``analyze_performance``. """ step_profiles: List[StepProfile] = field(default_factory=list) @@ -87,12 +102,15 @@ def __init__(self, track_memory: bool = False): self._track_memory: bool = bool(track_memory) def start_scenario(self) -> None: - """Start profiling for the entire scenario execution.""" + """Record the scenario start time.""" self._scenario_start_time = time.perf_counter() logger.debug("Started scenario-level profiling") def end_scenario(self) -> None: - """End profiling for the entire scenario execution.""" + """Record total wall time and sum CPU time and calls across steps. + + Logs a warning and returns early if ``start_scenario`` was not called. + """ if self._scenario_start_time is None: logger.warning( "Scenario profiling ended without start - timing may be inaccurate" @@ -104,7 +122,6 @@ def end_scenario(self) -> None: self._scenario_end_time - self._scenario_start_time ) - # Calculate aggregate statistics self.results.total_cpu_time = sum( p.cpu_time for p in self.results.step_profiles ) @@ -120,11 +137,13 @@ def end_scenario(self) -> None: def profile_step( self, step_name: str, step_type: str ) -> Generator[None, None, None]: - """Context manager for profiling individual workflow steps. + """Profile the enclosed block as one workflow step. + + A StepProfile is appended when the block exits, including on error. Args: step_name: Name of the workflow step being profiled. - step_type: Type/class name of the workflow step. + step_type: Class name of the workflow step. Yields: None @@ -135,15 +154,17 @@ def profile_step( profiler = cProfile.Profile() profiler.enable() - # Per-step tracemalloc, when requested, to capture peak memory - mem_tracing_started = False - if self._track_memory: - try: - tracemalloc.start() - mem_tracing_started = True - except RuntimeError: - # Another tracemalloc session might be active; skip memory tracking - mem_tracing_started = False + # Per-step tracemalloc, when requested, to capture peak memory. Skipped + # while another session is tracing, since stopping ours would end it. + track_memory = self._track_memory and not tracemalloc.is_tracing() + if track_memory: + tracemalloc.start() + elif self._track_memory: + logger.warning( + "tracemalloc is already tracing; peak memory for step %s is not " + "recorded", + step_name, + ) try: yield @@ -156,31 +177,17 @@ def profile_step( stats_stream = io.StringIO() stats = pstats.Stats(profiler, stream=stats_stream) - # Extract CPU time and function call counts - # Access stats data through the stats attribute (pstats internal structure) + # pstats keeps per-function tuples (cc, nc, tt, ct, callers) in an + # undeclared `stats` attribute: primitive calls, total calls, + # internal time, cumulative time. CPU time sums tt; calls sum cc. stats_data = getattr(stats, "stats", {}) - # stats_data values are tuples: (cc, nc, tt, ct, callers) - # cc=call count, nc=number of calls, tt=total time, ct=cumulative time - cpu_time = sum( - stat_tuple[2] for stat_tuple in stats_data.values() - ) # tt = total time - function_calls = sum( - stat_tuple[0] for stat_tuple in stats_data.values() - ) # cc = call count + cpu_time = sum(stat_tuple[2] for stat_tuple in stats_data.values()) + function_calls = sum(stat_tuple[0] for stat_tuple in stats_data.values()) - memory_peak_bytes: Optional[int] = None - if mem_tracing_started: - try: - current, peak = tracemalloc.get_traced_memory() - memory_peak_bytes = int(peak) - except Exception as exc: - logger.debug("Failed to get traced memory: %s", exc) - memory_peak_bytes = None - finally: - try: - tracemalloc.stop() - except Exception as exc: - logger.debug("Failed to stop tracemalloc: %s", exc) + memory_peak: Optional[float] = None + if track_memory: + memory_peak = float(tracemalloc.get_traced_memory()[1]) + tracemalloc.stop() step_profile = StepProfile( step_name=step_name, @@ -188,9 +195,7 @@ def profile_step( wall_time=wall_time, cpu_time=cpu_time, function_calls=function_calls, - memory_peak=float(memory_peak_bytes) - if memory_peak_bytes is not None - else None, + memory_peak=memory_peak, cprofile_stats=stats, ) @@ -204,6 +209,10 @@ def profile_step( def merge_child_profiles(self, profile_dir: Path, step_name: str) -> None: """Merge child worker profiles into the parent step profile. + Adds every ``*_thread_*.pstats`` file in ``profile_dir`` to the step's + stats, recounts calls, and deletes the merged files. Merge errors are + logged as warnings, not raised. + Args: profile_dir: Directory containing worker profile files. step_name: Name of the workflow step these workers belong to. @@ -234,7 +243,6 @@ def merge_child_profiles(self, profile_dir: Path, step_name: str) -> None: logger.debug(f"Merged worker profile: {worker_file.name}") merged_count += 1 - # Update function call count after merge stats_data = getattr(step_profile.cprofile_stats, "stats", {}) step_profile.function_calls = sum( stat_tuple[0] for stat_tuple in stats_data.values() @@ -245,7 +253,6 @@ def merge_child_profiles(self, profile_dir: Path, step_name: str) -> None: f"Merged {len(worker_files)} worker profiles into step '{step_name}'" ) - # Clean up worker files after successful merge for worker_file in worker_files: try: worker_file.unlink() @@ -258,9 +265,9 @@ def merge_child_profiles(self, profile_dir: Path, step_name: str) -> None: logger.warning(f"Failed to merge worker profiles: {type(e).__name__}: {e}") def analyze_performance(self) -> None: - """Analyze profiling results and identify bottlenecks. + """Flag steps above 10% of total wall time and fill ``analysis_summary``. - Calculates timing percentages and identifies steps consuming >10% of execution time. + Call after ``end_scenario``, which sets the total wall time. """ if not self.results.step_profiles: logger.warning("No step profiles available for analysis") @@ -280,7 +287,6 @@ def analyze_performance(self) -> None: percentage = (step.wall_time / total_time) * 100 step_percentages.append((step, percentage)) - # Identify bottlenecks (steps taking >10% of total time) bottlenecks = [] for step, percentage in step_percentages: if percentage > 10.0: @@ -323,14 +329,15 @@ def analyze_performance(self) -> None: def get_top_functions( self, step_name: str, limit: int = 10 ) -> List[Tuple[str, float, int]]: - """Get the top CPU-consuming functions for a specific step. + """Return the step's functions with the highest internal time. Args: step_name: Name of the workflow step to analyze. limit: Maximum number of functions to return. Returns: - List of tuples containing (function_name, cpu_time, call_count). + List of (function_name, cpu_time, call_count) tuples; empty when + the step has no profile. """ step_profile = next( (p for p in self.results.step_profiles if p.step_name == step_name), None @@ -338,57 +345,27 @@ def get_top_functions( if not step_profile or not step_profile.cprofile_stats: return [] - stats = step_profile.cprofile_stats - - # Sort by total time and extract top functions - # Access stats data through the stats attribute (pstats internal structure) - stats_data = getattr(stats, "stats", {}) - # stats_data values are tuples: (cc, nc, tt, ct, callers) - sorted_stats = sorted( - stats_data.items(), - key=lambda x: x[1][2], - reverse=True, # Sort by total time (tt) - ) - - top_functions = [] - for func_info, stat_tuple in sorted_stats[:limit]: - func_name = f"{func_info[0]}:{func_info[1]}({func_info[2]})" - # stat_tuple = (cc, nc, tt, ct, callers) - top_functions.append( - (func_name, stat_tuple[2], stat_tuple[0]) - ) # (name, total_time, call_count) + return _top_functions(step_profile.cprofile_stats, limit) - return top_functions - - def save_detailed_profile( - self, output_path: Path, step_name: Optional[str] = None - ) -> None: - """Save detailed profiling data to a file. + def save_detailed_profile(self, output_path: Path, step_name: str) -> None: + """Save one step's cProfile data to a file. Args: - output_path: Path where the profile data should be saved. - step_name: Optional step name to save profile for specific step only. + output_path: Destination for the ``pstats`` dump. + step_name: Step whose profile to save. Logs a warning if the step + has no profile. """ - if step_name: - step_profile = next( - (p for p in self.results.step_profiles if p.step_name == step_name), - None, + step_profile = next( + (p for p in self.results.step_profiles if p.step_name == step_name), + None, + ) + if step_profile and step_profile.cprofile_stats: + step_profile.cprofile_stats.dump_stats(str(output_path)) + logger.info( + f"Detailed profile for step '{step_name}' saved to: {output_path}" ) - if step_profile and step_profile.cprofile_stats: - step_profile.cprofile_stats.dump_stats(str(output_path)) - logger.info( - f"Detailed profile for step '{step_name}' saved to: {output_path}" - ) - else: - logger.warning( - f"No detailed profile data available for step: {step_name}" - ) else: - raise NotImplementedError( - "Combined profile saving requires a step_name argument. " - "To save all step profiles, iterate over step_profiles and call " - "save_detailed_profile for each step." - ) + logger.warning(f"No detailed profile data available for step: {step_name}") class PerformanceReporter: @@ -401,46 +378,37 @@ def __init__(self, results: ProfileResults): """Initialize the performance reporter. Args: - results: ProfileResults object containing profiling data to report. + results: Profiling data, after ``analyze_performance`` has run. """ self.results = results def generate_report(self) -> str: - """Generate performance report. + """Render the full report. + + Sections: summary, step timings, bottlenecks (when any), and top + functions per bottleneck step. Returns: - Formatted performance report string. + Report text, or a one-line message when no steps were profiled. """ if not self.results.step_profiles: return "No profiling data available to report." report_lines = [] - - # Report header report_lines.extend( ["=" * 80, "NETGRAPH PERFORMANCE PROFILING REPORT", "=" * 80, ""] ) - - # Summary report_lines.extend(self._generate_summary()) - - # Step-by-step timing analysis report_lines.extend(self._generate_timing_analysis()) - - # Bottleneck analysis if self.results.bottlenecks: report_lines.extend(self._generate_bottleneck_analysis()) - - # Detailed function analysis report_lines.extend(self._generate_detailed_analysis()) - - # Report footer report_lines.extend(["", "=" * 80, "END OF PERFORMANCE REPORT", "=" * 80]) return "\n".join(report_lines) def _generate_summary(self) -> List[str]: - """Generate summary section of the report.""" + """Return section 1: totals, CPU efficiency, and call rate.""" summary = self.results.analysis_summary lines = [ @@ -465,15 +433,13 @@ def _generate_summary(self) -> List[str]: return lines def _generate_timing_analysis(self) -> List[str]: - """Generate step-by-step timing analysis section.""" + """Return section 2: one table row per step, slowest first.""" lines = ["2. WORKFLOW STEP TIMING ANALYSIS", "-" * 40, ""] - # Sort steps by execution time sorted_steps = sorted( self.results.step_profiles, key=lambda p: p.wall_time, reverse=True ) - # Create formatted table headers = [ "Step Name", "Type", @@ -485,7 +451,6 @@ def _generate_timing_analysis(self) -> List[str]: "Workers", ] - # Calculate column widths col_widths = [len(h) for h in headers] table_data = [] @@ -495,7 +460,6 @@ def _generate_timing_analysis(self) -> List[str]: if self.results.total_wall_time > 0 else 0 ) - # Format memory column if available mem_str = "-" if step.memory_peak is not None: mem_mb = float(step.memory_peak) / (1024 * 1024) @@ -515,11 +479,9 @@ def _generate_timing_analysis(self) -> List[str]: ] table_data.append(row) - # Update column widths for i, cell in enumerate(row): col_widths[i] = max(col_widths[i], len(cell)) - # Format table separator = " " header_line = separator.join( h.ljust(col_widths[i]) for i, h in enumerate(headers) @@ -537,13 +499,13 @@ def _generate_timing_analysis(self) -> List[str]: return lines def _generate_bottleneck_analysis(self) -> List[str]: - """Generate bottleneck analysis section.""" + """Return section 3, classifying each bottleneck by CPU/wall ratio.""" lines = ["3. PERFORMANCE BOTTLENECK ANALYSIS", "-" * 40, ""] for i, bottleneck in enumerate(self.results.bottlenecks, 1): efficiency = bottleneck["efficiency_ratio"] - # Classify workload type and generate specific recommendation + # A low CPU/wall ratio means the step spent most of its time waiting. if efficiency < 0.3: workload_type = "I/O-bound workload" recommendation = "Investigate I/O operations, external dependencies, or process coordination" @@ -571,10 +533,9 @@ def _generate_bottleneck_analysis(self) -> List[str]: return lines def _generate_detailed_analysis(self) -> List[str]: - """Generate detailed function-level analysis section.""" + """Return section 4: top five functions per bottleneck step.""" lines = ["4. DETAILED FUNCTION ANALYSIS", "-" * 40, ""] - # Show top functions for each bottleneck step for bottleneck in self.results.bottlenecks: step_name = bottleneck["step_name"] lines.append(f"Top CPU-consuming functions in '{step_name}':") @@ -586,24 +547,11 @@ def _generate_detailed_analysis(self) -> List[str]: break if profiler and profiler.cprofile_stats: - # Access stats data through the stats attribute (pstats internal structure) - stats_data = getattr(profiler.cprofile_stats, "stats", {}) - - # Sort by total time and get top 5 - # stats_data values are tuples: (cc, nc, tt, ct, callers) - sorted_funcs = sorted( - stats_data.items(), - key=lambda x: x[1][2], - reverse=True, # Sort by total time (tt) - )[:5] - - for func_info, stat_tuple in sorted_funcs: - func_name = f"{func_info[0]}:{func_info[1]}({func_info[2]})" + for func_name, total_time, calls in _top_functions( + profiler.cprofile_stats, 5 + ): lines.append(f" {func_name}") - # stat_tuple = (cc, nc, tt, ct, callers) - lines.append( - f" Time: {stat_tuple[2]:.4f}s, Calls: {stat_tuple[0]:,}" - ) + lines.append(f" Time: {total_time:.4f}s, Calls: {calls:,}") lines.append("") else: diff --git a/ngraph/results/artifacts.py b/ngraph/results/artifacts.py index cb13c16..a96f6d3 100644 --- a/ngraph/results/artifacts.py +++ b/ngraph/results/artifacts.py @@ -1,12 +1,12 @@ """Serializable result artifacts for analysis workflows. -`CapacityEnvelope` captures a frequency-based capacity distribution, plus -optional aggregated flow statistics, in JSON-serializable form. +`CapacityEnvelope` captures a frequency-based capacity distribution in +JSON-serializable form. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List @@ -26,10 +26,8 @@ class CapacityEnvelope: min_capacity: Minimum observed capacity. max_capacity: Maximum observed capacity. mean_capacity: Mean capacity across all samples. - stdev_capacity: Standard deviation of capacity values. + stdev_capacity: Population standard deviation of capacity values. total_samples: Total number of samples represented. - flow_summary_stats: Optional dictionary with aggregated FlowSummary statistics. - Contains cost_distribution_stats and other flow analytics. """ source_pattern: str @@ -41,7 +39,6 @@ class CapacityEnvelope: mean_capacity: float stdev_capacity: float total_samples: int - flow_summary_stats: Dict[str, Any] = field(default_factory=dict) @classmethod def from_values( @@ -50,19 +47,17 @@ def from_values( sink_pattern: str, mode: str, values: List[float], - flow_summaries: List[Any] | None = None, ) -> "CapacityEnvelope": - """Create envelope from capacity values and optional flow summaries. + """Create envelope from capacity values. Args: source_pattern: Source node pattern. sink_pattern: Sink node pattern. mode: Flow analysis mode. values: List of capacity values from Monte Carlo iterations. - flow_summaries: Optional list of FlowSummary objects for detailed analytics. Returns: - CapacityEnvelope instance with capacity statistics and optional flow analytics. + CapacityEnvelope instance with capacity statistics. Raises: ValueError: If ``values`` is empty. @@ -87,18 +82,14 @@ def from_values( # Second pass over unique values: compute variance using the # numerically stable formula sum((x - mean)^2) / n. - # Iterating over the frequency map is efficient when there are - # many duplicate values (common in Monte Carlo results). + # Iterating over the frequency map costs one step per distinct + # value, which is small when Monte Carlo results repeat. variance_sum = 0.0 for value, count in frequencies.items(): diff = value - mean_capacity variance_sum += count * diff * diff stdev_capacity = (variance_sum / n) ** 0.5 - flow_summary_stats = {} - if flow_summaries: - flow_summary_stats = cls._aggregate_flow_summaries(flow_summaries) - return cls( source_pattern=source_pattern, sink_pattern=sink_pattern, @@ -109,76 +100,11 @@ def from_values( mean_capacity=mean_capacity, stdev_capacity=stdev_capacity, total_samples=n, - flow_summary_stats=flow_summary_stats, ) - @classmethod - def _aggregate_flow_summaries(cls, flow_summaries: List[Any]) -> Dict[str, Any]: - """Aggregate FlowSummary objects into statistical summaries. - - Args: - flow_summaries: List of FlowSummary objects from Monte Carlo iterations. - - Returns: - Dictionary with aggregated flow analytics including cost distribution statistics. - """ - from collections import Counter, defaultdict - - # Aggregate cost distributions - cost_data = defaultdict(list) # cost -> list of flow volumes - min_cut_frequencies = defaultdict(int) # edge -> frequency count - - valid_summaries = [s for s in flow_summaries if s is not None] - if not valid_summaries: - return {} - - for summary in valid_summaries: - # Support compact dict summaries coming from workers - if isinstance(summary, dict): - cd = summary.get("cost_distribution", {}) - mc = summary.get("min_cut", []) - if isinstance(cd, dict): - for cost, flow_volume in cd.items(): - cost_data[cost].append(flow_volume) - if isinstance(mc, list): - for edge in mc: - edge_key = str(edge) - min_cut_frequencies[edge_key] += 1 - continue - - # Process object-like summaries with attributes - if hasattr(summary, "cost_distribution"): - for cost, flow_volume in getattr( - summary, "cost_distribution", {} - ).items(): - cost_data[cost].append(flow_volume) - - if hasattr(summary, "min_cut"): - for edge in getattr(summary, "min_cut", []) or []: - edge_key = str(edge) - min_cut_frequencies[edge_key] += 1 - - # Calculate cost distribution statistics - cost_distribution_stats = {} - for cost, volumes in cost_data.items(): - if volumes: - cost_distribution_stats[float(cost)] = { - "mean": sum(volumes) / len(volumes), - "min": min(volumes), - "max": max(volumes), - "total_samples": len(volumes), - "frequencies": dict(Counter(volumes)), - } - - return { - "cost_distribution_stats": cost_distribution_stats, - "min_cut_frequencies": dict(min_cut_frequencies), - "total_flow_summaries": len(valid_summaries), - } - def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" - result = { + return { "source": self.source_pattern, "sink": self.sink_pattern, "mode": self.mode, @@ -190,11 +116,6 @@ def to_dict(self) -> Dict[str, Any]: "total_samples": self.total_samples, } - if self.flow_summary_stats: - result["flow_summary_stats"] = self.flow_summary_stats - - return result - @classmethod def from_dict(cls, data: Dict[str, Any]) -> "CapacityEnvelope": """Construct a CapacityEnvelope from a dictionary. @@ -203,7 +124,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "CapacityEnvelope": data: Dictionary as produced by to_dict(). Returns: - CapacityEnvelope + CapacityEnvelope with frequency keys converted to float. """ # Frequencies keys may arrive as strings via JSON; normalize to float freqs_raw = data.get("frequencies", {}) or {} @@ -219,7 +140,6 @@ def from_dict(cls, data: Dict[str, Any]) -> "CapacityEnvelope": mean_capacity=float(data.get("mean", 0.0)), stdev_capacity=float(data.get("stdev", 0.0)), total_samples=int(data.get("total_samples", 0)), - flow_summary_stats=dict(data.get("flow_summary_stats", {})), ) def get_percentile(self, percentile: float) -> float: @@ -229,7 +149,8 @@ def get_percentile(self, percentile: float) -> float: percentile: Percentile to calculate (0-100). Returns: - Capacity value at the specified percentile. + Smallest capacity whose cumulative count reaches + ``percentile / 100 * total_samples``. Raises: ValueError: If ``percentile`` is outside [0, 100]. @@ -247,7 +168,8 @@ def get_percentile(self, percentile: float) -> float: if cumulative_count >= target_count: return capacity - return sorted_capacities[-1] # Return max if we somehow don't find it + # Reached only when frequency counts sum to less than total_samples. + return sorted_capacities[-1] def expand_to_values(self) -> List[float]: """Expand frequency map back to individual values. diff --git a/ngraph/results/flow.py b/ngraph/results/flow.py index d96aaaa..230ca5d 100644 --- a/ngraph/results/flow.py +++ b/ngraph/results/flow.py @@ -1,16 +1,12 @@ -"""Unified flow result containers for failure-analysis iterations. +"""Flow result containers for failure-analysis iterations. -Defines small, serializable dataclasses that capture per-iteration outcomes -for capacity and demand-placement style analyses in a unit-agnostic form. +Serializable dataclasses for per-iteration outcomes of capacity and +demand-placement analyses. Values are unit-agnostic. Objects expose `to_dict()` that returns JSON-safe primitives. Float-keyed -distributions are normalized to string keys via `_fmt_float_key()`, and -arbitrary `data` payloads are sanitized. These dicts are written under -`data.flow_results` by steps. - -Utilities: - _fmt_float_key: Formats floats as stable string keys for JSON serialization, - in fixed-point notation with trailing zeros stripped. +distributions are normalized to fixed-point string keys via +`_fmt_float_key()`, and `data` payloads are checked by `_ensure_json_safe()`. +Steps write these dicts under `data.flow_results`. """ from __future__ import annotations @@ -27,8 +23,8 @@ def _fmt_float_key(x: float, places: int = 9) -> str: """Format a float as a canonical string key for JSON serialization. - Uses fixed-point notation (never exponential) with trailing zeros stripped. - This ensures stable, human-readable keys for cost distributions. + Uses fixed-point notation (never exponential) with trailing zeros stripped, + so cost-distribution keys are stable and human-readable. Args: x: Float value to format. @@ -38,9 +34,7 @@ def _fmt_float_key(x: float, places: int = 9) -> str: Canonical string representation of the float in fixed-point notation. """ rounded = round(float(x), places) - # Use 'f' format for fixed-point (never exponential), then strip trailing zeros formatted = f"{rounded:.{places}f}" - # Strip trailing zeros after decimal point, then trailing decimal point if any if "." in formatted: formatted = formatted.rstrip("0").rstrip(".") return formatted @@ -94,7 +88,6 @@ def __post_init__(self) -> None: ) raise TypeError("FlowEntry.priority must be a non-negative int") - # Basic numeric validation for name, value in ( ("demand", self.demand), ("placed", self.placed), @@ -168,15 +161,13 @@ def to_dict(self) -> Dict[str, Any]: Builds dict directly from known fields instead of using asdict() to avoid the overhead of recursive _asdict_inner calls (significant for large result sets). """ - # Canonicalize cost_distribution keys as strings to avoid float artifacts - # and ensure stable JSON. Use decimal quantization for determinism. - normalized_costs: Dict[str, float] = {} - for k, v in self.cost_distribution.items(): - try: - key_str = _fmt_float_key(float(k)) - normalized_costs[key_str] = float(v) - except Exception: # pragma: no cover - defensive - normalized_costs[str(k)] = float(v) + # Canonicalize cost_distribution keys as fixed-point strings rounded + # to 9 places, so JSON keys are stable across float artifacts. + # __post_init__ guarantees finite numeric keys and values. + normalized_costs: Dict[str, float] = { + _fmt_float_key(float(k)): float(v) + for k, v in self.cost_distribution.items() + } return { "source": self.source, @@ -311,7 +302,6 @@ def __post_init__(self) -> None: Raises: ValueError: If summary/flow counts mismatch or failure_state invalid. """ - # Validate occurrence_count if not isinstance(self.occurrence_count, int) or self.occurrence_count < 1: logger.error( "FlowIterationResult.occurrence_count must be a positive int: %r", @@ -319,7 +309,6 @@ def __post_init__(self) -> None: ) raise ValueError("occurrence_count must be a positive int") - # Validate failure_state structure if present if self.failure_state is not None: if not isinstance(self.failure_state, dict): logger.error( @@ -334,13 +323,11 @@ def __post_init__(self) -> None: logger.error("failure_state.%s must be a list[str]", key) raise ValueError("failure_state lists must be list[str]") - # Validate contained flow entries for entry in self.flows: if not isinstance(entry, FlowEntry): logger.error("flows must contain FlowEntry instances: %r", type(entry)) raise TypeError("flows must contain FlowEntry instances") - # Summary consistency with flow count if self.summary.num_flows != len(self.flows): logger.error( "FlowIterationResult summary.num_flows (%d) != len(flows) (%d)", @@ -367,9 +354,16 @@ def to_dict(self) -> Dict[str, Any]: def _ensure_json_safe(obj: Any, depth: int = 4) -> Any: - """Return an equivalent object composed of JSON primitives (or raise). + """Return obj rebuilt from JSON primitives, or raise. + + Raises instead of silently serializing non-JSON-safe values. Dict keys + become strings. Values nested more than `depth` levels below obj are + returned unchecked. - This defends against silently serializing non-JSON-safe structures. + Raises: + ValueError: On a non-finite float. + TypeError: On a type other than None, str, bool, int, float, list, + or dict. """ if depth < 0: return obj diff --git a/ngraph/results/snapshot.py b/ngraph/results/snapshot.py index 53b7d81..9c091d4 100644 --- a/ngraph/results/snapshot.py +++ b/ngraph/results/snapshot.py @@ -25,12 +25,6 @@ def build_scenario_snapshot( Returns: Dict containing: seed, failures (policy snapshots), demands (demand snapshots). """ - # Delegate policy serialization to FailurePolicy.to_dict so the snapshot - # matches the scenario YAML format (rule conditions nested under "match"). - snapshot_failure_policies: Dict[str, Any] = { - name: policy.to_dict() for name, policy in failure_policy_set.policies.items() - } - snapshot_demands: Dict[str, list[dict[str, Any]]] = { sname: [d.to_dict() for d in demands] for sname, demands in demand_set.sets.items() @@ -38,6 +32,7 @@ def build_scenario_snapshot( return { "seed": seed, - "failures": snapshot_failure_policies, + # Scenario YAML format (rule conditions nested under "match"). + "failures": failure_policy_set.to_dict(), "demands": snapshot_demands, } diff --git a/ngraph/results/store.py b/ngraph/results/store.py index 547c9a8..5c1e795 100644 --- a/ngraph/results/store.py +++ b/ngraph/results/store.py @@ -1,16 +1,17 @@ """Generic results store for workflow steps and their metadata. `Results` organizes outputs by workflow step name and records -`WorkflowStepMetadata` for execution context. Storage is strictly -step-scoped: steps must write two keys under their namespace: +`WorkflowStepMetadata` for execution context. Storage is step-scoped, and a +step may write only two keys under its namespace: - ``metadata``: step-level metadata (dict) - ``data``: step-specific payload (dict) -Export with :meth:`Results.to_dict`, which returns a JSON-safe structure -with shape ``{workflow, steps, scenario}``. During export, objects with a -``to_dict()`` method are converted, dictionary keys are coerced to strings, -tuples are emitted as lists, and only JSON primitives are produced. +Export with :meth:`Results.to_dict`, which returns a structure with shape +``{workflow, steps, scenario}``. During export, objects with a ``to_dict()`` +method are converted, dictionary keys are coerced to strings, and tuples are +emitted as lists. Other values pass through unchanged, so steps must store +JSON-compatible values. """ from dataclasses import dataclass, field @@ -59,9 +60,8 @@ class WorkflowStepMetadata: - "scenario-derived": seed was derived from scenario.seed - "explicit-step": seed was explicitly provided for the step - "none": no seed provided/active for this step - active_seed: The effective base seed used by the step, if any. For steps - that use Monte Carlo execution, per-iteration seeds are derived from - active_seed (e.g., active_seed + iteration_index). + Monte Carlo steps derive per-iteration seeds from step_seed + (step_seed + iteration_index). """ step_type: str @@ -70,7 +70,6 @@ class WorkflowStepMetadata: scenario_seed: Optional[int] = None step_seed: Optional[int] = None seed_source: str = "none" - active_seed: Optional[int] = None @dataclass @@ -95,7 +94,6 @@ class Results: # Scenario snapshot _scenario: Dict[str, Any] = field(default_factory=dict) - # ---- Scope management ------------------------------------------------- def enter_step(self, step_name: str) -> None: """Enter step scope. Subsequent put/get are scoped to this step.""" self._active_step = step_name @@ -103,15 +101,14 @@ def enter_step(self, step_name: str) -> None: self._store[step_name] = {} def exit_step(self) -> None: - """Exit step scope.""" + """Exit step scope; put/get raise until the next enter_step.""" self._active_step = None - # ---- Step-scoped accessors ------------------------------------------- def put(self, key: str, value: Any) -> None: """Store a value in the active step under an allowed key. - Allowed keys are strictly "metadata" and "data". Both are expected to be - dictionaries at export time. + Allowed keys are "metadata" and "data". `to_dict()` requires both to be + dicts (or None). """ if self._active_step is None: raise RuntimeError("Results.put() called without active step scope") @@ -140,7 +137,6 @@ def put_step_metadata( scenario_seed: Optional[int] = None, step_seed: Optional[int] = None, seed_source: str = "none", - active_seed: Optional[int] = None, ) -> None: """Store metadata for a workflow step. @@ -151,7 +147,6 @@ def put_step_metadata( scenario_seed: Scenario-level seed from YAML, if any. step_seed: Seed attached to this step (explicit or derived), if any. seed_source: Source of step seed ("scenario-derived", "explicit-step", or "none"). - active_seed: Effective base seed used by the step, if any. """ self._metadata[step_name] = WorkflowStepMetadata( step_type=step_type, @@ -160,7 +155,6 @@ def put_step_metadata( scenario_seed=scenario_seed, step_seed=step_seed, seed_source=seed_source, - active_seed=active_seed, ) def get_step_metadata(self, step_name: str) -> Optional[WorkflowStepMetadata]: @@ -198,7 +192,6 @@ def set_scenario_snapshot(self, snapshot: Dict[str, Any]) -> None: def to_dict(self) -> Dict[str, Any]: """Return exported results with shape: {workflow, steps, scenario}.""" - # Workflow metadata workflow: Dict[str, Any] = { step_name: { "step_type": md.step_type, @@ -207,15 +200,12 @@ def to_dict(self) -> Dict[str, Any]: "scenario_seed": md.scenario_seed, "step_seed": md.step_seed, "seed_source": md.seed_source, - "active_seed": md.active_seed, } for step_name, md in self._metadata.items() } - # Steps data with validation and to_dict() conversion steps: Dict[str, Dict[str, Any]] = {} for step_name, data in self._store.items(): - # Enforce explicit keys if not set(data.keys()).issubset({"metadata", "data"}): invalid = ", ".join(sorted(set(data.keys()) - {"metadata", "data"})) raise ValueError( diff --git a/ngraph/scenario.py b/ngraph/scenario.py index 6392c49..df19493 100644 --- a/ngraph/scenario.py +++ b/ngraph/scenario.py @@ -54,10 +54,9 @@ class Scenario: results: Results = field(default_factory=Results) components_library: ComponentsLibrary = field(default_factory=ComponentsLibrary) seed: Optional[int] = None - # Per-instance execution counter for thread-safe step ordering + # Per-instance step execution counter, reset by run() _execution_counter: int = field(default=0, init=False, repr=False) - # Module-level logger _logger = get_logger(__name__) def run( @@ -77,7 +76,6 @@ def run( """ # Reject duplicate effective step names before executing anything. validate_unique_step_names(self.workflow) - # Reset instance execution counter for this run self._execution_counter = 0 for step in self.workflow: if step_hook is None: @@ -122,9 +120,6 @@ def from_yaml( If no 'workflow' key is provided, the scenario has no steps to run. If 'failures' is omitted, scenario.failure_policy_set is empty. If 'components' is provided, it is merged with default_components. - If 'seed' is provided, it enables reproducible random operations. - If 'vars' is provided, it can contain YAML anchors and aliases for reuse. - If any unrecognized top-level key is found, a ValueError is raised. Args: yaml_str (str): The YAML string that defines the scenario. @@ -136,26 +131,27 @@ def from_yaml( Raises: ValueError: If the YAML is malformed or missing required sections, - or if there are any unrecognized top-level keys. - TypeError: If a workflow step's arguments are invalid for the step class. + has unrecognized keys in any section, defines two top-level + risk groups with the same name, or gives a workflow step a + key its step class does not define. + jsonschema.ValidationError: If the YAML does not match the + packaged schema, including unrecognized top-level keys. """ data = load_scenario_yaml(yaml_str) - # Extract seed first as it may be used by other components + # Extract seed first as it may be used by other components. The schema's + # draft-07 "integer" also admits floats with no fraction (5.0). seed = data.get("seed") if seed is not None and not isinstance(seed, int): raise ValueError("'seed' must be an integer if provided.") - # 1) Build the network using blueprint expansion logic + # 1) Expand blueprints and the network section into a Network network_obj = expand_network_dsl(data) - if network_obj is None: - network_obj = Network() - else: - Scenario._logger.debug( - "Expanded network: nodes=%d, links=%d", - len(network_obj.nodes), - len(network_obj.links), - ) + Scenario._logger.debug( + "Expanded network: nodes=%d, links=%d", + len(network_obj.nodes), + len(network_obj.links), + ) # 2) Build the failure policy set seed_manager = SeedManager(seed) @@ -193,7 +189,7 @@ def from_yaml( workflow_data, derive_seed=lambda name: seed_manager.derive_seed("workflow_step", name), ) - labels = [step.name or step.__class__.__name__ for step in workflow_steps] + labels = [step.name for step in workflow_steps] Scenario._logger.debug( "Built workflow: steps=%d%s", len(workflow_steps), @@ -223,6 +219,11 @@ def from_yaml( if rg_data: risk_groups, generate_specs_raw = build_risk_groups(rg_data) for rg in risk_groups: + if rg.name in network_obj.risk_groups: + raise ValueError( + f"Duplicate risk group name '{rg.name}'; each top-level " + "risk group must have a unique name." + ) network_obj.risk_groups[rg.name] = rg Scenario._logger.debug( "Attached risk groups: %d", len(network_obj.risk_groups) @@ -264,8 +265,7 @@ def from_yaml( if rg.disabled: network_obj.disable_risk_group(rg.name, recursive=True) - # 10) Validate risk group references - # Ensures all risk group names referenced by nodes/links are defined + # 10) Check that every risk group named on a node or link is defined validate_risk_group_references(network_obj) scenario_obj = Scenario( @@ -278,17 +278,13 @@ def from_yaml( ) # Attach minimal scenario snapshot to results for export - try: - scenario_obj.results.set_scenario_snapshot( - build_scenario_snapshot( - seed=seed, - failure_policy_set=failure_policy_set, - demand_set=ds, - ) + scenario_obj.results.set_scenario_snapshot( + build_scenario_snapshot( + seed=seed, + failure_policy_set=failure_policy_set, + demand_set=ds, ) - except Exception as exc: - # Snapshot should never block scenario construction - Scenario._logger.debug("Failed to attach scenario snapshot: %s", exc) + ) Scenario._logger.debug( "Scenario constructed: nodes=%d, links=%d, policies=%d, demand_sets=%d, steps=%d", diff --git a/ngraph/schemas/scenario.json b/ngraph/schemas/scenario.json index 3ce5515..ad07193 100644 --- a/ngraph/schemas/scenario.json +++ b/ngraph/schemas/scenario.json @@ -258,6 +258,46 @@ "target" ], "additionalProperties": false + }, + "component": { + "type": "object", + "properties": { + "component_type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "capex": { + "type": "number" + }, + "power_watts": { + "type": "number" + }, + "power_watts_max": { + "type": "number" + }, + "capacity": { + "type": "number" + }, + "ports": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "attrs": { + "type": "object" + }, + "children": { + "type": "object", + "description": "Nested child components by name", + "additionalProperties": { + "$ref": "#/$defs/component" + } + } + }, + "additionalProperties": false } }, "properties": { @@ -535,7 +575,7 @@ }, "expand_groups": { "type": "boolean", - "description": "Whether to fail risk groups" + "description": "Also fail every entity sharing a risk group with a failed entity" }, "modes": { "type": "array", @@ -660,17 +700,10 @@ "$ref": "#/$defs/expandBlock" }, "flow_policy": { - "description": "Routing policy configuration (preset name or integer)", - "oneOf": [ - { - "type": "string" - }, - { - "type": "integer" - }, - { - "type": "null" - } + "description": "Routing preset name (FlowPolicyPreset member, case-insensitive)", + "type": [ + "string", + "null" ] }, "attrs": { @@ -737,46 +770,9 @@ }, "components": { "type": "object", - "description": "Hardware component library (preserved as-is)", + "description": "Hardware component library: component name -> definition", "additionalProperties": { - "type": "object", - "properties": { - "component_type": { - "type": "string" - }, - "description": { - "type": "string" - }, - "capex": { - "type": "number" - }, - "power_watts": { - "type": "number" - }, - "power_watts_max": { - "type": "number" - }, - "capacity": { - "type": "number" - }, - "ports": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "attrs": { - "type": "object" - }, - "children": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - } - }, - "additionalProperties": false + "$ref": "#/$defs/component" } }, "workflow": { diff --git a/ngraph/types/__init__.py b/ngraph/types/__init__.py index 62a6603..0bb9016 100644 --- a/ngraph/types/__init__.py +++ b/ngraph/types/__init__.py @@ -1,8 +1,8 @@ """Shared typing constructs for NetGraph. -This package defines the public `Cost` and `EdgeDir` aliases, the `EdgeSelect`, -`FlowPlacement`, and `Mode` enums, and the edge-reference DTOs `EdgeRef` and -`MaxFlowResult`. Apart from enum parsing helpers it holds no runtime logic. +Public `Cost` and `EdgeDir` aliases, the `EdgeSelect`, `FlowPlacement`, and +`Mode` enums, and the DTOs `EdgeRef` and `MaxFlowResult`. Apart from enum +parsing helpers there is no runtime logic here. """ from ngraph.types.base import Cost, EdgeSelect, FlowPlacement, Mode diff --git a/ngraph/types/base.py b/ngraph/types/base.py index 80be926..ddb7eb3 100644 --- a/ngraph/types/base.py +++ b/ngraph/types/base.py @@ -1,11 +1,11 @@ -"""Base classes and enums for network analysis algorithms.""" +"""Cost alias and enums shared by network analysis algorithms.""" from __future__ import annotations from enum import IntEnum from typing import Union -#: Represents numeric cost in the network (e.g. distance, latency, etc.). +#: Numeric cost of a link or path (e.g. distance or latency). Cost = Union[int, float] diff --git a/ngraph/types/dto.py b/ngraph/types/dto.py index 1ee38a0..a898487 100644 --- a/ngraph/types/dto.py +++ b/ngraph/types/dto.py @@ -1,7 +1,4 @@ -"""Types and data structures for algorithm analytics. - -Defines immutable summary containers for algorithm outputs. -""" +"""Immutable edge references and result containers for algorithm outputs.""" from __future__ import annotations @@ -34,8 +31,6 @@ class EdgeRef: class MaxFlowResult: """Result of max-flow computation between a source/sink pair. - Captures total flow, cost distribution, and optionally min-cut edges. - Attributes: total_flow: Maximum flow value achieved. cost_distribution: Mapping of cost to flow volume placed at that cost. diff --git a/ngraph/utils/__init__.py b/ngraph/utils/__init__.py index 9723b04..012b4f0 100644 --- a/ngraph/utils/__init__.py +++ b/ngraph/utils/__init__.py @@ -1,7 +1,6 @@ -"""Utility helpers used across NetGraph. +"""Small, self-contained helpers used across NetGraph. -This package contains small, self-contained utilities that do not depend on -project internals. Keep modules minimal and focused. +Modules here do not import other NetGraph packages. """ __all__: list[str] = [] diff --git a/ngraph/utils/ids.py b/ngraph/utils/ids.py index e0e0783..8925634 100644 --- a/ngraph/utils/ids.py +++ b/ngraph/utils/ids.py @@ -12,6 +12,6 @@ def new_base64_uuid() -> str: characters. Returns: - A 22-character URL-safe Base64 representation of a UUID4, unpadded. + 22-character ASCII string. """ return base64.urlsafe_b64encode(uuid.uuid4().bytes)[:-2].decode("ascii") diff --git a/ngraph/utils/output_paths.py b/ngraph/utils/output_paths.py index 77856fc..883b57d 100644 --- a/ngraph/utils/output_paths.py +++ b/ngraph/utils/output_paths.py @@ -12,38 +12,34 @@ def scenario_prefix_from_path(scenario_path: Path) -> str: - """Return a safe prefix derived from a scenario file path. + """Return the artifact filename prefix for a scenario file. Args: scenario_path: The scenario YAML file path. Returns: - The scenario filename stem, trimmed of extensions. + The filename without its final extension (``Path.stem``). """ return scenario_path.stem def ensure_parent_dir(path: Path) -> None: - """Ensure the parent directory exists for a file path.""" + """Create the parent directory of ``path`` if it is missing.""" path.parent.mkdir(parents=True, exist_ok=True) -def build_artifact_path(output_dir: Optional[Path], prefix: str, suffix: str) -> Path: +def build_artifact_path(output_dir: Path, prefix: str, suffix: str) -> Path: """Compose an artifact path as output_dir / (prefix + suffix). - If ``output_dir`` is None, the path is created relative to the current - working directory. - Args: - output_dir: Base directory for outputs; if None, use CWD. + output_dir: Base directory for outputs. prefix: Filename prefix; usually derived from scenario or results stem. suffix: Per-artifact suffix including the dot (e.g. ".results.json"). Returns: The composed path. """ - base = output_dir if output_dir is not None else Path.cwd() - return base / f"{prefix}{suffix}" + return output_dir / f"{prefix}{suffix}" def resolve_override_path( @@ -66,10 +62,8 @@ def resolve_override_path( return None if override.is_absolute(): return override - # Compose relative to the output directory if available if output_dir is not None: return (output_dir / override).resolve() - # Otherwise, leave as relative to CWD return override @@ -112,7 +106,8 @@ def profiles_dir_for_run(scenario_path: Path, output_dir: Optional[Path]) -> Pat output_dir: Optional base output directory. Returns: - Directory path where worker profiles should be stored. + ``output_dir/.profiles``, or ``worker_profiles`` in the + current directory when ``output_dir`` is None. """ prefix = scenario_prefix_from_path(scenario_path) if output_dir is None: diff --git a/ngraph/utils/seed_manager.py b/ngraph/utils/seed_manager.py index 2285310..19f2021 100644 --- a/ngraph/utils/seed_manager.py +++ b/ngraph/utils/seed_manager.py @@ -7,11 +7,12 @@ class SeedManager: - """Manages deterministic seed derivation for isolated component reproducibility. + """Derives per-component seeds from one master seed. - Global random.seed() creates order dependencies and component interference. - SeedManager derives unique seeds per component from a master seed using SHA-256, - ensuring reproducible results regardless of execution order or parallelism. + A global random.seed() makes each component's random draws depend on what + ran before it. SeedManager hashes the master seed with component + identifiers (SHA-256), so a component's seed does not depend on execution + order or parallelism. Usage: seed_mgr = SeedManager(42) @@ -23,7 +24,7 @@ def __init__(self, master_seed: Optional[int] = None) -> None: Args: master_seed: Master seed for deterministic operations. If None, - seed derivation will return None (non-deterministic). + derive_seed() returns None (non-deterministic). """ self.master_seed = master_seed @@ -38,7 +39,7 @@ def derive_seed(self, *components: Any) -> Optional[int]: uniquely identify the component needing a seed. Returns: - Derived seed as positive integer, or None if no master seed set. + Derived seed in [0, 2**31 - 1], or None if no master seed is set. Example: seed_mgr = SeedManager(42) @@ -48,10 +49,8 @@ def derive_seed(self, *components: Any) -> Optional[int]: if self.master_seed is None: return None - # Create a deterministic hash from master seed and components seed_input = f"{self.master_seed}:" + ":".join(str(c) for c in components) hash_digest = hashlib.sha256(seed_input.encode()).digest() - # Convert first 4 bytes to a positive integer seed_value = int.from_bytes(hash_digest[:4], byteorder="big") - return seed_value & 0x7FFFFFFF # Ensure positive 32-bit integer + return seed_value & 0x7FFFFFFF # Clear the sign bit: non-negative 31-bit value diff --git a/ngraph/utils/yaml_utils.py b/ngraph/utils/yaml_utils.py index 08c7429..e63437d 100644 --- a/ngraph/utils/yaml_utils.py +++ b/ngraph/utils/yaml_utils.py @@ -1,12 +1,12 @@ -"""Utilities for handling YAML parsing quirks and common operations.""" +"""Helpers for YAML parsing quirks and configuration key checks.""" -from typing import Any, Dict, TypeVar +from typing import AbstractSet, Any, Dict, Mapping, TypeVar V = TypeVar("V") def normalize_yaml_dict_keys(data: Dict[Any, V]) -> Dict[str, V]: - """Normalize dictionary keys from YAML parsing to ensure consistent string keys. + """Convert YAML-parsed dictionary keys to strings. YAML 1.1 parses true/false/yes/no/on/off keys as Python booleans. Those become "True"/"False"; every other key is coerced with str(). @@ -15,7 +15,7 @@ def normalize_yaml_dict_keys(data: Dict[Any, V]) -> Dict[str, V]: data: Dictionary that may contain boolean or other non-string keys from YAML parsing Returns: - Dictionary with all keys converted to strings, boolean keys converted to "True"/"False" + New dictionary with str() keys. Examples: >>> normalize_yaml_dict_keys({True: "value1", False: "value2", "normal": "value3"}) @@ -24,12 +24,25 @@ def normalize_yaml_dict_keys(data: Dict[Any, V]) -> Dict[str, V]: >>> # In YAML: true:, yes:, on: all become Python True >>> # In YAML: false:, no:, off: all become Python False """ - normalized = {} - for key, value in data.items(): - # YAML 1.1 turns true/false/yes/no/on/off keys into Python bools; - # normalize those to "True"/"False". - if isinstance(key, bool): - key = str(key) - key = str(key) - normalized[key] = value - return normalized + return {str(key): value for key, value in data.items()} + + +def check_no_extra_keys( + data: Mapping[Any, Any], allowed: AbstractSet[str], context: str +) -> None: + """Raise if ``data`` has keys outside ``allowed``; they would be ignored. + + Args: + data: Mapping parsed from configuration. + allowed: Recognized keys. + context: Short description of ``data`` used in the error message. + + Raises: + ValueError: If ``data`` contains any key not in ``allowed``. + """ + extra = sorted(str(k) for k in data if k not in allowed) + if extra: + raise ValueError( + f"Unrecognized key(s) in {context}: {', '.join(extra)}. " + f"Allowed keys are: {sorted(allowed)}" + ) diff --git a/ngraph/workflow/base.py b/ngraph/workflow/base.py index 7ccc162..c65a3a8 100644 --- a/ngraph/workflow/base.py +++ b/ngraph/workflow/base.py @@ -11,18 +11,18 @@ import os import time from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, Optional, Type, Union from ngraph.logging import get_logger if TYPE_CHECKING: - # Only imported for type-checking; not at runtime, so no circular import occurs. + # Type-checking import only; a runtime import would be circular. from ngraph.scenario import Scenario logger = get_logger(__name__) -# Registry for workflow step classes +# Maps YAML step `type` names to WorkflowStep subclasses WORKFLOW_STEP_REGISTRY: Dict[str, Type["WorkflowStep"]] = {} @@ -82,49 +82,38 @@ def resolve_parallelism(parallelism: Union[int, str]) -> int: Positive integer worker count (minimum 1). Raises: - ValueError: If parallelism is a string other than "auto", or an - integer < 1. + ValueError: If parallelism is neither "auto" nor an integer >= 1. """ - if isinstance(parallelism, str): - if parallelism != "auto": - raise ValueError("parallelism must be an integer or 'auto'") - return max(1, int(os.cpu_count() or 1)) - if int(parallelism) < 1: + if parallelism == "auto": + return max(1, os.cpu_count() or 1) + if isinstance(parallelism, bool) or not isinstance(parallelism, int): + raise ValueError( + f"parallelism must be an integer or 'auto', got {parallelism!r}" + ) + if parallelism < 1: raise ValueError("parallelism must be >= 1") - return int(parallelism) + return parallelism -def serialize_monte_carlo_results(raw: Dict[str, Any]) -> tuple[Any, list[dict]]: +def serialize_monte_carlo_results(raw: Dict[str, Any]) -> tuple[dict, list[dict]]: """Convert FailureManager Monte Carlo output into JSON-safe dicts. Args: - raw: Dict with optional "baseline" entry and "results" list, whose - items expose to_dict() (e.g. FlowIterationResult) or are already - plain dicts. + raw: ``run_monte_carlo_analysis`` output whose "baseline" and + "results" items are FlowIterationResult objects. Returns: - Tuple of (baseline_dict, flow_results): the baseline iteration (or - None) and the failure iterations, converted via to_dict() when - available. + Tuple of (baseline_dict, flow_results). """ - - def _to_dict(item: Any) -> Any: - to_dict = getattr(item, "to_dict", None) - return to_dict() if callable(to_dict) else item - - baseline = raw.get("baseline") - baseline_dict = _to_dict(baseline) if baseline is not None else None - flow_results = [_to_dict(item) for item in raw.get("results", [])] - return baseline_dict, flow_results + return raw["baseline"].to_dict(), [item.to_dict() for item in raw["results"]] @dataclass class WorkflowStep(ABC): """Base class for all workflow steps. - Every step is logged with execution timing, supports seeding for - reproducible random operations, and has its metadata stored in - scenario.results for analysis. + `execute()` logs each step with its duration and records step metadata in + scenario.results. A step seed makes random operations reproducible. YAML Configuration: ```yaml @@ -140,32 +129,31 @@ class WorkflowStep(ABC): used for logging and result storage. When empty, the class name is used instead. seed: Optional seed for reproducible random operations. If None, - random operations will be non-deterministic. + random operations are non-deterministic. """ name: str = "" seed: Optional[int] = None - # Internal: seed provenance, one of "explicit-step", "scenario-derived", "none". - _seed_source: str = "" + # Provenance of a set seed: "explicit-step", or "scenario-derived" when + # the workflow parser derived it from the scenario seed. + _seed_source: str = field(default="explicit-step", init=False, repr=False) def execute(self, scenario: "Scenario") -> None: - """Execute the workflow step with logging and metadata storage. + """Run the step inside its results namespace and record metadata. - Wraps `run()` with timing, logging, and metadata storage for the - analysis registry system. + Wraps `run()`: enters the step's results scope, stores step metadata + (type, execution order, seeds), logs start and end, and adds + `duration_sec` to the step metadata on success. Args: scenario: The scenario to execute the step on. - Returns: - None - Raises: Exception: Re-raises any exception raised by `run()` after logging duration and context. """ step_type = self.__class__.__name__ - # Guarantee a stable results namespace even when name is not provided + # An unnamed step uses its class name as the results namespace. step_name = self.name or step_type # Determine seed provenance from the seed the step actually uses. @@ -173,23 +161,11 @@ def execute(self, scenario: "Scenario") -> None: # concrete step seed means the step runs unseeded. scenario_seed = scenario.seed step_seed = self.seed - if step_seed is not None: - explicit_source = getattr(self, "_seed_source", None) - seed_source = ( - explicit_source - if explicit_source in ("explicit-step", "scenario-derived") - else "explicit-step" - ) - active_seed = step_seed - else: - seed_source = "none" - active_seed = None + seed_source = self._seed_source if step_seed is not None else "none" - # Get execution order from scenario instance (thread-safe) execution_order = scenario._execution_counter scenario._execution_counter += 1 - # Enter step scope and store workflow metadata scenario.results.enter_step(step_name) scenario.results.put_step_metadata( step_name=step_name, @@ -198,7 +174,6 @@ def execute(self, scenario: "Scenario") -> None: scenario_seed=scenario_seed, step_seed=step_seed, seed_source=seed_source, - active_seed=active_seed, ) if self.seed is not None: @@ -215,7 +190,8 @@ def execute(self, scenario: "Scenario") -> None: self.run(scenario) end_time = time.time() duration = end_time - start_time - # Persist step duration into step-scoped metadata for downstream analysis + # Merge duration_sec into the step's 'metadata' entry (created + # if run() stored none). existing_md = scenario.results.get("metadata", {}) if not isinstance(existing_md, dict): raise TypeError("Results metadata must be a dict") @@ -226,35 +202,22 @@ def execute(self, scenario: "Scenario") -> None: f"Completed workflow step: {step_name} ({step_type}) " f"in {duration:.3f} seconds" ) - try: - keys = ", ".join(sorted(scenario.results.get_step(step_name).keys())) - except Exception as exc: - logger.debug( - "Failed to read results keys for step %s: %s", step_name, exc - ) - keys = "-" logger.debug( "Step %s finished: duration=%.3fs, results_keys=%s", step_name, duration, - keys or "-", + ", ".join(sorted(scenario.results.get_step(step_name))) or "-", ) except Exception as e: end_time = time.time() duration = end_time - start_time logger.error( f"Failed workflow step: {step_name} ({step_type}) " - f"after {duration:.3f} seconds - {type(e).__name__}: {e}" + f"after {duration:.3f} seconds: {type(e).__name__}: {e}" ) raise finally: - # Always exit step scope - try: - scenario.results.exit_step() - except Exception as exc: - logger.warning( - "Failed to exit step scope cleanly for %s: %s", step_name, exc - ) + scenario.results.exit_step() @abstractmethod def run(self, scenario: "Scenario") -> None: @@ -265,8 +228,5 @@ def run(self, scenario: "Scenario") -> None: Args: scenario: The scenario to execute the step on. - - Returns: - None """ pass diff --git a/ngraph/workflow/build_graph.py b/ngraph/workflow/build_graph.py index 72471e7..e2c50de 100644 --- a/ngraph/workflow/build_graph.py +++ b/ngraph/workflow/build_graph.py @@ -1,8 +1,8 @@ -"""Graph building workflow component. +"""BuildGraph workflow step. -Validates the network topology and exports it as a NetworkX node-link -representation for inspection. Graph building for analysis happens in the -analysis functions, not here. +Exports the network topology as a NetworkX node-link representation for +inspection. Analysis functions build their own graphs and do not read this +one. YAML Configuration Example: ```yaml @@ -17,7 +17,7 @@ `false` for directed-only graphs. Results stored in `scenario.results` under the step name as two keys: - - metadata: Step-level execution metadata (node/link counts) + - metadata: node_count and link_count (graph edges, including reverse edges) - data: { graph: node-link JSON dict, context: { add_reverse: bool } } """ @@ -39,44 +39,36 @@ @dataclass class BuildGraph(WorkflowStep): - """Validates network topology and stores node-link representation. - - The stored representation is JSON-serializable NetworkX node-link data. - Core graph building for analysis happens in analysis functions as needed. + """Stores the network as JSON-serializable NetworkX node-link data. Attributes: - add_reverse: If True, adds reverse edges for bidirectional connectivity. - Defaults to True. + add_reverse: If True, adds a reverse edge (id "_reverse") for + every link. Defaults to True. """ add_reverse: bool = True def run(self, scenario: Scenario) -> None: - """Validate network and store node-link representation. + """Store the network's node-link representation. Args: scenario: Scenario containing the network model. - - Returns: - None """ logger.info("Starting BuildGraph: name=%s", self.name) network = scenario.network - # Build NetworkX MultiDiGraph from Network graph = nx.MultiDiGraph() - # Add nodes with attributes. Reserved keys win over user attrs to - # avoid kwarg collisions when attrs contain e.g. "disabled". + # Reserved keys win over user attrs to avoid kwarg collisions when + # attrs contain e.g. "disabled". for node_name in sorted(network.nodes.keys()): node = network.nodes[node_name] graph.add_node(node_name, **{**node.attrs, "disabled": node.disabled}) - # Add edges (links) with attributes. Reserved keys (id, capacity, - # cost, disabled) win over user attrs with the same names. + # Reserved keys (id, capacity, cost, disabled) win over user attrs + # with the same names. for link_id in sorted(network.links.keys()): link = network.links[link_id] - # Add forward edge graph.add_edge( link.source, link.target, @@ -88,7 +80,6 @@ def run(self, scenario: Scenario) -> None: "disabled": link.disabled, }, ) - # Add reverse edge if configured (for bidirectional connectivity) if self.add_reverse: reverse_id = f"{link_id}_reverse" graph.add_edge( @@ -103,7 +94,6 @@ def run(self, scenario: Scenario) -> None: }, ) - # Convert to node-link format for serialization graph_dict = nx.node_link_data(graph, edges="edges") scenario.results.put( @@ -129,5 +119,4 @@ def run(self, scenario: Scenario) -> None: ) -# Register the class after definition to avoid decorator ordering issues register_workflow_step("BuildGraph")(BuildGraph) diff --git a/ngraph/workflow/cost_power.py b/ngraph/workflow/cost_power.py index ce66597..b4dbbba 100644 --- a/ngraph/workflow/cost_power.py +++ b/ngraph/workflow/cost_power.py @@ -14,7 +14,8 @@ - etc. Disabled handling: -- When include_disabled is False, only enabled nodes and links are considered. +- When include_disabled is False, only enabled nodes and links are considered, + and a link's optics count only when both endpoint nodes are enabled. - Optics are counted only when the endpoint node has platform hardware. YAML Configuration Example: @@ -159,23 +160,18 @@ def add_values( if not node_enabled(network.nodes[lk.target]): continue - (src_end, dst_end, per_end) = resolve_link_end_components(lk.attrs, library) - if not per_end: - continue + src_end, dst_end = resolve_link_end_components(lk.attrs, library) - # Source endpoint src_comp, src_cnt, _src_excl = src_end if src_comp is not None and node_has_hw.get(lk.source, False): capex, power, _ = totals_with_multiplier(src_comp, src_cnt) add_values(lk.source, 0.0, 0.0, float(capex), float(power)) - # Destination endpoint dst_comp, dst_cnt, _dst_excl = dst_end if dst_comp is not None and node_has_hw.get(lk.target, False): capex, power, _ = totals_with_multiplier(dst_comp, dst_cnt) add_values(lk.target, 0.0, 0.0, float(capex), float(power)) - # Build payload levels_payload: Dict[int, List[Dict[str, Any]]] = {} for lvl, mapping in levels.items(): out_list: List[Dict[str, Any]] = [] @@ -194,7 +190,6 @@ def add_values( ) levels_payload[lvl] = out_list - # Store results scenario.results.put("metadata", {}) scenario.results.put( "data", @@ -207,7 +202,6 @@ def add_values( }, ) - # Log root summary root_items = levels_payload.get(0, []) root = root_items[0] if root_items else {} logger.info( diff --git a/ngraph/workflow/max_flow_step.py b/ngraph/workflow/max_flow_step.py index db86866..06de6d7 100644 --- a/ngraph/workflow/max_flow_step.py +++ b/ngraph/workflow/max_flow_step.py @@ -1,7 +1,8 @@ """MaxFlow workflow step. Monte Carlo analysis of maximum flow capacity between node groups using FailureManager. -Produces unified `flow_results` per iteration under `data.flow_results`. +Writes one `FlowIterationResult` dict per unique failure pattern under +`data.flow_results` and the no-failure result under `data.baseline`. Baseline (no failures) always runs first as a separate reference; `iterations` counts failure scenarios only. @@ -85,7 +86,6 @@ class MaxFlow(WorkflowStep): shortest_path: bool = False require_capacity: bool = True flow_placement: FlowPlacement | str = FlowPlacement.PROPORTIONAL - seed: int | None = None store_failure_patterns: bool = False include_flow_details: bool = False include_min_cut: bool = False @@ -134,7 +134,7 @@ def run(self, scenario: "Scenario") -> None: flow_placement=self.flow_placement, seed=self.seed, store_failure_patterns=self.store_failure_patterns, - include_flow_summary=self.include_flow_details, + include_flow_details=self.include_flow_details, include_min_cut=self.include_min_cut, ) diff --git a/ngraph/workflow/maximum_supported_demand_step.py b/ngraph/workflow/maximum_supported_demand_step.py index 48bb41c..2ef0433 100644 --- a/ngraph/workflow/maximum_supported_demand_step.py +++ b/ngraph/workflow/maximum_supported_demand_step.py @@ -73,26 +73,23 @@ class _MSDCache: class MaximumSupportedDemand(WorkflowStep): """Finds the maximum uniform traffic multiplier that is fully placeable. - Binary search yields alpha_star: the largest multiplier at which every - demand in the set still places fully on the network. + Brackets alpha_star by scaling alpha_start up or down by growth_factor, + then bisects. alpha_star is the largest multiplier at which every demand + in the set still places fully on the network. Attributes: demand_set: Name of the demand set to analyze. - acceptance_rule: Currently only "hard" is implemented; anything else - raises ValueError at run time. alpha_start: Starting multiplier for binary search. growth_factor: Factor for bracket expansion; must be > 1.0. - alpha_min: Minimum allowed alpha value. - alpha_max: Maximum allowed alpha value. - resolution: Convergence threshold for binary search; must be positive. + alpha_min: Lowest alpha probed; the step raises if it is infeasible. + alpha_max: Highest alpha probed; returned when it is feasible. + resolution: Bisection stops once the bracket is no wider than this; + must be positive. max_bracket_iters: Maximum iterations for bracketing phase. max_bisect_iters: Maximum iterations for bisection phase. - placement_rounds: Deprecated; accepted for backward compatibility but - has no effect (each demand is placed in one deterministic pass). """ demand_set: str = "default" - acceptance_rule: str = "hard" alpha_start: float = 1.0 growth_factor: float = 2.0 alpha_min: float = 1e-6 @@ -100,14 +97,8 @@ class MaximumSupportedDemand(WorkflowStep): resolution: float = 0.01 max_bracket_iters: int = 32 max_bisect_iters: int = 32 - placement_rounds: int | str = "auto" def __post_init__(self) -> None: - if self.placement_rounds != "auto": - logger.warning( - "MaximumSupportedDemand 'placement_rounds' is deprecated and has " - "no effect; each demand is placed in one deterministic pass." - ) try: self.alpha_start = float(self.alpha_start) self.growth_factor = float(self.growth_factor) @@ -124,9 +115,6 @@ def __post_init__(self) -> None: raise ValueError("resolution must be positive") def run(self, scenario: "Any") -> None: - if self.acceptance_rule != "hard": - raise ValueError("Only 'hard' acceptance_rule is implemented") - t0 = time.perf_counter() logger.info("Starting MaximumSupportedDemand: name=%s", self.name) logger.debug( @@ -144,18 +132,16 @@ def run(self, scenario: "Any") -> None: if not base_demands: raise ValueError( - f"Demand set '{self.demand_set}' contains no demands. " - "Cannot compute maximum supported demand without traffic specifications." + f"Demand set '{self.demand_set}' contains no demands; " + "there is nothing to scale." ) - # Build cache once for all probes cache = self._build_cache(scenario, base_tds) logger.debug( "MSD cache built: %d expanded demands", len(cache.base_expanded), ) - # Binary search probes: list[dict[str, Any]] = [] def probe(alpha: float) -> tuple[bool, dict[str, Any]]: @@ -165,9 +151,7 @@ def probe(alpha: float) -> tuple[bool, dict[str, Any]]: alpha_star = self._binary_search(probe) - # Store results context = { - "acceptance_rule": self.acceptance_rule, "alpha_start": self.alpha_start, "growth_factor": self.growth_factor, "alpha_min": self.alpha_min, @@ -290,7 +274,7 @@ def _build_cache(scenario: Any, base_tds: list[TrafficDemand]) -> _MSDCache: """ ctx, expansion, resolved_ids = build_demand_placement_inputs( scenario.network, - [{**td.to_dict(), "flow_policy": td.flow_policy} for td in base_tds], + [td.to_dict() for td in base_tds], ) # Build masks once (no exclusions during MSD) diff --git a/ngraph/workflow/network_stats.py b/ngraph/workflow/network_stats.py index f289cdf..417c7f7 100644 --- a/ngraph/workflow/network_stats.py +++ b/ngraph/workflow/network_stats.py @@ -1,9 +1,10 @@ """Workflow step for basic node and link statistics. -Computes and stores network statistics including node/link counts, -capacity distributions, cost distributions, and degree distributions. Excluded -entities are filtered out without modifying the base network; disabled nodes -and links are excluded too unless `include_disabled` is set. +Computes node and link counts plus mean, median, min, and max of link +capacity, link cost, and node degree. Excluded entities are filtered out +without modifying the base network; disabled nodes and links are excluded too +unless `include_disabled` is set. A link counts only when both endpoints +remain. YAML Configuration Example: ```yaml @@ -15,7 +16,7 @@ excluded_links: ["link1", "link3"] # Optional: Temporary link exclusions ``` -Results stored in `scenario.results`: +Results stored in `scenario.results` under `data`: - Node statistics: node_count - Link statistics: link_count, total_capacity, mean_capacity, median_capacity, min_capacity, max_capacity, mean_cost, median_cost, min_cost, max_cost @@ -41,7 +42,7 @@ class NetworkStats(WorkflowStep): """Compute basic node and link statistics for the network. - Supports optional exclusion simulation without modifying the base network. + Exclusions apply to this computation only; the network is not modified. Attributes: include_disabled: If True, include disabled nodes and links in statistics. @@ -57,14 +58,8 @@ class NetworkStats(WorkflowStep): def run(self, scenario: Scenario) -> None: """Compute and store network statistics. - If `excluded_nodes` or `excluded_links` are specified, filters them out - without modifying the base network. - Args: scenario: The scenario containing the network to analyze. - - Returns: - None """ logger.info("Starting NetworkStats: name=%s", self.name) @@ -72,7 +67,6 @@ def run(self, scenario: Scenario) -> None: excluded_nodes_set = set(self.excluded_nodes) if self.excluded_nodes else set() excluded_links_set = set(self.excluded_links) if self.excluded_links else set() - # Filter nodes based on disabled status and exclusions if self.include_disabled: nodes = { name: node @@ -86,7 +80,7 @@ def run(self, scenario: Scenario) -> None: if not node.disabled and name not in excluded_nodes_set } - # Filter links based on disabled status, exclusions, and node availability + # A link counts only if both endpoints survived node filtering. if self.include_disabled: links = { link_id: link @@ -126,7 +120,6 @@ def run(self, scenario: Scenario) -> None: min_cost_val = min(costs) max_cost_val = max(costs) - # Compute degree statistics over the selected node set mean_degree_val = median_degree_val = min_degree_val = max_degree_val = 0.0 if nodes: degrees: Dict[str, int] = {name: 0 for name in nodes} @@ -143,7 +136,6 @@ def run(self, scenario: Scenario) -> None: min_degree_val = min(degree_values) max_degree_val = max(degree_values) - # Store results scenario.results.put("metadata", {}) scenario.results.put( "data", @@ -175,5 +167,4 @@ def run(self, scenario: Scenario) -> None: ) -# Register the class after definition to avoid decorator ordering issues register_workflow_step("NetworkStats")(NetworkStats) diff --git a/ngraph/workflow/parse.py b/ngraph/workflow/parse.py index 023d47a..d044698 100644 --- a/ngraph/workflow/parse.py +++ b/ngraph/workflow/parse.py @@ -6,14 +6,12 @@ from __future__ import annotations +import dataclasses from typing import Any, Callable, Dict, List, Optional -from ngraph.logging import get_logger -from ngraph.utils.yaml_utils import normalize_yaml_dict_keys +from ngraph.utils.yaml_utils import check_no_extra_keys, normalize_yaml_dict_keys from ngraph.workflow.base import WORKFLOW_STEP_REGISTRY, WorkflowStep -_logger = get_logger(__name__) - def build_workflow_steps( workflow_data: List[Dict[str, Any]], @@ -26,7 +24,13 @@ def build_workflow_steps( derive_seed: Callable that takes a step name and returns a seed or None. Returns: - A list of WorkflowStep instances with unique names and optional seeds. + WorkflowStep instances. An unnamed step is named "{type}_{index}"; a + step without a seed gets ``derive_seed(name)`` when that is not None. + + Raises: + ValueError: If ``workflow_data`` is not a list, a step lacks ``type`` + or names an unregistered type, two steps resolve to the same + name, or a step carries a key its step class does not define. """ if not isinstance(workflow_data, list): raise ValueError("'workflow' must be a list if present.") @@ -67,15 +71,13 @@ def build_workflow_steps( if derived is not None: normalized_ctor_args["seed"] = derived + init_fields = {f.name for f in dataclasses.fields(step_cls) if f.init} + check_no_extra_keys( + normalized_ctor_args, init_fields, f"workflow step '{step_name}'" + ) step_obj = step_cls(**normalized_ctor_args) - try: - step_obj._seed_source = ( - "explicit-step" - if "seed" in ctor_args and ctor_args["seed"] is not None - else "scenario-derived" - ) - except Exception as exc: - _logger.debug("Failed to set _seed_source on step %s: %s", step_name, exc) + if ctor_args.get("seed") is None: + step_obj._seed_source = "scenario-derived" steps.append(step_obj) diff --git a/ngraph/workflow/traffic_matrix_placement_step.py b/ngraph/workflow/traffic_matrix_placement_step.py index bd2fda9..8c9f379 100644 --- a/ngraph/workflow/traffic_matrix_placement_step.py +++ b/ngraph/workflow/traffic_matrix_placement_step.py @@ -1,7 +1,8 @@ """TrafficMatrixPlacement workflow step. -Runs Monte Carlo demand placement using a named demand set and produces -unified `flow_results` per iteration under `data.flow_results`. +Runs Monte Carlo demand placement using a named demand set. Writes one +`FlowIterationResult` dict per unique failure pattern under +`data.flow_results` and the no-failure result under `data.baseline`. Baseline (no failures) always runs first as a separate reference; `iterations` counts failure scenarios only. @@ -31,7 +32,7 @@ from ngraph.analysis.placement import CACHEABLE_PRESETS from ngraph.logging import get_logger from ngraph.model.demand.spec import TrafficDemand -from ngraph.model.flow.policy_config import FlowPolicyPreset +from ngraph.model.flow.policy_config import DEFAULT_PRESET from ngraph.workflow.base import ( WorkflowStep, register_workflow_step, @@ -81,7 +82,7 @@ def resolve_placement_parallelism( return resolved if _python_is_free_threaded(): return resolved - presets = {td.flow_policy or FlowPolicyPreset.SHORTEST_PATHS_ECMP for td in demands} + presets = {td.flow_policy or DEFAULT_PRESET for td in demands} if presets - CACHEABLE_PRESETS: return resolved return 1 @@ -106,16 +107,14 @@ class TrafficMatrixPlacement(WorkflowStep): set, or a free-threaded interpreter) and 1 otherwise, because cacheable presets are Python-bound under the GIL and threads only slow them down. See ``resolve_placement_parallelism``. - placement_rounds: Deprecated; accepted for backward compatibility but - has no effect (each demand is placed in one deterministic pass). seed: Optional seed for reproducibility. store_failure_patterns: Record the failure trace on each result. Iterations are deduplicated, so a trace describes the first iteration of its pattern, not every matching iteration. include_flow_details: When True, include cost_distribution per flow. include_used_edges: When True, include set of used edges per demand in entry data. - alpha: Numeric scale for demands in the set; must be > 0.0. Ignored - when alpha_from_step is set. + alpha: Numeric scale for demands in the set; must be > 0.0. Defaults + to 1.0; cannot be combined with alpha_from_step. alpha_from_step: Optional producer step name to read alpha from; it must run before this step. alpha_from_field: Dotted field path in producer step (default: "data.alpha_star"). @@ -125,26 +124,22 @@ class TrafficMatrixPlacement(WorkflowStep): failure_policy: str | None = None iterations: int = 1 parallelism: int | str = "auto" - placement_rounds: int | str = "auto" - seed: int | None = None store_failure_patterns: bool = False include_flow_details: bool = False include_used_edges: bool = False - alpha: float = 1.0 + alpha: float | None = None alpha_from_step: str | None = None alpha_from_field: str = "data.alpha_star" def __post_init__(self) -> None: - if self.placement_rounds != "auto": - logger.warning( - "TrafficMatrixPlacement 'placement_rounds' is deprecated and has " - "no effect; each demand is placed in one deterministic pass." - ) if self.iterations < 0: raise ValueError("iterations must be >= 0") resolve_parallelism(self.parallelism) # validate at construction - if not (float(self.alpha) > 0.0): - raise ValueError("alpha must be > 0.0") + if self.alpha is not None: + if self.alpha_from_step: + raise ValueError("Set either alpha or alpha_from_step, not both") + if not (float(self.alpha) > 0.0): + raise ValueError("alpha must be > 0.0") def run(self, scenario: "Scenario") -> None: if not self.demand_set: @@ -162,7 +157,6 @@ def run(self, scenario: "Scenario") -> None: self.alpha, ) - # Extract and serialize demand set try: td_list = scenario.demand_set.get_set(self.demand_set) except KeyError as exc: @@ -170,28 +164,15 @@ def run(self, scenario: "Scenario") -> None: f"Demand set '{self.demand_set}' not found in scenario." ) from exc - # Resolve alpha - effective_alpha = self._resolve_alpha(scenario) - alpha_src = getattr(self, "_alpha_source", None) or "explicit" - logger.info( - "Using alpha: value=%.6g source=%s", - float(effective_alpha), - str(alpha_src), - ) + effective_alpha, alpha_source = self._resolve_alpha(scenario) + logger.info("Using alpha: value=%.6g source=%s", effective_alpha, alpha_source) - # base_demands: canonical serialized form for output (unscaled). - # demands_config: analysis wire format (scaled volume, raw preset). + # base_demands is the unscaled output form; demands_config scales it. base_demands: list[dict[str, Any]] = [td.to_dict() for td in td_list] demands_config: list[dict[str, Any]] = [ - { - **td.to_dict(), - "volume": float(td.volume) * float(effective_alpha), - "flow_policy": td.flow_policy, - } - for td in td_list + {**d, "volume": d["volume"] * effective_alpha} for d in base_demands ] - # Run via FailureManager fm = FailureManager( network=scenario.network, failure_policy_set=scenario.failure_policy_set, @@ -220,14 +201,10 @@ def run(self, scenario: "Scenario") -> None: raw.get("metadata", {}).get("unique_patterns", 0), ) - # Store outputs scenario.results.put("metadata", raw.get("metadata", {})) baseline_dict, flow_results = serialize_monte_carlo_results(raw) - alpha_value = float(effective_alpha) - alpha_source_value = getattr(self, "_alpha_source", "explicit") - scenario.results.put( "data", { @@ -238,8 +215,8 @@ def run(self, scenario: "Scenario") -> None: "include_flow_details": self.include_flow_details, "include_used_edges": self.include_used_edges, "base_demands": base_demands, - "alpha": alpha_value, - "alpha_source": alpha_source_value, + "alpha": effective_alpha, + "alpha_source": alpha_source, }, }, ) @@ -249,20 +226,21 @@ def run(self, scenario: "Scenario") -> None: "TrafficMatrixPlacement completed: name=%s alpha=%.6g failure_iters=%d " "unique_patterns=%d workers=%d duration=%.3fs", self.name, - alpha_value, + effective_alpha, metadata.get("iterations", self.iterations), metadata.get("unique_patterns", 0), metadata.get("parallelism", effective_parallelism), time.perf_counter() - t0, ) - def _resolve_alpha(self, scenario: "Scenario") -> float: + def _resolve_alpha(self, scenario: "Scenario") -> tuple[float, str]: + """Return the demand scale and its source ("explicit" or a step name).""" if self.alpha_from_step: step = scenario.results.get_step(self.alpha_from_step) # Results.get_step returns {} for unknown or not-yet-run steps. if not step: raise ValueError( - f"alpha_from_step '{self.alpha_from_step}' has no results - " + f"alpha_from_step '{self.alpha_from_step}' has no results; " "check the step name and that it runs before this step" ) parts = [p for p in str(self.alpha_from_field).split(".") if p] @@ -281,9 +259,8 @@ def _resolve_alpha(self, scenario: "Scenario") -> float: ) from exc if not (value > 0.0): raise ValueError("alpha_from_step produced non-positive alpha") - self._alpha_source = self.alpha_from_step - return value - return float(self.alpha) + return value, self.alpha_from_step + return (1.0 if self.alpha is None else float(self.alpha)), "explicit" register_workflow_step("TrafficMatrixPlacement")(TrafficMatrixPlacement) diff --git a/pyproject.toml b/pyproject.toml index 231bdc2..5e1ea32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77", "wheel"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" # --------------------------------------------------------------------- @@ -32,8 +32,8 @@ classifiers = [ # Runtime deps dependencies = [ "networkx>=3.0", + "numpy>=1.23.2", "pyyaml>=6.0", - "pandas>=2.0", "jsonschema>=4.0", "netgraph-core>=0.10.0", ] @@ -47,29 +47,21 @@ dev = [ # testing "pytest>=8", "pytest-cov", - "pytest-benchmark", - "pytest-mock", "pytest-timeout", - # perf/visualization - "numpy", + # perf/visualization (dev/perf) "matplotlib", "seaborn", # docs "mkdocs-material", - "pdoc", # style + type checking "ruff==0.11.13", "pyright==1.1.401", - # type stubs - "pandas-stubs", # pre-commit hooks "pre-commit", # build "build", # publishing "twine", - # schema validation - "jsonschema", ] [project.scripts] @@ -78,19 +70,14 @@ ngraph = "ngraph.cli:main" # --------------------------------------------------------------------- # Pytest flags [tool.pytest.ini_options] -addopts = "--cov=./ngraph --cov-fail-under=75 --cov-report term-missing --benchmark-disable-gc --benchmark-min-rounds=5 --benchmark-warmup=on" +addopts = "--cov=./ngraph --cov-fail-under=75 --cov-report term-missing" timeout = 30 markers = [ "slow: marks integration tests as slow (deselect with '-m \"not slow\"')", - "benchmark: marks tests as performance benchmarks (run with '-m benchmark')", ] # --------------------------------------------------------------------- -# Coverage configuration -[tool.coverage.run] -source = ["."] -omit = ["*/tests/*", "*/test_*", "*/conftest.py"] - +# Coverage configuration (source is set by --cov in addopts) [tool.coverage.report] exclude_lines = [ "pragma: no cover", @@ -103,7 +90,6 @@ exclude_lines = [ # Package discovery [tool.setuptools.packages.find] include = ["ngraph*"] -exclude = ["tests*", "notebooks*", "examples*", "dev*"] # Include schema files in the package for runtime validation [tool.setuptools.package-data] diff --git a/scenarios/backbone_clos.yml b/scenarios/backbone_clos.yml index 5884c6c..9443604 100644 --- a/scenarios/backbone_clos.yml +++ b/scenarios/backbone_clos.yml @@ -1100,7 +1100,6 @@ workflow: - type: MaximumSupportedDemand name: msd_baseline demand_set: baseline_traffic_matrix - acceptance_rule: hard alpha_start: 1.0 growth_factor: 2.0 alpha_min: 0.001 @@ -1114,7 +1113,7 @@ workflow: demand_set: baseline_traffic_matrix failure_policy: weighted_modes iterations: 1000 - parallelism: 7 + parallelism: auto store_failure_patterns: false include_flow_details: true include_used_edges: false diff --git a/scenarios/square_mesh.yaml b/scenarios/square_mesh.yaml index cace280..1083ebf 100644 --- a/scenarios/square_mesh.yaml +++ b/scenarios/square_mesh.yaml @@ -50,7 +50,6 @@ workflow: - type: MaximumSupportedDemand name: msd_baseline demand_set: baseline_traffic_matrix - acceptance_rule: hard alpha_start: 1.0 growth_factor: 2.0 alpha_min: 0.001 @@ -63,7 +62,7 @@ workflow: demand_set: baseline_traffic_matrix failure_policy: single_link_failure iterations: 1000 - parallelism: 8 + parallelism: auto seed: 42 store_failure_patterns: true include_flow_details: true diff --git a/tests/analysis/test_context.py b/tests/analysis/test_context.py index ea8118a..4316ea4 100644 --- a/tests/analysis/test_context.py +++ b/tests/analysis/test_context.py @@ -1,7 +1,7 @@ """Tests for AnalysisContext internal infrastructure. -Tests verify that disabled nodes/links are properly tracked and that -the context provides correct access to Core graph components. +Checks that disabled nodes and links are tracked and that the context +exposes the Core graph components. """ from ngraph import Link, Network, Node @@ -34,7 +34,6 @@ def test_disabled_node_tracked(): ctx = AnalysisContext.from_network(net) - # Disabled node B should be tracked assert len(ctx.disabled_node_ids) == 1 @@ -47,5 +46,4 @@ def test_disabled_link_tracked(): ctx = AnalysisContext.from_network(net) - # Disabled link should be tracked assert len(ctx.disabled_link_ids) == 1 diff --git a/tests/analysis/test_context_review_fixes.py b/tests/analysis/test_context_review_fixes.py index 573121b..3f62ae7 100644 --- a/tests/analysis/test_context_review_fixes.py +++ b/tests/analysis/test_context_review_fixes.py @@ -1,4 +1,4 @@ -"""Regression tests for AnalysisContext review fixes. +"""Regression tests for AnalysisContext. Covers: - Fractional link costs raise ValueError instead of silent int64 truncation. @@ -176,9 +176,9 @@ def _fail(*args: object, **kwargs: object) -> None: def test_missing_pair_defaults_are_not_aliased(self) -> None: """Each missing pair gets a fresh default object. - Regression: one mutable default (dict, MaxFlowResult) was stored - under every missing pair key, so mutating one entry of a public - API result would silently mutate the others. + Regression: one mutable default (dict, MaxFlowResult) shared by every + missing pair key would let a mutation of one entry of a public API + result silently change the others. """ net = Network() for name in ["A", "B"]: @@ -254,7 +254,7 @@ def test_selector_argument_validation(self) -> None: bound = analyze(net, source="^A$", sink="^B$") unbound = analyze(net) - with pytest.raises(ValueError, match="source/sink already configured"): + with pytest.raises(ValueError, match="source/sink/mode already configured"): bound.sensitivity_with_flow("^A$", "^B$") with pytest.raises(ValueError, match="source and sink are required"): unbound.sensitivity_with_flow() @@ -305,7 +305,7 @@ def test_pseudo_edge_count_linear_in_group_members(self) -> None: # 2 links x 2 directions = 4 real edges; pseudo edges: one per # member per participating group = (3 + 3) groups * 2 members = 12 - # (previously duplicated once per opposing group: 24). + # (not duplicated once per opposing group, which would give 24). assert ctx.edge_count == 4 + 12 def test_pairwise_flows_unchanged(self) -> None: diff --git a/tests/analysis/test_demand.py b/tests/analysis/test_demand.py index 4f80c20..093d5b0 100644 --- a/tests/analysis/test_demand.py +++ b/tests/analysis/test_demand.py @@ -194,7 +194,6 @@ def test_combine_augmentations_structure(self, simple_network: Network) -> None: ) expansion = expand_demands(simple_network, [td]) - # Check augmentation edges aug_edges = [(a.source, a.target) for a in expansion.augmentations] # Pseudo source -> real sources @@ -261,7 +260,7 @@ def test_different_ids_produce_different_pseudo_nodes( class TestExpandDemandsEdgeCases: - """Test edge cases for expand_demands.""" + """expand_demands with empty input, unmatched selectors, and mixed modes.""" def test_empty_demands_raises(self, simple_network: Network) -> None: """Empty demands list raises ValueError.""" @@ -279,7 +278,7 @@ def test_no_matching_nodes_raises(self, simple_network: Network) -> None: expand_demands(simple_network, [td]) def test_multiple_demands_mixed_modes(self, simple_network: Network) -> None: - """Multiple demands with different modes expand correctly.""" + """A pairwise and a combine demand expand to one demand each.""" td_pairwise = TrafficDemand( source="A", target="B", @@ -339,11 +338,8 @@ def test_group_by_selector(self, network_with_attrs: Network) -> None: ) expansion = expand_demands(network_with_attrs, [td]) - # With group_by=dc and pairwise mode, we get: - # dc1->dc2 and dc2->dc1 (excluding self-pairs) - # Each group has 4 nodes, so 16 pairs per direction = 32 total - # But wait, pairwise is between individual nodes, not groups - # Actually pairwise still creates per-node pairs + # group_mode defaults to "flatten": both dc groups merge into one set of + # 8 nodes, and pairwise creates every non-self node pair (8 * 7 = 56). assert len(expansion.demands) > 0 # Volume is distributed across pairs total_volume = sum(d.volume for d in expansion.demands) @@ -442,7 +438,7 @@ def test_all_fields_preserved_in_dict_round_trip(self) -> None: assert reconstructed.priority == original.priority def test_default_values_for_new_fields(self) -> None: - """New fields have sensible defaults when not specified.""" + """group_mode defaults to "flatten" when not specified.""" td = TrafficDemand( source="^A$", target="^B$", diff --git a/tests/analysis/test_demand_expansion_semantics.py b/tests/analysis/test_demand_expansion_semantics.py index ad762f7..fd5d0af 100644 --- a/tests/analysis/test_demand_expansion_semantics.py +++ b/tests/analysis/test_demand_expansion_semantics.py @@ -137,11 +137,10 @@ def test_no_shared_labels_yields_no_demands(self) -> None: class TestCombineOverlapExclusion: """Combine mode excludes nodes selected on both sides. - Regression: overlapping source/target selections previously attached - shared nodes to both pseudo endpoints, creating a zero-cost - pseudo_src -> node -> pseudo_snk bypass over two LARGE_CAPACITY - augmentation edges that absorbed the entire demand without touching - the real network. + Attaching a shared node to both pseudo endpoints would create a + zero-cost pseudo_src -> node -> pseudo_snk bypass over two + LARGE_CAPACITY augmentation edges that absorbs the entire demand + without touching the real network. """ @staticmethod @@ -197,7 +196,7 @@ def test_per_group_combine_excludes_own_group_nodes(self) -> None: # Each group's combine demand is a virtual source: A/2 and B/2 # have no path at all and leave the pool, so A/1 -> B/1 and # B/1 -> A/1 each carry the single capacity-1.0 link's worth - # whatever the preset. The bypass previously placed all 100. + # whatever the preset; a bypass would place all 100. ("SHORTEST_PATHS_ECMP", 2.0), ("SHORTEST_PATHS_ECMP_LOSSY", 2.0), ("SHORTEST_PATHS_WCMP", 2.0), diff --git a/tests/analysis/test_failure_manager.py b/tests/analysis/test_failure_manager.py index 347b3c3..9b8929f 100644 --- a/tests/analysis/test_failure_manager.py +++ b/tests/analysis/test_failure_manager.py @@ -1,7 +1,7 @@ -"""High-value tests for `FailureManager` public behavior and APIs. +"""Tests for `FailureManager` public behavior. -Focus on functional outcomes and API semantics. Tests core functionality, -policy management, exclusion computation, and convenience methods. +Covers initialization, policy lookup, exclusion computation (including +risk-group expansion), Monte Carlo entry points, and sensitivity aggregation. """ from typing import Any @@ -66,7 +66,7 @@ class TestFailureManagerInitialization: def test_initialization( self, simple_network: Network, failure_policy_set: FailurePolicySet ) -> None: - """Test basic initialization.""" + """The constructor stores the network, policy set, and policy name.""" fm = FailureManager( network=simple_network, failure_policy_set=failure_policy_set, @@ -201,11 +201,16 @@ def __init__(self, failed_group: str) -> None: self.failed_group = failed_group self.modes: list[Any] = [] + expand_groups = False + def prepare_matches( self, *args: Any, **kwargs: Any ) -> dict[int, tuple[str, ...]]: return {} + def prepare_weights(self, *args: Any, **kwargs: Any) -> dict: + return {} + def apply_failures_typed( self, *args: Any, **kwargs: Any ) -> tuple[set[str], set[str], set[str]]: @@ -249,17 +254,22 @@ def test_compute_exclusions_risk_group_cache_preserves_failure_trace( "selected_ids": [parent.name], } ], - "expansion": {"nodes": [], "links": [], "risk_groups": [child.name]}, + "expansion": {"nodes": [], "links": []}, } class TracedRiskGroupPolicy: modes: list[Any] = [] + expand_groups = False + def prepare_matches( self, *args: Any, **kwargs: Any ) -> dict[int, tuple[str, ...]]: return {} + def prepare_weights(self, *args: Any, **kwargs: Any) -> dict: + return {} + def apply_failures_typed( self, *args: Any, @@ -305,11 +315,16 @@ def test_compute_exclusions_handles_cyclic_risk_groups( class FixedRiskGroupPolicy: modes: list[Any] = [] + expand_groups = False + def prepare_matches( self, *args: Any, **kwargs: Any ) -> dict[int, tuple[str, ...]]: return {} + def prepare_weights(self, *args: Any, **kwargs: Any) -> dict: + return {} + def apply_failures_typed( self, *args: Any, **kwargs: Any ) -> tuple[set[str], set[str], set[str]]: @@ -330,13 +345,12 @@ def apply_failures_typed( class TestFailureManagerTopLevelMatching: - """Test compute_exclusions merged attribute view correctness.""" + """compute_exclusions matches rules on top-level fields (disabled, capacity).""" def test_node_matching_on_disabled_attribute( self, simple_network: Network, failure_policy_set: FailurePolicySet ) -> None: """Test node matching on disabled attribute.""" - # Mark one node as disabled simple_network.nodes["node1"].disabled = True rule = FailureRule( @@ -398,7 +412,6 @@ def test_iterations_without_policy_runs_baseline_only( policy_name=None, ) - # Mock analysis function def mock_analysis_func(*args: Any, **kwargs: Any) -> dict[str, Any]: return {"result": "mock"} @@ -414,7 +427,6 @@ def mock_analysis_func(*args: Any, **kwargs: Any) -> dict[str, Any]: def test_baseline_always_present(self, failure_manager: FailureManager) -> None: """Test that baseline is always present in results.""" - # Mock analysis function def mock_analysis_func(*args: Any, **kwargs: Any) -> dict[str, Any]: return {"result": "mock"} @@ -454,20 +466,29 @@ def test_run_max_flow_monte_carlo_delegates( def test_run_demand_placement_monte_carlo_delegates( self, mock_mc_analysis: MagicMock, failure_manager: FailureManager ) -> None: - """Test run_demand_placement_monte_carlo delegates correctly.""" + """run_demand_placement_monte_carlo delegates to run_monte_carlo_analysis.""" mock_mc_analysis.return_value = { "results": [], "metadata": {"iterations": 1}, } - mock_demands = MagicMock() result = failure_manager.run_demand_placement_monte_carlo( - demands_config=mock_demands, iterations=1, parallelism=1 + demands_config=[], iterations=1, parallelism=1 ) assert mock_mc_analysis.called assert result == mock_mc_analysis.return_value + def test_run_demand_placement_monte_carlo_rejects_other_inputs( + self, failure_manager: FailureManager + ) -> None: + """Inputs other than a list or DemandSet raise instead of placing nothing.""" + with pytest.raises(TypeError, match="list of demand configs or a DemandSet"): + failure_manager.run_demand_placement_monte_carlo( + demands_config=({"source": "A", "target": "B", "volume": 1.0},), + iterations=1, + ) + def test_flow_placement_string_conversion_max_flow( self, failure_manager: FailureManager ) -> None: @@ -524,18 +545,17 @@ def test_case_insensitive_flow_placement_conversion( class TestFailureManagerErrorHandling: - """Test error handling and edge cases.""" + """Errors raised during Monte Carlo execution.""" @patch("ngraph.analysis.failure_manager.ThreadPoolExecutor") def test_parallel_execution_error_propagation( self, mock_pool_executor: MagicMock, failure_manager: FailureManager ) -> None: - """Test that parallel execution errors propagate correctly.""" + """An error from the thread pool propagates to the caller unchanged.""" mock_pool = MagicMock() mock_pool_executor.return_value.__enter__.return_value = mock_pool mock_pool.map.side_effect = RuntimeError("Parallel execution failed") - # Mock analysis function def mock_analysis_func(*args: Any, **kwargs: Any) -> dict[str, Any]: return {"result": "mock"} @@ -557,7 +577,7 @@ class TestSensitivityResultsProcessing: def test_process_sensitivity_results_weights_by_occurrence_count( self, failure_manager: FailureManager ) -> None: - """Verify weighted statistics calculation uses occurrence_count correctly.""" + """Weighted statistics weight each pattern by its occurrence_count.""" from ngraph.results.flow import FlowEntry, FlowIterationResult, FlowSummary # Pattern A: score=0.8, occurred 5 times @@ -614,7 +634,7 @@ def test_process_sensitivity_results_weights_by_occurrence_count( def test_process_sensitivity_results_single_pattern( self, failure_manager: FailureManager ) -> None: - """Single pattern with occurrence_count > 1 should have correct count.""" + """A single pattern's count equals its occurrence_count.""" from ngraph.results.flow import FlowEntry, FlowIterationResult, FlowSummary summary = FlowSummary( diff --git a/tests/analysis/test_failure_manager_fixes.py b/tests/analysis/test_failure_manager_fixes.py index e68b7d1..e620a89 100644 --- a/tests/analysis/test_failure_manager_fixes.py +++ b/tests/analysis/test_failure_manager_fixes.py @@ -1,9 +1,9 @@ -"""Regression tests for FailureManager review fixes. +"""Regression tests for FailureManager. Covers: - Demand-placement Monte Carlo with id-less demand configs (stable demand ids). - Seed fallback to policy.seed when FailureManager seed is None. -- Context injection gated on the analysis function declaring 'context'. +- Context injection only through a prepare_inputs hook. - Prepared-matches cache identity check (id() address-reuse hazard). - No forced serial execution for __main__-defined analysis functions. - Transitive risk-group exclusions across a 3-level hierarchy. @@ -109,7 +109,7 @@ def test_reconstructed_ids_are_deterministic(self) -> None: assert len(set(ids_first)) == len(ids_first) def test_prebuilt_context_without_expansion_idless_config(self) -> None: - """Fallback path: context provided but expansion absent must still work.""" + """Pre-built context without expansion: an id-less config places its volume.""" network = _chain_network() config = [{"source": "^A$", "target": "^C$", "volume": 5.0, "mode": "combine"}] ctx, _, _ = build_demand_placement_inputs(network, config) @@ -228,7 +228,7 @@ def custom( assert all("context" not in kwargs for kwargs in captured) def test_declared_context_without_hook_gets_no_injection(self) -> None: - # Declaring a `context` parameter is no longer enough on its own; + # Declaring a `context` parameter is not enough on its own; # only a prepare_inputs hook opts a function into pre-building. fm = _manager_without_policy(_chain_network()) diff --git a/tests/analysis/test_failure_manager_integration.py b/tests/analysis/test_failure_manager_integration.py index 15b6c5c..1edc430 100644 --- a/tests/analysis/test_failure_manager_integration.py +++ b/tests/analysis/test_failure_manager_integration.py @@ -1,4 +1,4 @@ -"""Tests for FailureManager core functionality and integration.""" +"""Tests for FailureManager exclusions, Monte Carlo runs, and failure traces.""" import pytest @@ -11,7 +11,7 @@ class TestFailureManagerCore: - """Test core FailureManager functionality.""" + """FailureManager on a 3-node network: policies, exclusions, runs, and traces.""" @pytest.fixture def simple_network(self): @@ -112,7 +112,6 @@ def test_run_monte_carlo_analysis(self, simple_network, failure_policy_set): """Test Monte Carlo analysis execution.""" manager = FailureManager(simple_network, failure_policy_set, "single_failures") - # Run analysis with max flow function results = manager.run_monte_carlo_analysis( analysis_func=max_flow_analysis, iterations=5, # Small number for testing @@ -156,11 +155,10 @@ def test_analysis_with_parallel_execution(self, simple_network, failure_policy_s """Test parallel execution of Monte Carlo analysis.""" manager = FailureManager(simple_network, failure_policy_set, "single_failures") - # Run with multiple workers results = manager.run_monte_carlo_analysis( analysis_func=max_flow_analysis, iterations=4, - parallelism=2, # Multiple workers + parallelism=2, seed=42, source="A", target="C", @@ -235,7 +233,6 @@ def test_failure_trace_fields_present(self, simple_network, failure_policy_set): assert "selections" in trace, "Trace field 'selections' missing" assert "expansion" in trace, "Trace field 'expansion' missing" - # Verify selections structure assert isinstance(trace["selections"], list) if trace["selections"]: sel = trace["selections"][0] @@ -245,10 +242,9 @@ def test_failure_trace_fields_present(self, simple_network, failure_policy_set): assert "matched_count" in sel assert "selected_ids" in sel - # Verify expansion structure assert "nodes" in trace["expansion"] assert "links" in trace["expansion"] - assert "risk_groups" in trace["expansion"] + assert set(trace["expansion"]) == {"nodes", "links"} def test_failure_trace_not_present_when_disabled( self, simple_network, failure_policy_set @@ -260,7 +256,7 @@ def test_failure_trace_not_present_when_disabled( analysis_func=max_flow_analysis, iterations=5, parallelism=1, - store_failure_patterns=False, # Disabled + store_failure_patterns=False, seed=42, source="A", target="C", @@ -378,31 +374,27 @@ def mock_analysis(*args, **kwargs): class TestFailureManagerIntegration: - """Test FailureManager integration with workflow systems.""" + """FailureManager end to end with real and failing analysis functions.""" def test_capacity_envelope_analysis_integration(self): """Test integration with capacity analysis workflow producing FlowIterationResult.""" - # Create larger network for meaningful analysis + # 2-spine, 3-leaf fabric from ngraph.model.network import Link, Node network = Network() network.attrs["name"] = "spine_leaf" - # Add spine nodes network.add_node(Node("spine1")) network.add_node(Node("spine2")) - # Add leaf nodes network.add_node(Node("leaf1")) network.add_node(Node("leaf2")) network.add_node(Node("leaf3")) - # Add spine-leaf connections for spine in ["spine1", "spine2"]: for leaf in ["leaf1", "leaf2", "leaf3"]: network.add_link(Link(spine, leaf, capacity=10.0, cost=1)) - # Create failure policy policy_set = FailurePolicySet() rule = FailureRule( scope="link", @@ -416,7 +408,6 @@ def test_capacity_envelope_analysis_integration(self): manager = FailureManager(network, policy_set, "dual_link_failures") - # Run capacity analysis results = manager.run_monte_carlo_analysis( analysis_func=max_flow_analysis, iterations=10, @@ -427,7 +418,6 @@ def test_capacity_envelope_analysis_integration(self): mode="pairwise", ) - # Verify meaningful results assert "results" in results assert "metadata" in results @@ -437,7 +427,7 @@ def test_capacity_envelope_analysis_integration(self): total_occurrences = sum(r.occurrence_count for r in results["results"]) assert total_occurrences == 10 - # Each result is a FlowIterationResult; ensure flows present + # Each result is a FlowIterationResult with a flows list for iter_res in results["results"]: assert isinstance(iter_res, FlowIterationResult) assert hasattr(iter_res, "summary") @@ -445,8 +435,7 @@ def test_capacity_envelope_analysis_integration(self): assert iter_res.occurrence_count >= 1 def test_error_handling_in_analysis(self): - """Test error handling during analysis execution.""" - # Create test network + """An exception raised by the analysis function propagates to the caller.""" from ngraph.model.network import Link, Node network = Network() @@ -467,7 +456,6 @@ def failing_analysis_func(*args, **kwargs): manager = FailureManager(network, policy_set, "no_failures") - # Analysis should handle worker errors gracefully with pytest.raises(ValueError): # Should propagate the specific error manager.run_monte_carlo_analysis( analysis_func=failing_analysis_func, diff --git a/tests/analysis/test_flow_placement_semantics.py b/tests/analysis/test_flow_placement_semantics.py index dc944f8..acc409e 100644 --- a/tests/analysis/test_flow_placement_semantics.py +++ b/tests/analysis/test_flow_placement_semantics.py @@ -1,7 +1,4 @@ -"""Comprehensive tests validating IP and TE flow placement semantics with ECMP and WCMP. - -This test suite validates that NetGraph correctly implements the distinct behavioral -semantics of IP routing vs Traffic Engineering, and ECMP vs WCMP flow placement. +"""IP vs TE routing and ECMP vs WCMP flow placement semantics. Key distinctions tested: 1. IP routing (shortest_path=True): Uses only lowest-cost paths @@ -9,8 +6,8 @@ 3. ECMP (EQUAL_BALANCED): Equal splitting across equal-cost paths 4. WCMP (PROPORTIONAL): Capacity-proportional splitting across equal-cost paths -Tests use a shared topology where different settings produce measurably different results, -validating actual placement behavior (not just API correctness). +Shared topologies make each setting place a different volume, so the tests +check actual placement, not only the API surface. """ from __future__ import annotations @@ -23,7 +20,7 @@ def _unbalanced_parallel_paths() -> Network: """Create network with parallel paths of equal cost but different capacities. - This topology is specifically designed to expose differences between: + Exposes differences between: - ECMP vs WCMP: Different capacities mean WCMP can utilize more flow - IP vs TE: Multiple augmentations will behave differently @@ -161,7 +158,6 @@ def test_ecmp_vs_wcmp_utilization_gap(self): f"Expected WCMP ({wcmp_flow}) to be 3x ECMP ({ecmp_flow})" ) - # Verify specific values assert ecmp_flow == pytest.approx(30.0, abs=1e-6) assert wcmp_flow == pytest.approx(90.0, abs=1e-6) @@ -474,7 +470,7 @@ def test_progressive_ip_vs_true_ip(self): class TestAccountingValidation: - """Validate that flow accounting is correct across all modes.""" + """Cost distribution totals and run-to-run determinism across all modes.""" @pytest.mark.parametrize("shortest_path", [True, False]) @pytest.mark.parametrize( @@ -569,7 +565,6 @@ def test_4_lsps_on_8_diverse_paths(self): # Create TE LSP config with custom max_flow_count config = netgraph_core.FlowPolicyConfig() - config.path_alg = netgraph_core.PathAlg.SPF config.flow_placement = netgraph_core.FlowPlacement.EQUAL_BALANCED config.selection = netgraph_core.EdgeSelection( multi_edge=False, @@ -624,7 +619,6 @@ def test_2_lsps_on_5_diverse_paths(self): algs = ctx.algorithms config = netgraph_core.FlowPolicyConfig() - config.path_alg = netgraph_core.PathAlg.SPF config.flow_placement = netgraph_core.FlowPlacement.EQUAL_BALANCED config.selection = netgraph_core.EdgeSelection( multi_edge=False, @@ -656,7 +650,7 @@ def test_2_lsps_on_5_diverse_paths(self): class TestContextReuse: - """Test that AnalysisContext can be reused efficiently.""" + """One AnalysisContext serves repeated max_flow calls.""" def test_multiple_flow_calls_same_context(self): """Test that the same context can compute multiple flows.""" diff --git a/tests/analysis/test_functions.py b/tests/analysis/test_functions.py index d9db9d2..d53d303 100644 --- a/tests/analysis/test_functions.py +++ b/tests/analysis/test_functions.py @@ -19,11 +19,9 @@ class TestMaxFlowAnalysis: def simple_network(self) -> Network: """Create a simple test network with multiple paths.""" network = Network() - # Add nodes for node in ["datacenter1", "datacenter2", "edge1", "edge2", "router"]: network.add_node(Node(node)) - # Add links to create a network with capacity network.add_link(Link("datacenter1", "router", capacity=100.0, cost=1.0)) network.add_link(Link("datacenter2", "router", capacity=80.0, cost=1.0)) network.add_link(Link("router", "edge1", capacity=120.0, cost=1.0)) @@ -31,8 +29,24 @@ def simple_network(self) -> Network: return network + def test_max_flow_analysis_rejects_unbound_context( + self, simple_network: Network + ) -> None: + """An unbound context cannot serve a max-flow analysis; it raises.""" + from ngraph.analysis import analyze + + with pytest.raises(ValueError, match="Provided context is unbound"): + max_flow_analysis( + network=simple_network, + excluded_nodes=set(), + excluded_links=set(), + source="datacenter.*", + target="edge.*", + context=analyze(simple_network), + ) + def test_max_flow_analysis_basic(self, simple_network: Network) -> None: - """Test basic max_flow_analysis functionality.""" + """Combine mode returns one flow whose demand equals the placed max flow.""" result = max_flow_analysis( network=simple_network, excluded_nodes=set(), @@ -42,7 +56,6 @@ def test_max_flow_analysis_basic(self, simple_network: Network) -> None: mode="combine", ) - # Verify return format assert isinstance(result, FlowIterationResult) assert len(result.flows) == 1 # In combine mode, we get one aggregated flow @@ -94,7 +107,7 @@ def test_max_flow_analysis_with_optional_params( assert isinstance(result, FlowIterationResult) # In pairwise mode with 2 datacenters and 2 edges, we get 4 pairs assert len(result.flows) >= 1 - # Check that all flows have proper source/destination matching the regex + # Every flow's endpoints come from the source and target selectors for flow in result.flows: assert flow.source.startswith("datacenter") assert flow.destination.startswith("edge") @@ -115,7 +128,7 @@ def test_max_flow_analysis_rejects_unknown_params( def test_max_flow_analysis_empty_result(self, simple_network: Network) -> None: """Test max_flow_analysis with no matching nodes raises an error.""" - # In NetGraph-Core, non-matching nodes raise ValueError (better UX than silent empty) + # Unmatched selectors raise ValueError instead of returning an empty result with pytest.raises(ValueError, match="No source nodes found"): max_flow_analysis( network=simple_network, @@ -132,8 +145,7 @@ class TestDemandPlacementAnalysis: # Uses diamond_network fixture from conftest.py def test_demand_placement_analysis_basic(self, diamond_network: Network) -> None: - """Test basic demand_placement_analysis functionality.""" - # Use a smaller demand that should definitely fit + """A 50-unit pairwise demand on the diamond network is fully placed.""" demands_config = [ { "source": "A", @@ -151,7 +163,6 @@ def test_demand_placement_analysis_basic(self, diamond_network: Network) -> None demands_config=demands_config, ) - # Verify results structure assert isinstance(result, FlowIterationResult) assert len(result.flows) == 1 @@ -219,7 +230,6 @@ def test_context_caching_pairwise_mode(self, diamond_network: Network) -> None: # Build context once ctx, _, _ = build_demand_placement_inputs(diamond_network, demands_config) - # Use context for analysis result = demand_placement_analysis( network=diamond_network, excluded_nodes=set(), @@ -248,7 +258,7 @@ def test_context_caching_combine_mode(self, diamond_network: Network) -> None: # Build context once ctx, _, _ = build_demand_placement_inputs(diamond_network, demands_config) - # Use context for analysis - this is where the bug manifested + # Combine mode needs the pre-built context's pseudo nodes to match this run result = demand_placement_analysis( network=diamond_network, excluded_nodes=set(), @@ -292,10 +302,10 @@ def test_context_caching_combine_multiple_iterations( def test_context_caching_without_id_works(self, diamond_network: Network) -> None: """Context caching works without explicit IDs (deterministic ids). - Regression: configs without "id" previously got a fresh uuid on - every reconstruction, so pseudo node names diverged from the - pre-built context and analysis crashed with KeyError. IDs derived - from source/target/position keep them stable. + A config without "id" must not get a fresh uuid on every + reconstruction: pseudo node names would diverge from the pre-built + context and analysis would fail with KeyError. IDs derived from + source/target/position keep them stable. """ from ngraph.analysis.functions import build_demand_placement_inputs @@ -337,7 +347,7 @@ def simple_network(self) -> Network: return network def test_sensitivity_analysis_basic(self, simple_network: Network) -> None: - """Test basic sensitivity_analysis functionality.""" + """A 2-link chain reports max flow 10 and both saturated links as critical.""" result = sensitivity_analysis( network=simple_network, excluded_nodes=set(), @@ -351,14 +361,12 @@ def test_sensitivity_analysis_basic(self, simple_network: Network) -> None: assert isinstance(result, FlowIterationResult) assert len(result.flows) == 1 - # Check flow entry structure entry = result.flows[0] assert entry.source == "A" assert entry.destination == "C" assert entry.demand == entry.placed == 10.0 # max flow value assert entry.dropped == 0.0 - # Check sensitivity data in entry.data assert "sensitivity" in entry.data sensitivity = entry.data["sensitivity"] assert isinstance(sensitivity, dict) @@ -403,7 +411,7 @@ def counting(self: AnalysisContext, excluded_nodes=None): context=ctx, ) - # Previously two full passes (max_flow + sensitivity) built two masks. + # One combined pass (max_flow + sensitivity) builds a single mask. assert calls["node_mask"] == 1 entry = result.flows[0] assert entry.demand == entry.placed == 10.0 diff --git a/tests/analysis/test_functions_details.py b/tests/analysis/test_functions_details.py index 9fee1c5..42be0f0 100644 --- a/tests/analysis/test_functions_details.py +++ b/tests/analysis/test_functions_details.py @@ -11,7 +11,6 @@ def test_demand_placement_analysis_includes_flow_details_costs_and_edges() -> No for node in ["A", "B", "C", "D"]: network.add_node(Node(node)) - # Create two paths with different costs # Path 1: A -> B -> D (cost 2, capacity 100) network.add_link(Link("A", "B", capacity=100.0, cost=1.0)) network.add_link(Link("B", "D", capacity=100.0, cost=1.0)) @@ -24,7 +23,7 @@ def test_demand_placement_analysis_includes_flow_details_costs_and_edges() -> No { "source": "A", "target": "D", - "volume": 150.0, # Exceeds single path capacity, will use both paths + "volume": 150.0, # Exceeds the cost-2 path's capacity "mode": "pairwise", "priority": 0, }, @@ -39,14 +38,12 @@ def test_demand_placement_analysis_includes_flow_details_costs_and_edges() -> No include_used_edges=True, ) - # Validate result structure assert len(result.flows) == 1 flow = result.flows[0] # Should have cost_distribution when include_flow_details=True assert isinstance(flow.cost_distribution, dict) - # With both paths used, we should see different costs - # (exact distribution depends on flow policy) + # Exact distribution depends on the flow policy if flow.cost_distribution: assert len(flow.cost_distribution) > 0 assert all(isinstance(k, float) for k in flow.cost_distribution.keys()) diff --git a/tests/analysis/test_maxflow_api.py b/tests/analysis/test_maxflow_api.py index 326cdbe..3fe9b30 100644 --- a/tests/analysis/test_maxflow_api.py +++ b/tests/analysis/test_maxflow_api.py @@ -108,13 +108,12 @@ def test_empty_selection_raises() -> None: def test_shortest_path_vs_full_max_flow() -> None: """Test that shortest_path mode uses all equal-cost shortest paths. - This is a regression test for a critical bug that was fixed in NetGraph-Core. - The bug (flow_state.cpp line 233) caused shortest_path=True to break after - one DFS push, using only 1 of N parallel equal-cost paths instead of saturating - the entire equal-cost DAG. + Guards against the Core failure mode where shortest_path=True stops after + one DFS push and uses only 1 of N parallel equal-cost paths instead of + saturating the entire equal-cost DAG. - This test ensures shortest_path=True correctly saturates all equal-cost paths - in the lowest-cost tier without going to higher-cost tiers. + shortest_path=True must saturate all equal-cost paths in the lowest-cost + tier without going to higher-cost tiers. """ net = _simple_network() full = analyze(net).max_flow("^S$", "^T$", mode=Mode.COMBINE, shortest_path=False) @@ -165,7 +164,6 @@ def test_max_flow_with_details_include_min_cut() -> None: cut_capacity = sum(net.links[e.link_id].capacity for e in summary.min_cut) assert pytest.approx(cut_capacity, rel=0, abs=1e-9) == 2.0 - # Verify total flow is still correct assert pytest.approx(summary.total_flow, rel=0, abs=1e-9) == 2.0 @@ -175,8 +173,8 @@ def test_network_dc_to_dc_reverse_edge_first_hop() -> None: Nodes: A/dc, A/leaf, B/leaf, B/dc. Links (forward): A/leaf->A/dc (10), A/leaf->B/leaf (10), B/leaf->B/dc (10) - The graph builder creates a StrictMultiDiGraph with add_reverse=True, creating - reverse DC->leaf edges, so A/dc can reach B/dc via DC->leaf->leaf->DC. + The graph builder adds a reverse edge for every link, including DC->leaf, + so A/dc can reach B/dc via DC->leaf->leaf->DC. Expect positive flow (10.0) in combine mode. """ @@ -215,7 +213,7 @@ def _two_cost_tier_network() -> Network: def test_sensitivity_shortest_path_parameter_accepted() -> None: - """Test that sensitivity analysis correctly uses shortest_path parameter. + """Sensitivity analysis honors the shortest_path parameter. The shortest_path parameter controls routing semantics: - shortest_path=False (default): Full max-flow (SDN/TE mode). Reports all diff --git a/tests/analysis/test_maxflow_cache.py b/tests/analysis/test_maxflow_cache.py index af2f241..4d56750 100644 --- a/tests/analysis/test_maxflow_cache.py +++ b/tests/analysis/test_maxflow_cache.py @@ -1,13 +1,8 @@ -"""Tests for AnalysisContext caching and masking functionality. +"""Tests for AnalysisContext masking of disabled topology. -This module tests that AnalysisContext correctly handles: -- Disabled nodes (pre-computed in context, applied via masks) -- Disabled links (pre-computed in context, applied via masks) -- Combination of disabled topology and explicit exclusions -- Reuse of context for repeated analysis (bound vs unbound patterns) - -These tests validate that disabled topology elements are correctly -masked out in analysis results. +Covers disabled nodes and links (precomputed in the context and applied via +masks), their combination with explicit exclusions, and context reuse in +bound and unbound patterns. """ from __future__ import annotations @@ -210,7 +205,6 @@ def test_explicit_exclusion_without_disabled_topology(self) -> None: ctx = analyze(net, source="^A$", sink="^D$", mode=Mode.COMBINE) - # Exclude node B explicitly result = ctx.max_flow(excluded_nodes={"B"}) # Should only flow through C @@ -275,7 +269,7 @@ def test_disabled_node_pairwise_mode(self) -> None: class TestContextReuse: - """Tests for efficient context reuse with different exclusions.""" + """Tests for context reuse with different exclusions.""" def test_multiple_exclusion_scenarios(self) -> None: """Same context should work with different exclusion sets.""" @@ -287,11 +281,9 @@ def test_multiple_exclusion_scenarios(self) -> None: baseline = ctx.max_flow() assert pytest.approx(baseline[("^A$", "^D$")], abs=1e-9) == 8.0 - # Exclude B exclude_b = ctx.max_flow(excluded_nodes={"B"}) assert pytest.approx(exclude_b[("^A$", "^D$")], abs=1e-9) == 3.0 - # Exclude C exclude_c = ctx.max_flow(excluded_nodes={"C"}) assert pytest.approx(exclude_c[("^A$", "^D$")], abs=1e-9) == 5.0 diff --git a/tests/analysis/test_maxflow_cost_distribution.py b/tests/analysis/test_maxflow_cost_distribution.py index 371ddad..97f2319 100644 --- a/tests/analysis/test_maxflow_cost_distribution.py +++ b/tests/analysis/test_maxflow_cost_distribution.py @@ -1,7 +1,6 @@ """Tests for cost distribution in max-flow results. -These tests verify that max_flow_detailed correctly computes and reports -the distribution of flow across different cost tiers. +max_flow_detailed reports how much flow is placed at each path cost. """ from __future__ import annotations @@ -49,10 +48,10 @@ def _single_path_network() -> Network: class TestCostDistributionBasic: - """Basic cost distribution tests.""" + """Cost distribution on multi-tier, equal-cost, and single-path networks.""" def test_multi_tier_distribution(self) -> None: - """Test that flow is distributed across cost tiers correctly.""" + """The asymmetric diamond places 5 at cost 2 and 3 at cost 4.""" net = make_asymmetric_diamond() result = analyze(net).max_flow_detailed("^A$", "^D$", mode=Mode.COMBINE) @@ -159,7 +158,7 @@ def test_proportional_placement(self) -> None: class TestEdgeCases: - """Edge case tests for cost distribution.""" + """Cost distribution with no flow and with zero-capacity paths.""" def test_no_flow_empty_distribution(self) -> None: """Test that zero flow results in empty distribution.""" diff --git a/tests/analysis/test_paths.py b/tests/analysis/test_paths.py index 60bace8..4883bcd 100644 --- a/tests/analysis/test_paths.py +++ b/tests/analysis/test_paths.py @@ -151,7 +151,6 @@ def test_returns_path_objects(self) -> None: paths = results[("^A$", "^C$")] assert len(paths) >= 1 - # Check first path structure path = paths[0] assert path.cost == 2.0 # A->B->C assert len(path.path) == 3 # A, B, C (path attribute is the sequence) @@ -288,9 +287,9 @@ def _multi_member_group_network() -> Network: def test_multi_node_groups_merge_paths_across_pairs(self) -> None: """KSP between multi-node groups merges paths from all node pairs. - Regression: KSP previously ran only between the single best (src, - snk) node pair, silently omitting cheaper paths from other pairs - (here the cost-11 x2->y2 path lost to the cost-15 x1->m->y1 path). + Running KSP only between the single best (src, snk) node pair would + silently omit cheaper paths from other pairs (here the cost-11 + x2->y2 path versus the cost-15 x1->m->y1 path). """ net = self._multi_member_group_network() @@ -340,9 +339,9 @@ def _many_pair_group_network() -> Network: def test_multi_node_groups_prune_pairs_beyond_kth_best_cost(self) -> None: """Per-pair KSP stops once later pairs cannot reach the top-k. - Regression: every reachable node pair previously ran a full KSP - (100 runs here) even though only the cheapest few pairs can - contribute paths that survive the max_k truncation. + A full KSP per reachable node pair (100 runs here) is wasted work: + only the cheapest few pairs can contribute paths that survive the + max_k truncation. """ net = self._many_pair_group_network() ctx = analyze(net) @@ -371,8 +370,8 @@ def ksp(self, *args: Any, **kwargs: Any) -> Any: def test_equal_cost_truncation_is_deterministic(self) -> None: """Equal-cost ties beyond max_k truncate by structural path order. - Regression: with more equal-cost paths than max_k, truncation - previously kept a set-iteration-order (hash-dependent) subset. + With more equal-cost paths than max_k, truncation must not keep a + set-iteration-order (hash-dependent) subset. """ net = Network() for name in ["S1", "S2", "M1", "M2", "M3", "T1", "T2"]: @@ -400,8 +399,8 @@ def test_equal_cost_truncation_is_deterministic(self) -> None: def test_equal_cost_truncation_stable_across_hash_seeds(self) -> None: """Truncated path selection is identical across PYTHONHASHSEED values. - Regression: the selected subset of equal-cost paths previously - varied across processes with different string-hash seeds. + The selected subset of equal-cost paths must not vary across + processes with different string-hash seeds. """ script = textwrap.dedent( """ @@ -444,7 +443,7 @@ class TestDictSelectorsWithShortestPaths: """Tests for dict-based selectors with shortest path methods. Verifies that shortest_path_cost, shortest_paths, and k_shortest_paths - correctly handle dict selectors (group_by, match) in both unbound and + handle dict selectors (group_by, match) in both unbound and bound context modes. """ @@ -554,7 +553,6 @@ def test_shortest_path_cost_bound_mode(self) -> None: """Test shortest_path_cost works with bound context.""" net = _simple_path_network() - # Create bound context ctx = analyze(net, source="^A$", sink="^C$") # Call without source/sink - should use bound values @@ -606,15 +604,30 @@ def test_bound_mode_rejects_source_sink_args(self) -> None: net = _simple_path_network() ctx = analyze(net, source="^A$", sink="^C$") - with pytest.raises(ValueError, match="source/sink already configured"): + with pytest.raises(ValueError, match="source/sink/mode already configured"): ctx.shortest_path_cost(source="^X$", sink="^Y$") - with pytest.raises(ValueError, match="source/sink already configured"): + with pytest.raises(ValueError, match="source/sink/mode already configured"): ctx.shortest_paths(source="^X$", sink="^Y$") - with pytest.raises(ValueError, match="source/sink already configured"): + with pytest.raises(ValueError, match="source/sink/mode already configured"): ctx.k_shortest_paths(source="^X$", sink="^Y$") + def test_bound_context_rejects_mode(self) -> None: + """A bound context fixes its mode; passing one per call raises.""" + net = _simple_path_network() + ctx = analyze(net, source="^A$", sink="^C$") + + with pytest.raises(ValueError, match="source/sink/mode already configured"): + ctx.shortest_paths(mode=Mode.PAIRWISE) + with pytest.raises(ValueError, match="source/sink/mode already configured"): + ctx.max_flow(mode=Mode.COMBINE) + + def test_mode_without_binding_raises(self) -> None: + """analyze(mode=...) without source/sink would be ignored, so it raises.""" + with pytest.raises(ValueError, match="mode applies to a bound context"): + analyze(_simple_path_network(), mode=Mode.PAIRWISE) + def test_unbound_mode_requires_source_sink(self) -> None: """Test that unbound context requires source/sink arguments.""" net = _simple_path_network() diff --git a/tests/analysis/test_placement.py b/tests/analysis/test_placement.py index cec92d3..b8956ab 100644 --- a/tests/analysis/test_placement.py +++ b/tests/analysis/test_placement.py @@ -13,8 +13,7 @@ from ngraph.analysis.functions import demand_placement_analysis from ngraph.analysis.placement import ( CACHEABLE_PRESETS, - _get_edge_selection, - _get_flow_placement, + _preset_modes, ) from ngraph.model.flow.policy_config import FlowPolicyPreset from ngraph.model.network import Link, Network, Node @@ -41,12 +40,10 @@ def _run_demand_placement_without_cache( from ngraph.analysis.demand import expand_demands from ngraph.model.demand.spec import TrafficDemand from ngraph.model.flow.policy_config import ( - FlowPolicyPreset, create_flow_policy, ) from ngraph.results.flow import FlowEntry, FlowSummary - # Reconstruct TrafficDemand objects traffic_demands = [] for config in demands_config: demand = TrafficDemand( @@ -59,14 +56,8 @@ def _run_demand_placement_without_cache( ) traffic_demands.append(demand) - # Expand demands - expansion = expand_demands( - network, - traffic_demands, - default_policy_preset=FlowPolicyPreset.SHORTEST_PATHS_ECMP, - ) + expansion = expand_demands(network, traffic_demands) - # Build context ctx = AnalysisContext.from_network(network, augmentations=expansion.augmentations) handle = ctx.handle @@ -124,7 +115,9 @@ def _run_demand_placement_without_cache( ) edges = flow_graph.get_flow_edges(flow_idx) for edge_id, _ in edges: - edge_ref = edge_mapper.to_ref(edge_id, multidigraph) + edge_ref = edge_mapper.decode_ext_id( + int(multidigraph.ext_edge_ids_view()[edge_id]) + ) if edge_ref is not None: used_edges.add(f"{edge_ref.link_id}:{edge_ref.direction}") @@ -169,19 +162,19 @@ class TestHelperFunctions: def test_get_selection_for_ecmp(self) -> None: """Test EdgeSelection for ECMP preset.""" - selection = _get_edge_selection(FlowPolicyPreset.SHORTEST_PATHS_ECMP) + selection = _preset_modes(FlowPolicyPreset.SHORTEST_PATHS_ECMP)[0] assert selection.multi_edge is True assert selection.require_capacity is False def test_get_selection_for_wcmp(self) -> None: """Test EdgeSelection for WCMP preset.""" - selection = _get_edge_selection(FlowPolicyPreset.SHORTEST_PATHS_WCMP) + selection = _preset_modes(FlowPolicyPreset.SHORTEST_PATHS_WCMP)[0] assert selection.multi_edge is True assert selection.require_capacity is False def test_get_selection_for_te_wcmp_unlim(self) -> None: """Test EdgeSelection for TE_WCMP_UNLIM preset.""" - selection = _get_edge_selection(FlowPolicyPreset.TE_WCMP_UNLIM) + selection = _preset_modes(FlowPolicyPreset.TE_WCMP_UNLIM)[0] assert selection.multi_edge is True assert selection.require_capacity is True @@ -189,7 +182,7 @@ def test_get_placement_for_ecmp(self) -> None: """Test FlowPlacement for ECMP preset.""" import netgraph_core - placement = _get_flow_placement(FlowPolicyPreset.SHORTEST_PATHS_ECMP) + placement = _preset_modes(FlowPolicyPreset.SHORTEST_PATHS_ECMP)[1] # Lossless hash-ECMP admission with a load-blind next-hop set. assert placement == netgraph_core.FlowPlacement.EQUAL_BALANCED @@ -197,19 +190,19 @@ def test_get_placement_for_wcmp(self) -> None: """Test FlowPlacement for WCMP preset.""" import netgraph_core - placement = _get_flow_placement(FlowPolicyPreset.SHORTEST_PATHS_WCMP) + placement = _preset_modes(FlowPolicyPreset.SHORTEST_PATHS_WCMP)[1] assert placement == netgraph_core.FlowPlacement.PROPORTIONAL def test_get_placement_for_te_wcmp_unlim(self) -> None: """Test FlowPlacement for TE_WCMP_UNLIM preset.""" import netgraph_core - placement = _get_flow_placement(FlowPolicyPreset.TE_WCMP_UNLIM) + placement = _preset_modes(FlowPolicyPreset.TE_WCMP_UNLIM)[1] assert placement == netgraph_core.FlowPlacement.PROPORTIONAL class TestCacheablePresets: - """Test that cacheable preset sets are correctly defined.""" + """Shortest-path and TE_WCMP_UNLIM presets are cacheable; LSP presets are not.""" def test_cacheable_presets_contains_expected(self) -> None: """Test that cacheable presets contain expected policies.""" @@ -255,7 +248,7 @@ def multi_source_network(self) -> Network: return network def test_single_demand_ecmp(self, diamond_network: Network) -> None: - """Test that single demand with ECMP works correctly with caching.""" + """A single 50-unit ECMP demand is fully placed through the SPF cache.""" demands_config = [ { "source": "A", @@ -284,7 +277,7 @@ def test_single_demand_ecmp(self, diamond_network: Network) -> None: def test_multiple_demands_same_source_reuses_cache( self, multi_source_network: Network ) -> None: - """Test that multiple demands from same source benefit from caching.""" + """Two demands from S1 that share one cached SPF are both fully placed.""" # Multiple demands from S1 to different destinations demands_config = [ { @@ -401,7 +394,6 @@ def test_equivalence_ecmp_single_demand(self, mesh_network: Network) -> None: demands_config=demands_config, ) - # Compare results assert len(cached_result.flows) == len(reference_result.flows) assert ( cached_result.summary.total_demand == reference_result.summary.total_demand @@ -434,7 +426,6 @@ def test_equivalence_ecmp_multiple_demands(self, mesh_network: Network) -> None: demands_config=demands_config, ) - # Compare summaries assert ( cached_result.summary.total_demand == reference_result.summary.total_demand ) @@ -442,7 +433,6 @@ def test_equivalence_ecmp_multiple_demands(self, mesh_network: Network) -> None: reference_result.summary.total_placed, rel=1e-9 ) - # Compare individual flows for cached_flow, ref_flow in zip( cached_result.flows, reference_result.flows, strict=True ): @@ -471,7 +461,6 @@ def test_equivalence_with_flow_details(self, mesh_network: Network) -> None: include_flow_details=True, ) - # Both should have cost distribution for cached_flow, ref_flow in zip( cached_result.flows, reference_result.flows, strict=True ): @@ -503,13 +492,11 @@ def test_equivalence_with_used_edges(self, mesh_network: Network) -> None: include_used_edges=True, ) - # Both should have used edges for cached_flow, ref_flow in zip( cached_result.flows, reference_result.flows, strict=True ): cached_edges = set(cached_flow.data.get("edges", [])) ref_edges = set(ref_flow.data.get("edges", [])) - # Edges should be the same assert cached_edges == ref_edges @@ -635,7 +622,7 @@ def test_te_wcmp_multiple_demands_same_source( class TestSPFCachingEdgeCases: - """Test edge cases and error handling for SPF caching.""" + """Cached placement with unreachable targets, zero or excess demand, no details.""" @pytest.fixture def disconnected_network(self) -> Network: @@ -800,7 +787,7 @@ def triangle_network(self) -> Network: network.add_link(Link("A", "B", capacity=100.0, cost=1.0)) network.add_link(Link("B", "C", capacity=100.0, cost=1.0)) - network.add_link(Link("A", "C", capacity=100.0, cost=2.0)) # Longer path + network.add_link(Link("A", "C", capacity=100.0, cost=2.0)) # Same cost as A-B-C return network @@ -834,9 +821,8 @@ def test_placement_with_excluded_link(self, triangle_network: Network) -> None: assert len(result.flows) == 1 flow = result.flows[0] assert flow.placed == 50.0 - # Should use path A -> B -> C (cost 2) instead of A -> C (cost 2) + # A -> B -> C costs 2, the same as the excluded direct link if flow.cost_distribution: - # Cost should be 2 (through B) not 2 (direct, which is excluded) assert 2.0 in flow.cost_distribution def test_placement_with_excluded_node(self, triangle_network: Network) -> None: @@ -868,7 +854,7 @@ def test_placement_with_excluded_node(self, triangle_network: Network) -> None: class TestSPFCachingCostDistribution: - """Test cost distribution correctness with SPF caching.""" + """Cost distribution with SPF caching on one and two tiers.""" # Uses multi_tier_network fixture from conftest.py @@ -1136,7 +1122,6 @@ def test_cached_equals_noncached( demands_config=demands_config, ) - # Compare summaries assert cached_result.summary.total_demand == pytest.approx( reference_result.summary.total_demand, rel=1e-9 ), "Total demand mismatch" @@ -1151,7 +1136,6 @@ def test_cached_equals_noncached( reference_result.summary.overall_ratio, rel=1e-9 ), "Overall ratio mismatch" - # Compare individual flows assert len(cached_result.flows) == len(reference_result.flows), ( "Flow count mismatch" ) @@ -1173,7 +1157,7 @@ def test_cached_equals_noncached( def test_te_overlapping_paths(self, overlapping_paths_network: Network) -> None: """Test TE policy with overlapping paths to different destinations. - This test specifically validates that cached placement handles the case where: + Cached placement must handle the case where: 1. First demand A->D saturates shared edge A->B, triggers TE rerouting 2. Second demand A->E needs the same shared edge A->B for optimal path @@ -1213,7 +1197,6 @@ def test_te_overlapping_paths(self, overlapping_paths_network: Network) -> None: demands_config=demands_config, ) - # Compare total placed - should be identical assert cached_result.summary.total_placed == pytest.approx( reference_result.summary.total_placed, rel=1e-9 ), ( @@ -1222,7 +1205,6 @@ def test_te_overlapping_paths(self, overlapping_paths_network: Network) -> None: f"ref={reference_result.summary.total_placed}" ) - # Compare individual flow placements for i, (cached_flow, ref_flow) in enumerate( zip(cached_result.flows, reference_result.flows, strict=True) ): @@ -1234,8 +1216,8 @@ def test_te_overlapping_paths(self, overlapping_paths_network: Network) -> None: def test_mixed_preset_same_endpoints_no_flow_index_collision() -> None: """Regression: cached and policy-based demands sharing (src, dst, priority) - must not merge flows via colliding FlowIndex values. Pre-fix this scenario - reported 15.0 placed across a 10-unit min cut.""" + must not merge flows via colliding FlowIndex values. Merged flows would + report 15.0 placed across a 10-unit min cut.""" from ngraph.analysis.functions import demand_placement_analysis from ngraph.model.network import Link, Network, Node diff --git a/tests/analysis/test_risk_group_failure_e2e.py b/tests/analysis/test_risk_group_failure_e2e.py index 7ba6be8..1a1abbb 100644 --- a/tests/analysis/test_risk_group_failure_e2e.py +++ b/tests/analysis/test_risk_group_failure_e2e.py @@ -1,8 +1,8 @@ """End-to-end test for risk group scoped failures. -Verifies the full pipeline: FailureManager.compute_exclusions โ†’ -FailurePolicy.apply_failures โ†’ match_entity_ids correctly matches -risk group attributes and excludes member links. +Covers the full pipeline FailureManager.compute_exclusions โ†’ +FailurePolicy.apply_failures โ†’ match_entity_ids: risk group attributes +are matched and member links are excluded. """ from __future__ import annotations diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 78bd924..7900503 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -8,7 +8,7 @@ from ngraph import cli -# High-value CLI run command tests +# run command def test_run_writes_results_file_and_contains_build_graph(tmp_path: Path) -> None: @@ -129,16 +129,19 @@ def test_run_filter_by_step_names_subsets_results(tmp_path: Path, monkeypatch) - ) -def test_run_filter_nonexistent_step_produces_empty_results( - tmp_path: Path, monkeypatch +def test_run_filter_nonexistent_step_fails_before_running( + tmp_path: Path, monkeypatch, capsys ) -> None: scenario = Path("tests/integration/scenario_3.yaml").resolve() out_path = tmp_path / "empty.json" monkeypatch.chdir(tmp_path) - cli.main(["run", str(scenario), "--results", str(out_path), "--keys", "missing"]) - data = json.loads(out_path.read_text()) - assert data.get("steps", {}) == {} + with pytest.raises(SystemExit): + cli.main( + ["run", str(scenario), "--results", str(out_path), "--keys", "missing"] + ) + assert "Unknown step name(s) in --keys: missing" in capsys.readouterr().err + assert not out_path.exists() def test_run_profile_flag_writes_results(tmp_path: Path, monkeypatch) -> None: @@ -169,7 +172,14 @@ def test_run_profile_flag_writes_results(tmp_path: Path, monkeypatch) -> None: assert "steps" in data and "stats" in data["steps"] -# Logging behavior (value assertions, not implementation details) +# Flag validation and logging levels + + +def test_run_profile_memory_requires_profile(capsys) -> None: + scenario = Path("tests/integration/scenario_1.yaml") + with pytest.raises(SystemExit): + cli.main(["run", str(scenario), "--no-results", "--profile-memory"]) + assert "--profile-memory requires --profile" in capsys.readouterr().err def test_logging_levels_default_verbose_quiet( @@ -206,7 +216,7 @@ def test_logging_levels_default_verbose_quiet( assert len(info_records) < 5 -# Inspect command tests (functional output presence) +# inspect command def test_inspect_happy_path_prints_sections(tmp_path: Path) -> None: @@ -381,7 +391,6 @@ def test_inspect_capacity_vs_demand_summary_basic(tmp_path: Path) -> None: def test_run_profile_uses_output_dir_profiles(tmp_path: Path, monkeypatch) -> None: - # Minimal scenario scenario_file = tmp_path / "p.yaml" scenario_file.write_text( """ diff --git a/tests/cli/test_cli_helpers.py b/tests/cli/test_cli_helpers.py index 26c92f8..0170591 100644 --- a/tests/cli/test_cli_helpers.py +++ b/tests/cli/test_cli_helpers.py @@ -35,7 +35,7 @@ def test_format_table() -> None: ["H1", "H2"], [["abc", "1"], ["defghi", "2"]], max_col_width=5 ) assert "H1" in table and "H2" in table - # Ensure clipping with ASCII ellipsis (max_col_width=5 -> keep 2 chars + '...') + # Clipped with an ASCII ellipsis: max_col_width=5 keeps 2 chars + '...' assert "de..." in table @@ -45,7 +45,6 @@ def test_collect_and_summarize_node_matches() -> None: summary = cli_mod._summarize_node_matches(step, net) # Only *_path and *_regex fields considered assert set(summary.keys()) == {"src_path", "dst_regex"} - # Each entry should include expected keys for v in summary.values(): assert set(v.keys()) >= { "pattern", diff --git a/tests/cli/test_cli_inspect_fixes.py b/tests/cli/test_cli_inspect_fixes.py index e8f991e..99347ad 100644 --- a/tests/cli/test_cli_inspect_fixes.py +++ b/tests/cli/test_cli_inspect_fixes.py @@ -1,11 +1,9 @@ -"""Regression tests for `ngraph inspect` output fixes. +"""Regression tests for `ngraph inspect` output. Covers: - Single-pass per-node capacity/link-count aggregation in - ``_print_network_structure`` (previously O(V*E) nested scans), including - self-loop semantics. -- "Top demands (by offered volume)" sorting by the ``volume`` attribute - (previously keyed on a nonexistent ``demand`` attribute). + ``_print_network_structure``, including self-loop semantics. +- "Top demands (by offered volume)" sorting by the ``volume`` attribute. """ from __future__ import annotations diff --git a/tests/cli/test_cli_profile_hook.py b/tests/cli/test_cli_profile_hook.py index 81c28c1..83faea8 100644 --- a/tests/cli/test_cli_profile_hook.py +++ b/tests/cli/test_cli_profile_hook.py @@ -26,7 +26,7 @@ def test_run_profile_prints_performance_report( tmp_path: Path, monkeypatch, capsys ) -> None: - """``ngraph run --profile`` still emits the per-step performance report.""" + """``ngraph run --profile`` emits the per-step performance report.""" scenario_file = tmp_path / "p.yaml" scenario_file.write_text(_SCENARIO_YAML) monkeypatch.chdir(tmp_path) @@ -50,8 +50,8 @@ def test_run_profile_restores_profile_dir_env(tmp_path: Path, monkeypatch) -> No cli.main(["run", str(scenario_file), "--profile", "--no-results"]) - # Previously unset, so it must be removed (not left pointing at a stale - # directory that would silently re-enable worker profiling later). + # Unset before the run, so it must be removed afterwards (not left + # pointing at a stale directory that would re-enable worker profiling). assert "NGRAPH_PROFILE_DIR" not in os.environ diff --git a/tests/cli/test_module_entrypoint.py b/tests/cli/test_module_entrypoint.py index 8be74da..29370f9 100644 --- a/tests/cli/test_module_entrypoint.py +++ b/tests/cli/test_module_entrypoint.py @@ -1,7 +1,6 @@ """Tests for running NetGraph as a module (`python -m ngraph`). -These tests exercise the `ngraph.__main__` entrypoint to improve coverage -for the module execution path. +These tests exercise the `ngraph.__main__` entrypoint. """ from __future__ import annotations diff --git a/tests/cli/test_package_layering.py b/tests/cli/test_package_layering.py index 1bf733c..5941122 100644 --- a/tests/cli/test_package_layering.py +++ b/tests/cli/test_package_layering.py @@ -17,6 +17,6 @@ def test_import_ngraph_does_not_import_cli() -> None: def test_from_ngraph_import_cli_still_works() -> None: - """``from ngraph import cli`` resolves via submodule import fallback.""" + """``from ngraph import cli`` imports the submodule on demand.""" code = "from ngraph import cli; assert callable(cli.main)" subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/conftest.py b/tests/conftest.py index 8fd3ab5..bdfddd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,6 @@ from ngraph import Link, Network, Node -# ----------------------------------------------------------------------------- -# Shared Network Fixtures -# ----------------------------------------------------------------------------- - @pytest.fixture def diamond_network() -> Network: @@ -70,15 +66,6 @@ def make_asymmetric_diamond( return net -@pytest.fixture -def asymmetric_diamond() -> Network: - """Asymmetric diamond network with different cost tiers. - - Shortcut fixture for make_asymmetric_diamond() with defaults. - """ - return make_asymmetric_diamond() - - @pytest.fixture def multi_tier_network() -> Network: """Multi-tier cost network with large capacity. diff --git a/tests/dev/__init__.py b/tests/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dev/test_api_docs_generator.py b/tests/dev/test_api_docs_generator.py new file mode 100644 index 0000000..4f3cc4d --- /dev/null +++ b/tests/dev/test_api_docs_generator.py @@ -0,0 +1,39 @@ +"""Markdown normalization in dev/generate_api_docs.py.""" + +import importlib.util +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "generate_api_docs", + Path(__file__).resolve().parents[2] / "dev" / "generate_api_docs.py", +) +assert _SPEC is not None and _SPEC.loader is not None +_MODULE = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MODULE) +normalize = _MODULE._normalize_markdown_lists + + +def test_emphasis_spaces_are_removed_keeping_the_text() -> None: + assert normalize("a ** bold ** b") == "a **bold** b" + assert normalize("a * it * b") == "a *it* b" + assert normalize("a __ u __ b") == "a __u__ b" + + +def test_fenced_code_is_left_unchanged() -> None: + text = "```\nx = a ** b ** c\n* not a bullet\n```" + assert normalize(text) == text + + +def test_code_spans_are_left_unchanged() -> None: + assert normalize("keep `a ** b ** c` span") == "keep `a ** b ** c` span" + + +def test_blank_lines_inside_fenced_code_are_kept() -> None: + text = "```\na = 1\n\n\nb = 2\n```" + assert normalize(text) == text + + +def test_list_outside_code_gets_surrounding_blank_lines() -> None: + assert ( + normalize("Intro:\n- one\n- two\nAfter.") == "Intro:\n\n- one\n- two\n\nAfter." + ) diff --git a/tests/dev/test_perf_analysis.py b/tests/dev/test_perf_analysis.py new file mode 100644 index 0000000..ed23668 --- /dev/null +++ b/tests/dev/test_perf_analysis.py @@ -0,0 +1,42 @@ +"""Complexity fits in dev/perf are computed per profile run.""" + +from dev.perf.analysis import PerformanceAnalyzer +from dev.perf.core import ( + LINEAR, + QUADRATIC, + BenchmarkCase, + BenchmarkProfile, + BenchmarkResult, + BenchmarkSample, + BenchmarkTask, + ComplexityAnalysisSpec, + ComplexityModel, +) + + +def _run(name: str, model: ComplexityModel, times: dict[int, float]) -> BenchmarkResult: + cases = [ + BenchmarkCase(f"{name}_{n}", BenchmarkTask.SHORTEST_PATH, str(n), {}) + for n in times + ] + profile = BenchmarkProfile(name, cases, ComplexityAnalysisSpec(model)) + samples = [ + BenchmarkSample(case, case.problem_size, t, t, 0.0, t, t, 5, "t0") + for case, t in zip(cases, times.values(), strict=True) + ] + return BenchmarkResult(profile, samples, name, "t0", "t1") + + +def test_profiles_sharing_a_task_are_fitted_separately() -> None: + linear = _run("linear", LINEAR, {10: 0.01, 100: 0.1, 1000: 1.0}) + quadratic = _run("quadratic", QUADRATIC, {10: 0.001, 100: 0.1, 1000: 10.0}) + analyzer = PerformanceAnalyzer() + analyzer.add_runs([linear, quadratic]) + + linear_fit = analyzer.get_complexity_summary(linear) + quadratic_fit = analyzer.get_complexity_summary(quadratic) + + assert linear_fit["samples"] == 3 + assert quadratic_fit["samples"] == 3 + assert abs(linear_fit["empirical_exponent"] - 1.0) < 1e-9 + assert abs(quadratic_fit["empirical_exponent"] - 2.0) < 1e-9 diff --git a/tests/dsl/test_dot_notation_conditions.py b/tests/dsl/test_dot_notation_conditions.py index 8e1bcc7..ce24f13 100644 --- a/tests/dsl/test_dot_notation_conditions.py +++ b/tests/dsl/test_dot_notation_conditions.py @@ -1,6 +1,6 @@ """Tests for dot-notation attribute access in conditions.""" -from ngraph.dsl.selectors import Condition, evaluate_condition, resolve_attr_path +from ngraph.model.selectors import Condition, evaluate_condition, resolve_attr_path class TestResolveAttrPath: @@ -21,7 +21,7 @@ def test_nested_attribute(self): assert value == "Acme" def test_deeply_nested(self): - """Multiple levels of nesting work.""" + """Three-level dot path resolves to the leaf value.""" attrs = {"level1": {"level2": {"level3": "deep_value"}}} found, value = resolve_attr_path(attrs, "level1.level2.level3") assert found is True @@ -49,7 +49,7 @@ def test_path_through_non_dict(self): assert value is None def test_empty_attrs(self): - """Empty attrs dict handles any path.""" + """Any path on an empty attrs dict returns (False, None).""" found, value = resolve_attr_path({}, "any.path") assert found is False assert value is None @@ -114,7 +114,7 @@ def test_not_exists_nested_none(self): assert evaluate_condition(attrs, cond) is True def test_simple_attribute_path(self): - """Simple (non-dotted) attribute paths work correctly.""" + """Non-dotted attribute path matches with ==.""" attrs = {"role": "spine", "tier": 2} cond = Condition(attr="role", op="==", value="spine") assert evaluate_condition(attrs, cond) is True @@ -130,7 +130,7 @@ class TestDeeplyNestedDotNotation: """Tests for deeply nested attribute paths (4+ levels).""" def test_four_level_nesting(self): - """Four levels of nesting work correctly.""" + """Four-level dot path resolves to the leaf value.""" attrs = {"infrastructure": {"facility": {"building": {"floor": "3"}}}} found, value = resolve_attr_path( attrs, "infrastructure.facility.building.floor" @@ -139,7 +139,7 @@ def test_four_level_nesting(self): assert value == "3" def test_five_level_nesting(self): - """Five levels of nesting work correctly.""" + """Five-level dot path resolves to the leaf value.""" attrs = {"topology": {"fiber": {"path": {"segment": {"conduit_id": "C-001"}}}}} found, value = resolve_attr_path( attrs, "topology.fiber.path.segment.conduit_id" @@ -148,7 +148,7 @@ def test_five_level_nesting(self): assert value == "C-001" def test_deep_nesting_condition_evaluation(self): - """Condition evaluation works with deeply nested paths.""" + """== condition matches on a five-level dot path.""" attrs = {"facility": {"datacenter": {"room": {"rack": {"pdu_zone": "A"}}}}} cond = Condition( attr="facility.datacenter.room.rack.pdu_zone", op="==", value="A" diff --git a/tests/dsl/test_dsl_features_validation.py b/tests/dsl/test_dsl_features_validation.py index de8fba1..6917ca0 100644 --- a/tests/dsl/test_dsl_features_validation.py +++ b/tests/dsl/test_dsl_features_validation.py @@ -1,6 +1,6 @@ -"""Validation tests for DSL features to document in skill reference. +"""Behavior checks for DSL features described in the skill reference. -These tests verify the behavior of DSL features to ensure documentation accuracy. +Each test pins a documented behavior so the reference stays accurate. """ import jsonschema @@ -33,11 +33,9 @@ def test_link_match_filters_by_capacity(self): scenario = Scenario.from_yaml(yaml_str) net = scenario.network - # Check link costs costs = [link.cost for link in net.links.values()] assert sorted(costs) == [1, 99], f"Expected [1, 99], got {sorted(costs)}" - # Verify only the high-capacity link was updated for link in net.links.values(): if link.capacity >= 400: assert link.cost == 99, "High-capacity link should have cost 99" @@ -270,12 +268,11 @@ def test_path_filters_nodes_in_generate(self): "Env_development should not exist" ) - # Check membership assert "Env_production" in net.nodes["prod_srv1"].risk_groups assert "Env_production" in net.nodes["prod_srv2"].risk_groups def test_path_filters_links_in_generate(self): - """path filter works on links in generate blocks.""" + """path filter narrows links before generating risk groups.""" yaml_str = """ network: nodes: @@ -305,7 +302,7 @@ class TestInlineFlowPolicyObjects: """Validate inline flow_policy objects - custom policy configs.""" def test_flow_policy_preset_string(self): - """Preset string flow_policy should work.""" + """Preset string flow_policy parses to a FlowPolicyPreset enum.""" yaml_str = """ network: nodes: @@ -324,7 +321,6 @@ def test_flow_policy_preset_string(self): scenario = Scenario.from_yaml(yaml_str) demands = scenario.demand_set.sets.get("test", []) assert len(demands) == 1 - # flow_policy should be a FlowPolicyPreset enum from ngraph.model.flow.policy_config import FlowPolicyPreset assert demands[0].flow_policy == FlowPolicyPreset.SHORTEST_PATHS_ECMP @@ -350,6 +346,3 @@ def test_flow_policy_inline_object_rejected(self): """ with pytest.raises((jsonschema.ValidationError, ValueError)): Scenario.from_yaml(yaml_str) - - -# Run with: pytest tests/dsl/test_dsl_features_validation.py -v diff --git a/tests/dsl/test_examples.py b/tests/dsl/test_examples.py index bcc5671..e69c7fa 100644 --- a/tests/dsl/test_examples.py +++ b/tests/dsl/test_examples.py @@ -282,7 +282,7 @@ def test_demand_set_example(): """ scenario = Scenario.from_yaml(yaml_content) - default_demands = scenario.demand_set.get_default_set() + default_demands = scenario.demand_set.get_set("default") assert len(default_demands) == 1 demand = default_demands[0] assert demand.source == "source.*" @@ -351,16 +351,15 @@ def test_workflow_example(): assert len(scenario.workflow) == 1 assert scenario.workflow[0].__class__.__name__ == "BuildGraph" - # Test running the workflow scenario.run() - # Check that build_graph step was executed (default unique name assigned) + # The unnamed step gets a default unique name step_name = scenario.workflow[0].name exp = scenario.results.to_dict() assert exp["steps"][step_name]["data"].get("graph") is not None def test_node_overrides_example(): - """Test node overrides functionality.""" + """node_rules disable nodes matched by a path regex.""" yaml_content = """ blueprints: test_bp: @@ -384,7 +383,6 @@ def test_node_overrides_example(): scenario = Scenario.from_yaml(yaml_content) - # Check that switches 1 and 3 are disabled disabled_nodes = [ name for name, node in scenario.network.nodes.items() if node.disabled ] @@ -394,7 +392,7 @@ def test_node_overrides_example(): def test_link_overrides_example(): - """Test link overrides functionality.""" + """link_rules override capacity and cost on one link of a mesh.""" yaml_content = """ network: nodes: @@ -421,7 +419,6 @@ def test_link_overrides_example(): scenario = Scenario.from_yaml(yaml_content) - # Find the specific overridden link overridden_link = None for _link_id, link in scenario.network.links.items(): if link.source == "group1/node-1" and link.target == "group2/node-1": @@ -621,9 +618,8 @@ def test_group_by_selector_inside_blueprint(): """Test group_by selector in blueprint adjacency. When a blueprint adjacency uses a selector with group_by, nodes are - grouped by that attribute value regardless of path prefix. This test - ensures the expansion connects leaf->spine using attribute-based - selectors inside the blueprint. + grouped by that attribute value regardless of path prefix. The expansion + connects leaf->spine using attribute-based selectors inside the blueprint. """ yaml_content = """ blueprints: @@ -673,8 +669,7 @@ def test_group_by_selector_inside_blueprint(): def test_group_by_with_variable_expansion(): """Test group_by selector combined with variable expansion. - Validates that group_by selectors work correctly when the attribute - name is generated via variable expansion using $var syntax. + group_by selects by an attribute name generated via $var expansion. """ yaml_content = """ blueprints: diff --git a/tests/dsl/test_expand_review_fixes.py b/tests/dsl/test_expand_review_fixes.py index f763733..3d108e1 100644 --- a/tests/dsl/test_expand_review_fixes.py +++ b/tests/dsl/test_expand_review_fixes.py @@ -1,4 +1,4 @@ -"""Regression tests for DSL blueprint expansion review fixes. +"""Regression tests for DSL blueprint expansion. Covers: - Full variable substitution in link expand blocks (attrs, risk_groups, @@ -18,12 +18,8 @@ from ngraph.dsl.blueprints.expand import expand_network_dsl from ngraph.dsl.loader import load_scenario_yaml -from ngraph.dsl.selectors import flatten_link_attrs, flatten_node_attrs from ngraph.model.network import Link, Node - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Link expand block: full variable substitution -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from ngraph.model.selectors import flatten_link_attrs, flatten_node_attrs class TestLinkExpandFullSubstitution: @@ -197,11 +193,6 @@ def test_mesh_dedup_is_per_expansion_combination(self) -> None: assert pairs == [("dc1/gw", "dc2/gw"), ("dc2/gw", "dc1/gw")] -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Parent path regex escaping in blueprint links -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestBlueprintParentPathEscaping: """Literal parent paths are escaped before regex compilation.""" @@ -247,11 +238,6 @@ def test_plus_in_group_name_still_creates_links(self) -> None: assert link.target.startswith("agg+core/") -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Blueprint params override validation -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestBlueprintParamsValidation: """params override keys must address an existing blueprint subgroup.""" @@ -278,7 +264,7 @@ def test_key_without_field_part_raises(self) -> None: expand_network_dsl(data) def test_deep_dotted_nested_params_raises(self) -> None: - """'group.params.sub.field' no longer silently no-ops.""" + """'group.params.sub.field' raises instead of silently no-oping.""" data = { "blueprints": { "inner": {"nodes": {"spine": {"count": 2, "template": "s-{n}"}}}, @@ -390,11 +376,6 @@ def test_valid_override_with_bracket_pattern_group(self) -> None: assert len(plane_nodes) == 4 -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# link_rules source/target requirement -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestLinkRulesSourceTargetRequired: """link_rules entries must declare both source and target.""" @@ -425,11 +406,6 @@ def test_expansion_raises_value_error_not_key_error(self) -> None: expand_network_dsl(data) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Malformed rules sections raise -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestMalformedRulesSectionsRaise: """Non-list node_rules/link_rules raise instead of being ignored.""" @@ -460,11 +436,6 @@ def test_absent_rules_sections_are_noop(self) -> None: assert "A" in net.nodes -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Deterministic flattened risk_groups -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestFlattenedRiskGroupsSorted: """flatten_node_attrs/flatten_link_attrs expose sorted risk_groups.""" diff --git a/tests/dsl/test_expansion.py b/tests/dsl/test_expansion.py index 83e8a73..602e5b2 100644 --- a/tests/dsl/test_expansion.py +++ b/tests/dsl/test_expansion.py @@ -1,9 +1,5 @@ -"""Comprehensive tests for the variable expansion system. - -Tests for ngraph.dsl.expansion modules: -- ExpansionSpec: schema for expansion configuration -- substitute_vars: single template substitution -- expand_name_patterns: bracket expansion for names +"""Tests for ngraph.dsl.expansion: ExpansionSpec, substitute_vars, and bracket +expansion of group names and risk group references. """ import pytest @@ -15,10 +11,6 @@ substitute_vars, ) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# ExpansionSpec Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - class TestExpansionSpec: """Tests for ExpansionSpec dataclass.""" @@ -41,11 +33,6 @@ def test_custom_values(self) -> None: assert spec.mode == "zip" -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# substitute_vars Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestSubstituteVars: """Tests for substitute_vars function.""" @@ -113,11 +100,6 @@ def test_whole_string_placeholder_missing_raises(self) -> None: substitute_vars("${missing}", {"x": 1}) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# expand_name_patterns Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestExpandNamePatterns: """Tests for bracket expansion in group names.""" @@ -159,7 +141,7 @@ def test_brackets_at_end(self) -> None: assert expand_name_patterns("prefix[1-2]") == ["prefix1", "prefix2"] def test_adjacent_brackets(self) -> None: - """Adjacent brackets expand correctly.""" + """Adjacent brackets expand to their cartesian product.""" result = expand_name_patterns("[a,b][1-2]") assert sorted(result) == sorted(["a1", "a2", "b1", "b2"]) @@ -173,7 +155,7 @@ def test_single_value_list(self) -> None: class TestExpandNamePatternsEdgeCases: - """Edge case tests for bracket expansion validation.""" + """Bracket expansion error messages and boundary inputs.""" def test_alphabetic_range_raises_clear_error(self) -> None: """Alphabetic range [a-c] raises ValueError with helpful message.""" @@ -220,13 +202,12 @@ def test_mixed_valid_and_invalid_range(self) -> None: assert "a-c" in error_msg def test_alphabetic_list_still_works(self) -> None: - """Comma-separated alphabetic values still work correctly.""" - # This should work (list syntax, not range syntax) + """Comma-separated alphabetic values expand; only a-c ranges are rejected.""" result = expand_name_patterns("node[a,b,c]") assert result == ["nodea", "nodeb", "nodec"] def test_zero_start_range_works(self) -> None: - """Range starting at zero works correctly.""" + """Range starting at zero includes 0.""" result = expand_name_patterns("node[0-2]") assert result == ["node0", "node1", "node2"] @@ -236,11 +217,6 @@ def test_leading_zeros_preserved(self) -> None: assert result == ["port01", "port02", "port03"] -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# expand_risk_group_refs Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestExpandRiskGroupRefs: """Tests for bracket expansion in risk group reference lists.""" diff --git a/tests/dsl/test_native_substitution_guards.py b/tests/dsl/test_native_substitution_guards.py index e6fa430..094acc9 100644 --- a/tests/dsl/test_native_substitution_guards.py +++ b/tests/dsl/test_native_substitution_guards.py @@ -1,7 +1,7 @@ """Guards for non-string values leaking out of native-type variable substitution. Whole-string ``${var}`` placeholders substitute the variable's native type -(so match conditions compare correctly against numeric attrs). String-only +(so match conditions compare numerically against numeric attrs). String-only positions must reject non-string values with a clear ValueError instead of crashing later with a context-free TypeError. """ diff --git a/tests/dsl/test_parse_helpers.py b/tests/dsl/test_parse_helpers.py index b646433..dfe85f7 100644 --- a/tests/dsl/test_parse_helpers.py +++ b/tests/dsl/test_parse_helpers.py @@ -2,12 +2,9 @@ import pytest -from ngraph.dsl.blueprints.parser import ( - check_link_keys, - check_no_extra_keys, - expand_name_patterns, - join_paths, -) +from ngraph.dsl.blueprints.parser import check_link_keys, join_paths +from ngraph.dsl.expansion import expand_name_patterns +from ngraph.utils.yaml_utils import check_no_extra_keys def test_expand_name_patterns_no_brackets_returns_same() -> None: diff --git a/tests/dsl/test_risk_group_expansion.py b/tests/dsl/test_risk_group_expansion.py index aaa3903..00a6788 100644 --- a/tests/dsl/test_risk_group_expansion.py +++ b/tests/dsl/test_risk_group_expansion.py @@ -138,17 +138,15 @@ def test_parent_and_children_both_expand(self) -> None: - name: "Room_R[1,2]" """ scenario = Scenario.from_yaml(yaml_content) - # Should have Building_DC1 and Building_DC2 assert "Building_DC1" in scenario.network.risk_groups assert "Building_DC2" in scenario.network.risk_groups - # Each should have Room_R1 and Room_R2 children for dc_name in ["Building_DC1", "Building_DC2"]: dc = scenario.network.risk_groups[dc_name] child_names = {c.name for c in dc.children} assert child_names == {"Room_R1", "Room_R2"} def test_no_expansion_needed(self) -> None: - """Literal names work unchanged.""" + """Names without brackets pass through unchanged.""" yaml_content = """ network: nodes: @@ -312,7 +310,7 @@ def test_mixed_literal_and_pattern(self) -> None: } def test_empty_risk_groups_array(self) -> None: - """Empty risk_groups array works correctly.""" + """Empty risk_groups array yields an empty membership set.""" yaml_content = """ network: nodes: @@ -325,7 +323,7 @@ def test_empty_risk_groups_array(self) -> None: class TestRiskGroupExpansionEdgeCases: - """Edge cases and error handling for risk group expansion.""" + """Deduplication, inheritance and cross-references in risk group expansion.""" def test_overlapping_patterns_deduplicated(self) -> None: """Overlapping patterns in membership array are deduplicated.""" @@ -349,7 +347,7 @@ def test_overlapping_patterns_deduplicated(self) -> None: } def test_inherited_plus_own_risk_groups(self) -> None: - """Parent and child risk groups combine correctly via blueprint.""" + """Blueprint child nodes get their own plus the parent's risk groups.""" yaml_content = """ blueprints: rack: @@ -379,7 +377,7 @@ def test_inherited_plus_own_risk_groups(self) -> None: } def test_blueprint_risk_groups_expansion(self) -> None: - """Risk groups in blueprint nodes expand correctly.""" + """Bracket patterns in blueprint node risk_groups expand on every instance.""" yaml_content = """ blueprints: fabric: @@ -490,7 +488,7 @@ def test_mixed_string_and_dict_entries(self) -> None: ) def test_multiple_string_shorthands(self) -> None: - """Multiple string entries work correctly.""" + """Each string shorthand entry defines its own risk group.""" yaml_content = """ network: nodes: diff --git a/tests/dsl/test_rule_selectors.py b/tests/dsl/test_rule_selectors.py index 5d994e0..a4d7896 100644 --- a/tests/dsl/test_rule_selectors.py +++ b/tests/dsl/test_rule_selectors.py @@ -1,6 +1,6 @@ -"""Tests for enhanced rule selector support in link_rules and node_rules. +"""Tests for selector support in link_rules and node_rules. -Tests that: +Covers: - link_rules supports full selectors (path + match) for source/target - link_rules supports link_match for filtering by link attributes - node_rules supports match conditions for filtering by node attributes @@ -15,10 +15,6 @@ ) from ngraph.model.network import Link, Network, Node -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Fixtures -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @pytest.fixture def network_with_roles() -> Network: @@ -45,18 +41,13 @@ def network_with_roles() -> Network: return net -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# link_rules Full Selector Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestLinkRulesFullSelectors: """Tests for full selector support in link_rules source/target.""" def test_link_rules_string_selector_still_works( self, network_with_roles: Network ) -> None: - """String selectors in link_rules work as expected.""" + """String source/target regexes in link_rules select links by endpoint name.""" scenario = { "network": { "nodes": {}, @@ -71,7 +62,6 @@ def test_link_rules_string_selector_still_works( } } - # Apply rules to existing network _process_link_rules(network_with_roles, scenario["network"]) # Links from spine_1 should have updated capacity @@ -133,11 +123,6 @@ def test_link_rules_dict_selector_with_match( assert link.attrs.get("tagged") is True -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# link_match Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestLinkMatch: """Tests for link_match filtering in link_rules.""" @@ -198,7 +183,7 @@ def test_link_match_filters_by_cost(self, network_with_roles: Network) -> None: def test_link_match_combined_with_endpoint_selectors( self, network_with_roles: Network ) -> None: - """link_match works with endpoint selectors.""" + """link_match and endpoint selectors both filter the matched links.""" scenario = { "network": { "link_rules": [ @@ -230,11 +215,6 @@ def test_link_match_combined_with_endpoint_selectors( assert link.attrs.get("high_cap_spine1") is None -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# node_rules match Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestNodeRulesMatch: """Tests for match support in node_rules.""" @@ -268,7 +248,7 @@ def test_node_rules_match_filters_by_attribute( assert node.attrs.get("is_spine") is None def test_node_rules_match_with_tier(self, network_with_roles: Network) -> None: - """node_rules match works with numeric comparison.""" + """node_rules match compares numeric attributes.""" scenario = { "network": { "node_rules": [ @@ -320,16 +300,11 @@ def test_node_rules_path_and_match_combined( assert node.attrs.get("leaf_tier1") is None -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Integration: Full Network Expansion with Rules -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestRulesIntegration: """Integration tests for rules with full network expansion.""" def test_full_scenario_with_rules(self) -> None: - """Full scenario with node_rules and link_rules works end-to-end.""" + """Rules apply to nodes and links created in the same expansion.""" scenario = { "network": { "nodes": { @@ -365,12 +340,10 @@ def test_full_scenario_with_rules(self) -> None: net = expand_network_dsl(scenario) - # Verify spine nodes have critical attr for node in net.nodes.values(): if node.attrs.get("role") == "spine": assert node.attrs.get("critical") is True - # Verify high capacity links have the attr for link in net.links.values(): if link.capacity >= 100: assert link.attrs.get("high_capacity") is True diff --git a/tests/dsl/test_selectors.py b/tests/dsl/test_selectors.py index 891eb7d..8c4b0fb 100644 --- a/tests/dsl/test_selectors.py +++ b/tests/dsl/test_selectors.py @@ -1,32 +1,22 @@ -"""Comprehensive tests for the unified selector system. - -Tests for ngraph.dsl.selectors modules: -- normalize_selector: parsing and normalization -- select_nodes: node selection with all stages -- conditions: all condition operators -""" +"""Tests for the selector system in ngraph.dsl.selectors and ngraph.model.selectors.""" import pytest -from ngraph.dsl.selectors import ( +from ngraph.dsl.selectors import normalize_selector +from ngraph.model.network import Network, Node +from ngraph.model.selectors import ( Condition, MatchSpec, NodeSelector, evaluate_condition, evaluate_conditions, - normalize_selector, select_nodes, ) -from ngraph.model.network import Network, Node - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Fixtures -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @pytest.fixture def simple_network() -> Network: - """A simple network with 4 nodes for basic testing.""" + """Four nodes A-D without attributes.""" network = Network() for name in ["A", "B", "C", "D"]: network.add_node(Node(name)) @@ -35,7 +25,7 @@ def simple_network() -> Network: @pytest.fixture def attributed_network() -> Network: - """Network with nodes having various attributes for selector testing.""" + """Two datacenters with dc/role/tier attrs; dc2_leaf_2 is disabled.""" network = Network() # Datacenter 1: 2 leafs, 1 spine @@ -59,11 +49,6 @@ def attributed_network() -> Network: return network -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# NodeSelector Schema Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestNodeSelectorSchema: """Tests for NodeSelector dataclass validation.""" @@ -104,11 +89,6 @@ def test_no_fields_raises(self) -> None: NodeSelector() -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# normalize_selector Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestNormalizeSelector: """Tests for normalize_selector function.""" @@ -202,14 +182,14 @@ def test_workflow_context_active_only_true(self) -> None: sel = normalize_selector("^A$", "workflow") assert sel.active_only is True - def test_adjacency_context_active_only_false(self) -> None: - """Adjacency context defaults active_only to False.""" - sel = normalize_selector("^A$", "adjacency") + def test_link_context_active_only_false(self) -> None: + """Link context defaults active_only to False.""" + sel = normalize_selector("^A$", "link") assert sel.active_only is False - def test_override_context_active_only_false(self) -> None: - """Override context defaults active_only to False.""" - sel = normalize_selector("^A$", "override") + def test_rule_context_active_only_false(self) -> None: + """Rule context defaults active_only to False.""" + sel = normalize_selector("^A$", "rule") assert sel.active_only is False def test_explicit_active_only_overrides_default(self) -> None: @@ -218,11 +198,6 @@ def test_explicit_active_only_overrides_default(self) -> None: assert sel.active_only is False -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# select_nodes Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestSelectNodesByPath: """Tests for path-based node selection.""" @@ -372,7 +347,6 @@ def test_group_by_missing_attribute_excludes_nodes( def test_group_by_disabled_field(self, attributed_network: Network) -> None: """group_by can use top-level fields like disabled.""" - # Disable one node for testing attributed_network.nodes["dc1_leaf_1"].disabled = True sel = NodeSelector(path=".*", group_by="disabled") @@ -380,10 +354,8 @@ def test_group_by_disabled_field(self, attributed_network: Network) -> None: assert "True" in groups assert "False" in groups - # Verify disabled node is in the True group disabled_names = [n.name for n in groups["True"]] assert "dc1_leaf_1" in disabled_names - # Verify enabled nodes are in the False group enabled_names = [n.name for n in groups["False"]] assert len(enabled_names) > 0 assert "dc1_leaf_1" not in enabled_names @@ -418,11 +390,6 @@ def test_match_only_selects_all_then_filters( assert all(n.attrs["tier"] == 2 for n in all_nodes) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Condition Operators Tests -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - class TestConditionOperators: """Tests for all supported condition operators.""" @@ -590,3 +557,9 @@ def test_invalid_logic_raises(self) -> None: conds = [Condition("x", "==", 1)] with pytest.raises(ValueError, match="Unsupported logic"): evaluate_conditions({}, conds, "xor") + + +def test_selector_dict_rejects_unknown_keys() -> None: + """A misspelled selector key raises instead of being ignored.""" + with pytest.raises(ValueError, match="Unrecognized key.*selector: grop_by"): + normalize_selector({"path": "^A$", "grop_by": "role"}, "demand") diff --git a/tests/dsl/test_skill_examples_validation.py b/tests/dsl/test_skill_examples_validation.py index 36a2e35..dccc5dc 100644 --- a/tests/dsl/test_skill_examples_validation.py +++ b/tests/dsl/test_skill_examples_validation.py @@ -1,7 +1,6 @@ -"""Comprehensive validation of all 19 examples from EXAMPLES.md in the Claude skill. +"""Validation of the 19 examples in the Claude skill's EXAMPLES.md. -This test file validates that every example in the Claude skill documentation -parses correctly and produces the expected results. +Each example must parse and match the "Expected:" line in its docstring. """ import pytest @@ -9,9 +8,6 @@ from ngraph.scenario import Scenario -# ============================================================================= -# Example 1: Simple Data Center -# ============================================================================= def test_example_1_simple_data_center(): """Example 1: Simple Data Center - leaf-spine topology with traffic analysis. @@ -69,7 +65,6 @@ def test_example_1_simple_data_center(): f"Expected 8 links, got {len(scenario.network.links)}" ) - # Validate node names expected_nodes = [ "leaf/leaf1", "leaf/leaf2", @@ -81,19 +76,14 @@ def test_example_1_simple_data_center(): for name in expected_nodes: assert name in scenario.network.nodes, f"Missing node: {name}" - # Validate demands - demands = scenario.demand_set.get_default_set() + demands = scenario.demand_set.get_set("default") assert len(demands) == 1 - # Validate failure policy policy = scenario.failure_policy_set.get_policy("single_link") assert policy is not None assert len(policy.modes) == 1 -# ============================================================================= -# Example 2: Multi-Pod with Blueprint -# ============================================================================= def test_example_2_multi_pod_blueprint(): """Example 2: Multi-Pod with Blueprint - two pods sharing a blueprint. @@ -150,9 +140,6 @@ def test_example_2_multi_pod_blueprint(): ) -# ============================================================================= -# Example 3: Backbone with Risk Groups -# ============================================================================= def test_example_3_backbone_risk_groups(): """Example 3: Backbone with Risk Groups - WAN with shared-risk link groups. @@ -204,17 +191,14 @@ def test_example_3_backbone_risk_groups(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 3, ( f"Expected 3 nodes, got {len(scenario.network.nodes)}" ) - # Validate link count assert len(scenario.network.links) == 3, ( f"Expected 3 links, got {len(scenario.network.links)}" ) - # Validate risk groups assert len(scenario.network.risk_groups) == 2, ( f"Expected 2 risk groups, got {len(scenario.network.risk_groups)}" ) @@ -222,9 +206,6 @@ def test_example_3_backbone_risk_groups(): assert "RG_CHI_LA" in scenario.network.risk_groups -# ============================================================================= -# Example 4: Variable Expansion at Scale -# ============================================================================= def test_example_4_variable_expansion(): """Example 4: Variable Expansion at Scale - large fabric. @@ -274,9 +255,6 @@ def test_example_4_variable_expansion(): ) -# ============================================================================= -# Example 5: Full Mesh Topology -# ============================================================================= def test_example_5_full_mesh(): """Example 5: Full Mesh Topology - 4-node full mesh for testing. @@ -340,7 +318,6 @@ def test_example_5_full_mesh(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 4, ( f"Expected 4 nodes, got {len(scenario.network.nodes)}" ) @@ -350,13 +327,9 @@ def test_example_5_full_mesh(): f"Expected 6 links, got {len(scenario.network.links)}" ) - # Validate seed assert scenario.seed == 42 -# ============================================================================= -# Example 6: Attribute-Based Selectors -# ============================================================================= def test_example_6_attribute_selectors(): """Example 6: Attribute-Based Selectors - using match conditions. @@ -419,9 +392,6 @@ def test_example_6_attribute_selectors(): ) -# ============================================================================= -# Example 7: Blueprint with Parameter Overrides -# ============================================================================= def test_example_7_blueprint_params(): """Example 7: Blueprint with Parameter Overrides. @@ -446,7 +416,6 @@ def test_example_7_blueprint_params(): """ scenario = Scenario.from_yaml(yaml_content) - # Find the leaf node leaf_nodes = [n for n in scenario.network.nodes if "leaf" in n] assert len(leaf_nodes) == 1, f"Expected 1 leaf node, got {len(leaf_nodes)}" @@ -456,9 +425,6 @@ def test_example_7_blueprint_params(): ) -# ============================================================================= -# Example 8: Node and Link Rules -# ============================================================================= def test_example_8_node_link_rules(): """Example 8: Node and Link Rules - modifying topology after creation. @@ -505,7 +471,6 @@ def test_example_8_node_link_rules(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate switches 1 and 3 are disabled switch1 = scenario.network.nodes.get("my_clos1/switches/switch1") switch3 = scenario.network.nodes.get("my_clos1/switches/switch3") switch2 = scenario.network.nodes.get("my_clos1/switches/switch2") @@ -516,7 +481,6 @@ def test_example_8_node_link_rules(): "switch2 should not be disabled" ) - # Validate link rule applied upgraded_link = None for link in scenario.network.links.values(): if link.source == "group1/node1" and link.target == "group2/node1": @@ -530,9 +494,6 @@ def test_example_8_node_link_rules(): assert upgraded_link.cost == 5, f"Expected cost 5, got {upgraded_link.cost}" -# ============================================================================= -# Example 9: Complete Traffic Analysis -# ============================================================================= def test_example_9_traffic_analysis(): """Example 9: Complete Traffic Analysis - full workflow with MSD and placement. @@ -597,21 +558,15 @@ def test_example_9_traffic_analysis(): f"Expected 40 nodes, got {len(scenario.network.nodes)}" ) - # Validate workflow steps assert len(scenario.workflow) == 1 - # Run workflow scenario.run() - # Check stats were computed results = scenario.results.to_dict() assert "network_statistics" in results["steps"] assert results["steps"]["network_statistics"]["data"]["node_count"] == 40 -# ============================================================================= -# Example 10: Group-By Selectors -# ============================================================================= def test_example_10_group_by(): """Example 10: Group-By Selectors - grouping nodes by attribute. @@ -643,24 +598,18 @@ def test_example_10_group_by(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 4, ( f"Expected 4 nodes, got {len(scenario.network.nodes)}" ) - # Validate link count assert len(scenario.network.links) == 2, ( f"Expected 2 links, got {len(scenario.network.links)}" ) - # Validate demands demands = scenario.demand_set.get_set("inter_dc") assert len(demands) == 1 -# ============================================================================= -# Example 11: Advanced Failure Policies -# ============================================================================= def test_example_11_advanced_failures(): """Example 11: Advanced Failure Policies - weighted modes with conditions. @@ -749,30 +698,23 @@ def test_example_11_advanced_failures(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 5, ( f"Expected 5 nodes, got {len(scenario.network.nodes)}" ) - # Validate link count assert len(scenario.network.links) == 4, ( f"Expected 4 links, got {len(scenario.network.links)}" ) - # Validate risk groups assert len(scenario.network.risk_groups) == 3, ( f"Expected 3 risk groups, got {len(scenario.network.risk_groups)}" ) - # Validate failure policy modes policy = scenario.failure_policy_set.get_policy("mixed_failures") assert len(policy.modes) == 4, f"Expected 4 modes, got {len(policy.modes)}" assert policy.expand_groups is True -# ============================================================================= -# Example 12: Hardware Components and Cost Analysis -# ============================================================================= def test_example_12_hardware_components(): """Example 12: Hardware Components and Cost Analysis. @@ -858,16 +800,12 @@ def test_example_12_hardware_components(): f"Expected 16 links, got {len(scenario.network.links)}" ) - # Validate components library assert len(scenario.components_library.components) == 3 assert scenario.components_library.get("SpineRouter") is not None assert scenario.components_library.get("LeafRouter") is not None assert scenario.components_library.get("Optic400G") is not None -# ============================================================================= -# Example 13: YAML Anchors for Reuse -# ============================================================================= def test_example_13_yaml_anchors(): """Example 13: YAML Anchors for Reuse. @@ -927,9 +865,6 @@ def test_example_13_yaml_anchors(): assert spine_node.attrs["tier"] == 2 -# ============================================================================= -# Example 14: One-to-One Pattern and Zip Expansion -# ============================================================================= def test_example_14_one_to_one_zip(): """Example 14: One-to-One Pattern and Zip Expansion. @@ -972,13 +907,9 @@ def test_example_14_one_to_one_zip(): f"Expected 6 nodes, got {len(scenario.network.nodes)}" ) - # Validate links were created assert len(scenario.network.links) > 0, "Expected links to be created" -# ============================================================================= -# Example 15: Traffic Demands with Variable Expansion and Group Modes -# ============================================================================= def test_example_15_demand_variables(): """Example 15: Traffic Demands with Variable Expansion and Group Modes. @@ -1023,7 +954,6 @@ def test_example_15_demand_variables(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 5, ( f"Expected 5 nodes, got {len(scenario.network.nodes)}" ) @@ -1034,14 +964,10 @@ def test_example_15_demand_variables(): f"Expected 2 inter_dc demands, got {len(inter_dc_demands)}" ) - # Validate grouped demands grouped_demands = scenario.demand_set.get_set("grouped") assert len(grouped_demands) == 1 -# ============================================================================= -# Example 16: Hierarchical Risk Groups -# ============================================================================= def test_example_16_hierarchical_risk_groups(): """Example 16: Hierarchical Risk Groups - nested risk group structure. @@ -1098,7 +1024,6 @@ def test_example_16_hierarchical_risk_groups(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 4, ( f"Expected 4 nodes, got {len(scenario.network.nodes)}" ) @@ -1113,14 +1038,10 @@ def test_example_16_hierarchical_risk_groups(): rack1 = scenario.network.risk_groups["Rack1"] assert len(rack1.children) == 2, f"Expected 2 children, got {len(rack1.children)}" - # Validate failure policy policy = scenario.failure_policy_set.get_policy("hierarchical") assert policy.expand_groups is True -# ============================================================================= -# Example 17: Risk Group Membership Rules -# ============================================================================= def test_example_17_membership_rules(): """Example 17: Risk Group Membership Rules - dynamic assignment by attributes. @@ -1175,12 +1096,10 @@ def test_example_17_membership_rules(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 4, ( f"Expected 4 nodes, got {len(scenario.network.nodes)}" ) - # Validate risk groups assert "CoreTier3" in scenario.network.risk_groups assert "BackboneLinks" in scenario.network.risk_groups assert "ManualGroup1" in scenario.network.risk_groups @@ -1195,7 +1114,6 @@ def test_example_17_membership_rules(): f"core2 should have CoreTier3, has {core2.risk_groups}" ) - # Validate link membership backbone_link = None for link in scenario.network.links.values(): if link.source == "core1" and link.target == "core2": @@ -1208,9 +1126,6 @@ def test_example_17_membership_rules(): ) -# ============================================================================= -# Example 18: Generated Risk Groups -# ============================================================================= def test_example_18_generated_risk_groups(): """Example 18: Generated Risk Groups - create from unique attribute values. @@ -1258,7 +1173,6 @@ def test_example_18_generated_risk_groups(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 3, ( f"Expected 3 nodes, got {len(scenario.network.nodes)}" ) @@ -1285,9 +1199,6 @@ def test_example_18_generated_risk_groups(): ) -# ============================================================================= -# Example 19: Additional Selector Operators -# ============================================================================= def test_example_19_selector_operators(): """Example 19: Additional Selector Operators - all condition operators. @@ -1355,17 +1266,14 @@ def test_example_19_selector_operators(): """ scenario = Scenario.from_yaml(yaml_content) - # Validate node count assert len(scenario.network.nodes) == 4, ( f"Expected 4 nodes, got {len(scenario.network.nodes)}" ) - # Validate link count assert len(scenario.network.links) == 3, ( f"Expected 3 links, got {len(scenario.network.links)}" ) - # Validate demands were parsed demands = scenario.demand_set.get_set("filtered") assert len(demands) == 3, f"Expected 3 demands, got {len(demands)}" diff --git a/tests/explorer/test_explorer.py b/tests/explorer/test_explorer.py index 3e71b0d..039c7e3 100644 --- a/tests/explorer/test_explorer.py +++ b/tests/explorer/test_explorer.py @@ -30,8 +30,7 @@ def create_mock_components_library() -> ComponentsLibrary: @pytest.fixture def caplog_info_level(): """ - Pytest fixture to set the logger level to INFO and capture logs. - Ensures we can see warning messages in tests. + Set the 'explorer' logger to INFO for the test, then restore its level. """ logger = logging.getLogger("explorer") old_level = logger.level @@ -94,20 +93,17 @@ def test_explore_single_node_with_slashes(): assert len(ssw_node.children) == 1 leaf = ssw_node.children["ssw-1"] - # Check stats assert leaf.subtree_nodes == {"dc1/plane1/ssw/ssw-1"} assert leaf.stats.node_count == 1 def test_explore_network_with_links(): """ - Test a network with multiple nodes and links (internal + external), - verifying the stats are aggregated correctly. + Test a network with internal and external links: the built hierarchy and + the link and cost stats aggregated at root and at dc1. """ - # Setup network network = Network() - # Create some nodes # "dc1/plane1/ssw-1" and "dc1/plane1/ssw-2" share a common prefix, so they are in the same subtree network.nodes["dc1/plane1/ssw-1"] = Node( name="dc1/plane1/ssw-1", @@ -137,13 +133,11 @@ def test_explore_network_with_links(): capacity=200.0, ) - # Explore lib = create_mock_components_library() explorer = NetworkExplorer.explore_network(network, components_library=lib) root = explorer.root_node assert root is not None - # Validate that the hierarchy is built dc1_node = root.children.get("dc1") assert dc1_node is not None plane1_node = dc1_node.children.get("plane1") @@ -160,7 +154,6 @@ def test_explore_network_with_links(): ssw_3_node = plane2_node.children.get("ssw-3") assert ssw_3_node is not None - # Check aggregated stats for the root # By default, from the root's perspective, both links connect nodes in its subtree => both internal assert root.stats.node_count == 3 assert ( @@ -213,7 +206,7 @@ def test_unknown_hw_warnings(caplog, caplog_info_level): def test_print_tree_basic(capsys): """ - Basic test of print_tree output with skip_leaves=False, detailed=False. + print_tree with skip_leaves=False, detailed=False prints root and both nodes. """ network = Network() network.nodes["n1"] = Node(name="n1") diff --git a/tests/explorer/test_explorer_review_fixes.py b/tests/explorer/test_explorer_review_fixes.py index 743e051..fe4dab7 100644 --- a/tests/explorer/test_explorer_review_fixes.py +++ b/tests/explorer/test_explorer_review_fixes.py @@ -1,19 +1,17 @@ -"""Regression tests for NetworkExplorer review fixes. +"""Regression tests for NetworkExplorer. Covers: - get_bom_map include_root/root_label contract. -- get_node_utilization signature cleanup (no include_disabled, no disabled field). -- Node-utilization validation equivalence after the O(E) adjacency pre-pass. -- External link path attribution after hoisting path computation. +- get_node_utilization covers enabled nodes only. +- Node utilization and strict validation computed via the O(E) adjacency pre-pass. +- External link details name the opposite endpoint's full path. """ from __future__ import annotations -import dataclasses - import pytest -from ngraph.explorer import NetworkExplorer, NodeUtilization +from ngraph.explorer import NetworkExplorer from ngraph.model.components import Component, ComponentsLibrary from ngraph.model.network import Link, Network, Node @@ -77,19 +75,8 @@ def test_include_root_custom_label_not_duplicated(self) -> None: assert "" not in bom_map -class TestGetNodeUtilizationSignature: - """get_node_utilization takes no filter; snapshots cover enabled nodes only.""" - - def test_no_include_disabled_parameter(self) -> None: - explorer = NetworkExplorer.explore_network( - _network_with_hw(), components_library=_library() - ) - with pytest.raises(TypeError): - explorer.get_node_utilization(include_disabled=False) # type: ignore[call-arg] - - def test_disabled_field_removed(self) -> None: - field_names = {f.name for f in dataclasses.fields(NodeUtilization)} - assert "disabled" not in field_names +class TestGetNodeUtilizationEnabledOnly: + """Utilization snapshots cover enabled nodes only.""" def test_disabled_nodes_have_no_snapshot(self) -> None: net = _network_with_hw() @@ -100,7 +87,7 @@ def test_disabled_nodes_have_no_snapshot(self) -> None: class TestUtilizationAdjacencyPrePass: - """Utilization results must be identical after the O(E) adjacency index.""" + """Node utilization computed via the per-node adjacency index.""" def test_disabled_links_and_endpoints_excluded(self) -> None: net = Network() diff --git a/tests/integration/README.md b/tests/integration/README.md index 8467c17..782d690 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,419 +1,70 @@ -# NetGraph Integration Testing Framework +# Integration Tests -## Overview +End-to-end tests that load full scenarios, run their workflows, and check the resulting network, demands, failure policies and flows. -This directory contains integration testing utilities for NetGraph scenarios. The framework provides modular utilities for validating network topologies, blueprint expansions, failure policies, traffic demands, and flow results. +## Files -## Architecture +| File | Contents | +|------|----------| +| `scenario_1.yaml` .. `scenario_4.yaml` | The reference scenarios below | +| `test_scenario_1.py` .. `test_scenario_4.py` | One test class per scenario | +| `expectations.py` | Expected node and edge counts and blueprint expansions per scenario (`SCENARIO_*_EXPECTATIONS`) | +| `helpers.py` | `ScenarioTestHelper` (validation methods), `NetworkExpectations`, `ScenarioDataBuilder`, `load_scenario_from_file`, `create_scenario_helper` | +| `test_data_templates.py` | Topology, blueprint, failure, demand and workflow templates, and `ScenarioTemplateBuilder` | +| `test_template_examples.py` | Tests that exercise every template | +| `test_error_cases.py` | Invalid scenarios that must fail to load or run | +| `test_schema_modes.py` | Schema validation of failure-policy `modes` and `weight_by` | -### Core Components +## Scenarios -#### 1. **helpers.py** - Core Testing Utilities +| Scenario | Nodes | Links | Exercises | +|----------|-------|-------|-----------| +| 1: L3 backbone | 6 | 10 | Explicit nodes and links, demands, single-link failures | +| 2: Hierarchical DSL | 19 | 56 | Blueprints with `params` overrides, mesh patterns, nesting four levels deep | +| 3: 3-tier Clos | 64 | 144 | Nested blueprints, `one_to_one` wiring, node and link rules, risk groups, MaxFlow steps | +| 4: Data center fabric | 80 | 92 | Components and hardware, variable expansion, node and link rules, risk groups, a disabled rack | -- **ScenarioTestHelper**: Main validation class with modular test methods -- **NetworkExpectations**: Structured expectations for network validation -- **ScenarioDataBuilder**: Builder pattern for programmatic scenario creation -- **ScenarioValidationConfig**: Configuration for selective validation control +Edge counts in the expectations are twice the link counts, because the exported graph holds a forward and a reverse edge per link. -#### 2. **expectations.py** - Test Expectations Data - -- **SCENARIO_*_EXPECTATIONS**: Predefined expectations for each test scenario -- **Validation constants**: Reusable constants for consistent validation -- **Helper functions**: Calculations for topology expectations - -#### 3. **test_data_templates.py** - Composable Templates - -- **NetworkTemplates**: Common topology patterns (linear, star, mesh, ring, tree) -- **BlueprintTemplates**: Reusable blueprint patterns for hierarchies -- **FailurePolicyTemplates**: Standard failure scenario configurations -- **TrafficDemandTemplates**: Traffic demand patterns and distributions -- **WorkflowTemplates**: Common analysis workflow configurations -- **ScenarioTemplateBuilder**: High-level builder for complete scenarios -- **CommonScenarios**: Pre-built scenarios for typical use cases - -### Test Scenarios - -#### Scenario 1: Basic L3 Backbone Network - -- **Tests**: Network parsing, link definitions, traffic matrices, single failure policies -- **Scale**: 6 nodes, 10 links, 4 traffic demands -- **Requirements**: Basic YAML parsing, graph construction - -#### Scenario 2: Hierarchical DSL with Blueprints - -- **Tests**: Blueprint expansion, parameter overrides, mesh patterns, hierarchical naming -- **Scale**: 15+ nodes from blueprint expansion, nested hierarchies 3 levels deep -- **Requirements**: Blueprint system, DSL parsing, mesh connectivity algorithms - -#### Scenario 3: 3-tier Clos Network - -- **Tests**: Deep blueprint nesting, capacity probing, node/link overrides, flow analysis -- **Scale**: 20+ nodes, 3-tier hierarchy, regex pattern matching -- **Requirements**: Clos topology knowledge, capacity probe workflow, override systems - -#### Scenario 4: Data Center Network - -- **Tests**: Variable expansion, component system, multi-tier hierarchies, workflow transforms -- **Scale**: 80+ nodes, 4+ hierarchy levels, multiple data centers -- **Requirements**: Component library, variable expansion, workflow transforms - -### Dual Testing Approach - -Each scenario uses two test patterns: - -#### 1. **Class-based Tests** (`TestScenarioX`) - -- **Detailed validation**: Tests network structure, blueprint expansions, traffic matrices, flow results -- **Modular structure**: Each test method focuses on specific functionality -- **Fixtures**: Shared scenario setup and graph construction -- **Examples**: `test_network_structure_validation()`, `test_blueprint_expansion_validation()` - -#### 2. **Smoke Tests** (`test_scenario_X_build_graph`) - -- **Basic validation**: Verifies scenario parsing and execution without errors -- **Fast execution**: Minimal overhead for CI/CD pipelines -- **Baseline checks**: Ensures scenarios load and run successfully -- **Error detection**: Catches parsing failures and execution errors - -**When to use each approach:** - -- **Smoke tests**: Quick validation and CI checks -- **Class-based tests**: Detailed validation and debugging - -## Key Features - -### Modular Validation - -```python -helper = ScenarioTestHelper(scenario) -helper.set_graph(built_graph) -helper.validate_network_structure(expectations) -helper.validate_topology_semantics() -helper.validate_flow_results("step_name", "flow_label", expected_value) -``` - -### Structured Expectations +## Writing a scenario test ```python -SCENARIO_1_EXPECTATIONS = NetworkExpectations( - node_count=6, - edge_count=20, # 10 physical links * 2 directed edges - specific_nodes={"SEA", "SFO", "DEN", "DFW", "JFK", "DCA"}, - blueprint_expansions={}, # No blueprints in scenario 1 -) -``` - -### Template-based Scenario Creation - -```python -scenario = (ScenarioTemplateBuilder("test_network", "1.0") - .with_linear_backbone(["A", "B", "C"], link_capacity=100.0) - .with_uniform_traffic(["A", "C"], demand_value=50.0) - .with_single_link_failures() - .with_capacity_analysis("A", "C") - .build()) -``` - -### Error Validation +from .expectations import SCENARIO_1_EXPECTATIONS +from .helpers import create_scenario_helper, load_scenario_from_file -- Malformed YAML handling -- Blueprint reference validation -- Traffic demand correctness -- Failure policy configuration -- Edge case coverage - -## Best Practices - -### Test Organization - -1. Use fixtures for common scenario setups -2. Validate incrementally from basic structure to flows -3. Group related tests in focused test classes -4. Provide clear error messages with context - -### Validation Approach - -1. Start with structural validation (node/edge counts) -2. Verify specific elements (expected nodes/links) -3. Check semantic correctness (topology properties) -4. Validate business logic (flow results, policies) - -### Template Usage - -1. Prefer templates over manual scenario construction -2. Compose templates for scenarios -3. Use constants for configuration values -4. Document template parameters clearly - -## Code Quality Standards - -### Documentation - -- Module and class docstrings -- Parameter and return value documentation -- Usage examples in docstrings -- Clear error message context - -### Type Safety - -- Type annotations for all functions -- Optional parameter handling -- Generic type usage where appropriate -- Union types for flexible interfaces - -### Error Handling - -- Descriptive error messages with context -- Input validation with clear feedback -- Graceful handling of edge cases -- Appropriate exception types - -### Maintainability - -- Constants for magic numbers -- Modular, focused methods -- Consistent naming conventions -- Separated concerns (validation vs data creation) - -## Usage Examples - -### Basic Scenario Validation - -```python -def test_my_scenario(): - scenario = load_scenario_from_file("my_scenario.yaml") - scenario.run() - - helper = create_scenario_helper(scenario) - graph = scenario.results.get("build_graph", "graph") - helper.set_graph(graph) - - # Validate structure - expectations = NetworkExpectations(node_count=5, edge_count=8) - helper.validate_network_structure(expectations) - - # Validate semantics - helper.validate_topology_semantics() -``` - -### Custom Scenario Building - -```python -def test_custom_topology(): - builder = ScenarioDataBuilder() - scenario = (builder - .with_simple_nodes(["Hub", "Spoke1", "Spoke2"]) - .with_simple_links([("Hub", "Spoke1", 10), ("Hub", "Spoke2", 10)]) - .with_traffic_demand("Spoke1", "Spoke2", 5.0) - .with_workflow_step("BuildGraph", "build_graph") - .build_scenario()) - - scenario.run() - # ... validation ... -``` - -### Blueprint Testing - -```python -def test_blueprint_expansion(): - helper = create_scenario_helper(scenario) - helper.set_graph(built_graph) - - # Validate blueprint created expected nodes - helper.validate_blueprint_expansions(NetworkExpectations( - blueprint_expansions={ - "datacenter_east/spine/": 4, - "datacenter_east/leaf/": 8, - } - )) -``` +scenario = load_scenario_from_file("scenario_1.yaml") +scenario.run() -## Architecture Details - -### File Organization - -- `expectations.py`: Test expectations and validation constants -- `helpers.py`: Core validation utilities and test helpers -- `test_data_templates.py`: Template builders for programmatic scenario creation -- `test_scenario_*.py`: Integration tests for specific scenarios - -### Validation Constants - -- Node count thresholds for topology validation -- Link capacity ranges for flow analysis -- Traffic demand bounds for matrix validation -- Timeout values for workflow execution - -### Template System - -- `ScenarioDataBuilder`: Programmatic scenario construction -- `NetworkTemplates`: Common topology patterns (star, mesh, tree) -- `ErrorInjectionTemplates`: Invalid configuration builders -- Network size limits to prevent test timeout - -## Contributing - -When adding new test scenarios or validation methods: - -1. Follow naming conventions established in existing code -2. Add documentation with usage examples -3. Include type annotations for all new functions -4. Write focused, modular tests that can be easily understood -5. Update expectations in the dedicated expectations.py file -6. Add templates for reusable patterns - -## Testing - -Run all integration tests: - -```bash -pytest tests/integration/ -v -``` - -Run specific scenario tests: - -```bash -pytest tests/integration/test_scenario_1.py -v -``` - -Run template examples: - -```bash -pytest tests/integration/test_template_examples.py -v -``` - -Run integration tests by directory: - -```bash -pytest tests/integration/ -v -``` - -## Template Usage Guidelines - -### Consistent Template Usage Strategy - -The integration tests framework follows a **hybrid approach** for template usage: - -#### 1. **Main Scenario Tests** (test_scenario_*.py) - -- **Primary**: Use `load_scenario_from_file()` with static YAML files -- **Rationale**: These serve as integration references and demonstrate real-world usage -- **Template Variants**: Also include template-based variants for testing different configurations - -#### 2. **Error Case Tests** (test_error_cases.py) - -- **Primary**: Use `ScenarioDataBuilder` and template builders consistently -- **Rationale**: Easier to create invalid configurations programmatically -- **Raw YAML**: Only for syntax errors that builders cannot create - -#### 3. **Template Examples** (test_template_examples.py) - -- **Primary**: Full template system usage with all template classes -- **Rationale**: Demonstrates template capabilities and validates template system - -### Template Selection Guide - -| Test Type | Recommended Approach | Example | -| ----------- | --------------------- | --------- | -| Basic Integration | YAML files + template variants | `test_scenario_1.py` | -| Error Cases | Template builders | `ErrorInjectionTemplates.missing_nodes_builder()` | -| Edge Cases | Template builders | `EdgeCaseTemplates.empty_network_builder()` | -| Performance Tests | Template builders | `PerformanceTestTemplates.large_star_network_builder()` | -| Parameterized Tests | Template builders | `ScenarioTemplateBuilder` with loops | - -### Template Builder Categories - -#### **ErrorInjectionTemplates** - -```python -# For testing invalid configurations -builder = ErrorInjectionTemplates.circular_blueprint_builder() -scenario = builder.build_scenario() -with pytest.raises((ValueError, RecursionError)): - scenario.run() +helper = create_scenario_helper(scenario) # attaches the graph exported by BuildGraph +helper.validate_network_structure(SCENARIO_1_EXPECTATIONS) +helper.validate_topology_semantics() ``` -#### **EdgeCaseTemplates** +`create_scenario_helper` reads the graph from the step named `build_graph`, so the scenario's workflow needs a `BuildGraph` step with that name. -```python -# For boundary conditions and edge cases -builder = EdgeCaseTemplates.zero_capacity_links_builder() -scenario = builder.build_scenario() -scenario.run() # Should handle gracefully -``` +## Which tool to use -#### **PerformanceTestTemplates** +- **Scenario tests** load the static YAML files with `load_scenario_from_file()`; they are the integration references. +- **Error case tests** build invalid configurations with `ScenarioDataBuilder`, and use raw YAML only for syntax errors a builder cannot produce. +- **Template examples** exercise the template classes in `test_data_templates.py`. ```python -# For stress testing and performance validation -builder = PerformanceTestTemplates.large_star_network_builder(leaf_count=500) -scenario = builder.build_scenario() -scenario.run() # Performance test -``` - -#### **ScenarioTemplateBuilder** +from .test_data_templates import ScenarioTemplateBuilder -```python -# For high-level scenario composition -scenario_yaml = (ScenarioTemplateBuilder("test", "1.0") +scenario_yaml = ( + ScenarioTemplateBuilder("test", "1.0") .with_linear_backbone(["A", "B", "C"]) .with_uniform_traffic(["A", "C"], 25.0) .with_single_link_failures() - .build()) + .build() +) ``` -### Template Selection Best Practices +Add a template only when a test uses it; templates without callers are removed. -#### **DO: Use Templates For** +## Running -- โœ… Error case testing with invalid configurations -- โœ… Parameterized tests with different scales -- โœ… Edge case and boundary condition testing -- โœ… Performance and stress testing -- โœ… Rapid prototyping of test scenarios - -#### **DON'T: Use Templates For** - -- โŒ Replacing existing YAML-based integration tests -- โŒ Simple one-off tests where YAML is clearer -- โŒ Tests that need exact YAML syntax validation - -#### **Template Composition** - -```python -# Combine multiple template categories -def test_complex_error_scenario(): - builder = ErrorInjectionTemplates.negative_demand_builder() - # Add additional edge case conditions - builder.data["network"]["links"].extend( - EdgeCaseTemplates.zero_capacity_links_builder().data["network"]["links"] - ) - scenario = builder.build_scenario() - # Test error handling with multiple conditions -``` - -#### **Consistent Error Testing** - -```python -# Standard pattern for error case tests -def test_missing_blueprint(): - builder = ErrorInjectionTemplates.circular_blueprint_builder() - with pytest.raises((ValueError, RecursionError)): - scenario = builder.build_scenario() - scenario.run() +```bash +pytest tests/integration/ -v +pytest tests/integration/test_scenario_1.py -v ``` - -### Template Development - -#### **Adding New Templates** - -1. Choose appropriate template class (Error/EdgeCase/Performance) -2. Follow existing naming conventions (`*_builder()` methods) -3. Return `ScenarioDataBuilder` instances for consistency -4. Add docstrings with usage examples - -#### **Template Testing** - -- Each template should have validation tests -- Test both successful scenario building and execution -- Verify template produces expected network structures diff --git a/tests/integration/expectations.py b/tests/integration/expectations.py index 9f481b1..289a038 100644 --- a/tests/integration/expectations.py +++ b/tests/integration/expectations.py @@ -1,18 +1,14 @@ """ Test expectations for NetGraph integration test scenarios. -This module defines the expected network characteristics for each test scenario, -including node counts, edge counts, and specific network properties. These -expectations are used by the validation helpers to verify that scenarios -produce the correct network topologies. - -The expectations are carefully calculated based on the scenario YAML definitions -and the NetGraph blueprint expansion rules. +Expected node counts, edge counts and named elements for each scenario, +derived from the scenario YAML and blueprint expansion rules. The validation +helpers compare built graphs against these values. """ from .helpers import NetworkExpectations -# Validation constants for consistency across tests +# Physical link counts per scenario DEFAULT_BIDIRECTIONAL_MULTIPLIER = 2 # NetGraph creates bidirectional edges SCENARIO_1_PHYSICAL_LINKS = 10 # Count from scenario_1.yaml SCENARIO_2_PHYSICAL_LINKS = 56 # Count from scenario_2.yaml blueprint expansions @@ -106,28 +102,8 @@ def _calculate_scenario_3_total_nodes() -> int: }, ) -# Validation helper constants for flow result expectations -SCENARIO_3_FLOW_EXPECTATIONS = { - "proportional_flow": 3200.0, # Expected max flow with PROPORTIONAL placement (400 Gb/s * 8 paths) - "equal_balanced_flow": 3200.0, # Expected max flow with EQUAL_BALANCED placement (400 Gb/s * 8 paths) -} - -# Traffic demand expectations by scenario -TRAFFIC_DEMAND_EXPECTATIONS = { - "scenario_1": 4, # 4 explicit traffic demands - "scenario_2": 4, # Same traffic demands as scenario 1 - "scenario_3": 0, # No traffic demands (capacity probe only) -} - -# Failure policy expectations by scenario -FAILURE_POLICY_EXPECTATIONS = { - "scenario_1": {"rules": 1, "scopes": ["link"]}, - "scenario_2": {"rules": 1, "scopes": ["link"]}, - "scenario_3": {"rules": 0, "scopes": []}, # No failure policy -} # Scenario 4: Advanced DSL features with complex data center fabric -# This scenario is the most complex, testing all advanced DSL features SCENARIO_4_NODE_BREAKDOWN = { "racks_per_pod": 2, # rack1-rack2 (2 racks per pod) "pods_per_dc": 2, # poda, podb @@ -171,26 +147,22 @@ def _calculate_scenario_4_total_links() -> int: """ Calculate total directed edges for scenario 4. - BuildGraph now adds bidirectional edges for each link in the network. - The scenario has 84 physical links, which results in 168 directed edges - (84 forward + 84 reverse). + BuildGraph adds a forward and a reverse edge for each link. The scenario + has 92 physical links: 64 server-to-ToR, 16 ToR-to-leaf, 8 leaf-to-spine + and 4 inter-DC spine links. Returns: Total directed edge count. """ - # Scenario 4 has 84 physical links - # BuildGraph adds reverse edges, so total edges = links * 2 - physical_links = 84 - return physical_links * DEFAULT_BIDIRECTIONAL_MULTIPLIER # 84 * 2 = 168 + physical_links = 92 + return physical_links * DEFAULT_BIDIRECTIONAL_MULTIPLIER -# Main expectation structure for scenario 4 SCENARIO_4_EXPECTATIONS = NetworkExpectations( - count=_calculate_scenario_4_total_nodes(), # Total nodes after disabled rack - edge_count=_calculate_scenario_4_total_links(), # Actual observed link count + count=_calculate_scenario_4_total_nodes(), # Includes the disabled rack + edge_count=_calculate_scenario_4_total_links(), # 92 links * 2 directions specific_nodes=set(), # All nodes generated from blueprints and expansion blueprint_expansions={ - # Each expanded rack should have expected components "dc1_poda_rack01/": 9, # 1 tor + 8 servers per rack "dc1_poda_rack02/": 9, "dc2_fabric/leaf/": 2, # 2 leaf switches per DC fabric @@ -198,7 +170,6 @@ def _calculate_scenario_4_total_links() -> int: }, ) -# Component expectations for scenario 4 SCENARIO_4_COMPONENT_EXPECTATIONS = { "total_components": 3, # ToRSwitch48p, SpineSwitch32p, ServerNode "tor_switches": "ToRSwitch48p", @@ -206,7 +177,6 @@ def _calculate_scenario_4_total_links() -> int: "servers": "ServerNode", } -# Risk group expectations for scenario 4 # Uses fiber/facility domain model for risk groups # Note: Only top-level risk groups are listed; children are nested inside parents SCENARIO_4_RISK_GROUP_EXPECTATIONS = { @@ -224,24 +194,14 @@ def _calculate_scenario_4_total_links() -> int: "hierarchical_groups": True, # Has nested risk group structure } -# Traffic matrix expectations for scenario 4 SCENARIO_4_TRAFFIC_EXPECTATIONS = { "default_matrix": 2, # 2 traffic demands in default matrix "hpc_workload_matrix": 1, # 1 HPC traffic demand "total_matrices": 2, # default + hpc_workload } -# Failure policy expectations for scenario 4 SCENARIO_4_FAILURE_POLICY_EXPECTATIONS = { "total_policies": 3, # single_link_failure, single_node_failure, default - "risk_group_policies": 0, # None use risk groups anymore - "conditional_policies": 0, # None use conditions anymore -} - -# Workflow expectations for scenario 4 -SCENARIO_4_WORKFLOW_EXPECTATIONS = { - "wan_locations": 2, # 2 WAN locations for test efficiency - "capacity_envelope_iterations": [10, 20], # Iteration counts for analysis steps - "enabled_nodes_count": 10, # Number of nodes to enable - "parallelism": 2, # Parallel processing degree + "risk_group_policies": 0, # No policy uses risk groups + "conditional_policies": 0, # No policy uses conditions } diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index c9bc3ee..5f97c92 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,43 +1,25 @@ """ -Test helpers for scenario-based integration testing. - -This module provides reusable utilities for validating NetGraph scenarios, -creating test data, and performing semantic correctness checks on network -topologies and flow results. - -Key Components: -- NetworkExpectations: Structured expectations for network validation -- ScenarioTestHelper: Main validation class with modular test methods -- ScenarioDataBuilder: Builder pattern for programmatic scenario creation -- Utility functions: File loading, helper creation, and pytest fixtures - -The validation approach emphasizes: -- Modular, focused validation methods -- Clear error messages with context -- Semantic correctness beyond simple counts -- Reusable patterns for common test scenarios +Helpers for scenario-based integration tests. + +- NetworkExpectations: expected node/edge counts and named elements +- ScenarioTestHelper: validation methods over a scenario and its built graph +- ScenarioDataBuilder: programmatic construction of scenario YAML +- load_scenario_from_file, create_scenario_helper: loading and setup """ from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple -import pytest - from ngraph.scenario import Scenario -# Validation constants for test consistency -DEFAULT_FLOW_TOLERANCE = 1e-9 # Default tolerance for flow value comparisons -MIN_CONNECTIVITY_COMPONENTS = ( - 1 # Expected minimum connected components for valid networks -) +# Validation constants DEFAULT_LINK_COST = 1 # Default cost value for validation MIN_CAPACITY_VALUE = 0.0 # Minimum valid capacity (inclusive) MIN_COST_VALUE = 0.0 # Minimum valid cost (inclusive) # Network validation thresholds MAX_EXPECTED_COMPONENTS_WARNING = 1 # Warn if more than this many components -LARGE_NETWORK_NODE_THRESHOLD = 1000 # Threshold for "large network" optimizations @dataclass @@ -45,10 +27,6 @@ class NetworkExpectations: """ Expected characteristics of a network after scenario processing. - This dataclass encapsulates all the expected properties that should be - validated after a scenario runs, including structural properties, - specific network elements, and blueprint expansion results. - Attributes: count: Expected total number of nodes in the final network edge_count: Expected total number of directed edges (links * 2 for bidirectional) @@ -64,7 +42,7 @@ class NetworkExpectations: blueprint_expansions: Optional[Dict[str, int]] = None def __post_init__(self) -> None: - """Initialize default values for optional fields to prevent None access.""" + """Replace None optional fields with empty containers.""" if self.specific_nodes is None: self.specific_nodes = set() if self.specific_links is None: @@ -73,38 +51,9 @@ def __post_init__(self) -> None: self.blueprint_expansions = {} -@dataclass -class ScenarioValidationConfig: - """ - Configuration options for controlling scenario validation behavior. - - This allows tests to selectively enable/disable different types of validation - based on the specific requirements of each test scenario. - - Attributes: - validate_topology: Whether to perform basic topology validation - validate_flows: Whether to validate flow calculation results - validate_attributes: Whether to check node/link attribute correctness - validate_semantics: Whether to perform deep semantic validation - check_risk_groups: Whether to validate risk group assignments - check_disabled_elements: Whether to check for disabled nodes/links - """ - - validate_topology: bool = True - validate_flows: bool = True - validate_attributes: bool = True - validate_semantics: bool = True - check_risk_groups: bool = True - check_disabled_elements: bool = True - - class ScenarioTestHelper: """ - Helper class for scenario testing with modular validation utilities. - - This class provides a high-level interface for validating NetGraph scenarios, - encapsulating common validation patterns and providing clear error messages. - It follows the builder pattern for configurable validation. + Validation methods for a scenario and its built graph. Usage: helper = ScenarioTestHelper(scenario) @@ -137,9 +86,7 @@ def validate_network_structure(self, expectations: NetworkExpectations) -> None: """ Validate that basic network structure matches expectations. - Performs fundamental structural validation including node count, edge count, - and presence of specific network elements. This is typically the first - validation performed after scenario execution. + Checks node count, edge count, and the presence of specific nodes and links. Args: expectations: Expected network characteristics to validate against @@ -150,7 +97,6 @@ def validate_network_structure(self, expectations: NetworkExpectations) -> None: if self.graph is None: raise ValueError("Graph must be set before validation using set_graph()") - # Validate node count with detailed context actual_nodes = len(self.graph.nodes) assert actual_nodes == expectations.count, ( f"Network node count mismatch: expected {expectations.count}, " @@ -158,7 +104,6 @@ def validate_network_structure(self, expectations: NetworkExpectations) -> None: f"Graph nodes: {sorted(list(self.graph.nodes)[:10])}{'...' if actual_nodes > 10 else ''}" ) - # Validate edge count with bidirectional context actual_edges = len(self.graph.edges) assert actual_edges == expectations.edge_count, ( f"Network edge count mismatch: expected {expectations.edge_count}, " @@ -166,10 +111,7 @@ def validate_network_structure(self, expectations: NetworkExpectations) -> None: f"Note: NetGraph typically creates bidirectional edges (physical_links * 2)" ) - # Validate presence of specific nodes self._validate_specific_nodes(expectations.specific_nodes) - - # Validate presence of specific links self._validate_specific_links(expectations.specific_links) def _validate_specific_nodes(self, expected_nodes: Optional[Set[str]]) -> None: @@ -204,8 +146,7 @@ def validate_blueprint_expansions(self, expectations: NetworkExpectations) -> No """ Validate that blueprint expansions created expected node counts. - This method checks that NetGraph's blueprint expansion mechanism - produced the correct number of nodes for each blueprint pattern. + Counts the nodes whose name starts with each blueprint path. Args: expectations: Network expectations containing blueprint expansion counts @@ -217,7 +158,6 @@ def validate_blueprint_expansions(self, expectations: NetworkExpectations) -> No return for blueprint_path, expected_count in expectations.blueprint_expansions.items(): - # Find all nodes matching the blueprint path pattern matching_nodes = [ node for node in self.network.nodes if node.startswith(blueprint_path) ] @@ -239,7 +179,7 @@ def validate_traffic_demands(self, expected_count: int) -> None: Raises: AssertionError: If traffic demand count doesn't match expectations """ - default_demands = self.scenario.demand_set.get_default_set() + default_demands = self.scenario.demand_set.get_all_demands() actual_count = len(default_demands) assert actual_count == expected_count, ( @@ -274,13 +214,12 @@ def validate_failure_policy( ) return - # Policy exists - validate rule count (modes-based API) + # Count rules across all modes actual_rules = sum(len(mode.rules) for mode in getattr(policy, "modes", [])) assert actual_rules == expected_rules, ( f"Failure policy rule count mismatch: expected {expected_rules}, found {actual_rules}" ) - # Validate rule scopes if specified if expected_scopes: actual_scopes = [ rule.scope @@ -361,51 +300,16 @@ def validate_link_attributes( f"expected {expected_value}, found {actual_value}" ) - def validate_flow_results( - self, - step_name: str, - flow_label: str, - expected_flow: float, - tolerance: float = DEFAULT_FLOW_TOLERANCE, - ) -> None: - """ - Validate flow calculation results. - - Args: - step_name: Name of the workflow step that produced the flow - flow_label: Label identifying the specific flow result - expected_flow: Expected flow value - tolerance: Numerical tolerance for flow comparison - - Raises: - AssertionError: If flow results don't match expectations within tolerance - """ - exported = self.scenario.results.to_dict() - step_data = exported.get("steps", {}).get(step_name, {}).get("data", {}) - actual_flow = step_data.get(flow_label) - assert actual_flow is not None, ( - f"Flow result '{flow_label}' not found for step '{step_name}'" - ) - - flow_difference = abs(actual_flow - expected_flow) - assert flow_difference <= tolerance, ( - f"Flow value mismatch for '{flow_label}': " - f"expected {expected_flow}, found {actual_flow} " - f"(difference: {flow_difference}, tolerance: {tolerance})" - ) - def validate_topology_semantics(self) -> None: """ - Validate semantic correctness of network topology. + Check edge attributes and report topology warnings. - Performs deep validation of network properties including: - - Edge attribute validity (non-negative capacity/cost) - - Self-loop detection and reporting - - Basic connectivity analysis - - Structural consistency checks + Asserts that every edge has non-negative capacity and cost. Self-loops + and multiple weakly connected components are printed as warnings, not + failures. Raises: - AssertionError: If semantic validation fails + AssertionError: If an edge has negative capacity or cost """ if self.graph is None: raise ValueError("Graph must be set before topology validation") @@ -420,19 +324,16 @@ def validate_topology_semantics(self) -> None: if len(self.graph.nodes) > 1: self._validate_network_connectivity() - # Validate edge attributes for semantic correctness self._validate_edge_attributes() def _validate_network_connectivity(self) -> None: - """Validate network connectivity properties.""" + """Print a warning when the graph is not weakly connected.""" import networkx as nx - # Ensure graph is available for connectivity checks assert self.graph is not None, ( "Graph must be set before connectivity validation" ) - # Check weak connectivity for directed graphs is_connected = nx.is_weakly_connected(self.graph) if not is_connected: components = list(nx.weakly_connected_components(self.graph)) @@ -443,8 +344,7 @@ def _validate_network_connectivity(self) -> None: ) def _validate_edge_attributes(self) -> None: - """Validate edge attributes for semantic correctness.""" - # Ensure graph is available for edge validation + """Assert non-negative capacity and cost on every edge.""" assert self.graph is not None, ( "Graph must be set before edge attribute validation" ) @@ -455,13 +355,11 @@ def _validate_edge_attributes(self) -> None: capacity = data.get("capacity", 0) cost = data.get("cost", 0) - # Check for invalid capacity values if capacity < MIN_CAPACITY_VALUE: invalid_edges.append( f"Edge ({u}, {v}, {key}) has invalid capacity: {capacity}" ) - # Check for invalid cost values if cost < MIN_COST_VALUE: invalid_edges.append(f"Edge ({u}, {v}, {key}) has invalid cost: {cost}") @@ -471,44 +369,10 @@ def _validate_edge_attributes(self) -> None: + ("..." if len(invalid_edges) > 5 else "") ) - def validate_flow_conservation(self, flow_results: Dict[str, float]) -> None: - """ - Validate that flow results satisfy basic conservation principles. - - Args: - flow_results: Dictionary mapping flow labels to flow values - - Raises: - AssertionError: If flow conservation principles are violated - """ - # Check for negative flows (usually invalid) - negative_flows = { - label: flow for label, flow in flow_results.items() if flow < 0 - } - assert not negative_flows, ( - f"Found negative flows (usually invalid): {negative_flows}" - ) - - # Check self-loop flows (should typically be zero) - self_loop_flows = { - label: flow - for label, flow in flow_results.items() - if "->" in label - and label.split("->")[0].strip() == label.split("->")[1].strip() - } - - for label, flow in self_loop_flows.items(): - assert flow == 0.0, f"Self-loop flow should be zero: {label} = {flow}" - class ScenarioDataBuilder: """ - Builder pattern implementation for creating test scenario data. - - This class provides a fluent interface for programmatically constructing - NetGraph scenario YAML data with composable components. It simplifies - the creation of test scenarios by providing convenient methods for - common network elements. + Fluent builder for scenario YAML data used in tests. Usage: builder = ScenarioDataBuilder() @@ -528,19 +392,6 @@ def __init__(self) -> None: "workflow": [], } - def with_seed(self, seed: int) -> "ScenarioDataBuilder": - """ - Add deterministic seed to scenario for reproducible results. - - Args: - seed: Random seed value for scenario execution - - Returns: - Self for method chaining - """ - self.data["seed"] = seed - return self - def with_simple_nodes(self, node_names: List[str]) -> "ScenarioDataBuilder": """ Add simple nodes to the network without any special attributes. @@ -606,13 +457,13 @@ def with_traffic_demand( self, source: str, target: str, volume: float, demand_set: str = "default" ) -> "ScenarioDataBuilder": """ - Add a traffic demand to the specified traffic matrix. + Add a traffic demand to the named demand set. Args: source: Source node/pattern for traffic demand target: Target node/pattern for traffic demand volume: Traffic demand volume - demand_set: Name of traffic matrix (default: "default") + demand_set: Name of the demand set (default: "default") Returns: Self for method chaining @@ -626,20 +477,19 @@ def with_traffic_demand( return self def with_failure_policy( - self, name: str, policy_data: Dict[str, Any], policy_name: str = "default" + self, name: str, policy_data: Dict[str, Any] ) -> "ScenarioDataBuilder": """ Add a failure policy to the scenario. Args: - name: Human-readable name for the policy + name: Policy name under the scenario's ``failures`` section policy_data: Policy configuration dictionary - policy_name: Internal policy identifier (default: "default") Returns: Self for method chaining """ - self.data["failures"][policy_name] = policy_data + self.data["failures"][name] = policy_data return self def with_workflow_step( @@ -649,7 +499,7 @@ def with_workflow_step( Add a workflow step to the scenario execution plan. Args: - type: Type of workflow step (e.g., "BuildGraph", "CapacityEnvelopeAnalysis") + type: Type of workflow step (e.g., "BuildGraph", "MaxFlow") name: Unique name for this step instance **kwargs: Additional step-specific parameters @@ -665,15 +515,13 @@ def build_yaml(self) -> str: """ Build YAML string from scenario data. - Automatically ensures that a BuildGraph workflow step is included - if workflow exists but lacks one. + Prepends a BuildGraph step when the workflow is non-empty and lacks one. Returns: YAML string representation of the scenario """ import yaml - # Ensure BuildGraph workflow step is included if workflow exists but lacks one workflow_steps = self.data.get("workflow", []) if workflow_steps and not any( step.get("type") == "BuildGraph" for step in workflow_steps @@ -694,9 +542,6 @@ def build_scenario(self) -> Scenario: return Scenario.from_yaml(yaml_content) -# Utility functions for common operations - - def load_scenario_from_file(filename: str) -> Scenario: """ Load a scenario from a YAML file in the integration directory. @@ -743,43 +588,3 @@ def create_scenario_helper(scenario: Scenario) -> ScenarioTestHelper: graph = graph_dict helper.set_graph(graph) return helper - - -# Pytest fixtures for common test data and patterns - - -@pytest.fixture -def scenario_builder() -> ScenarioDataBuilder: - """Pytest fixture providing a fresh scenario data builder.""" - return ScenarioDataBuilder() - - -@pytest.fixture -def minimal_scenario() -> Scenario: - """Pytest fixture providing a minimal valid scenario for testing.""" - return ( - ScenarioDataBuilder() - .with_simple_nodes(["A", "B", "C"]) - .with_simple_links([("A", "B", 10), ("B", "C", 20)]) - .with_workflow_step("BuildGraph", "build_graph") - .build_scenario() - ) - - -@pytest.fixture -def basic_failure_scenario() -> Scenario: - """Pytest fixture providing a scenario with failure policies configured.""" - builder = ( - ScenarioDataBuilder() - .with_simple_nodes(["A", "B", "C"]) - .with_simple_links([("A", "B", 10), ("B", "C", 20)]) - .with_failure_policy( - "single_link_failure", - { - "attrs": {"description": "Single link failure"}, - "rules": [{"scope": "link", "mode": "choice", "count": 1}], - }, - ) - .with_workflow_step("BuildGraph", "build_graph") - ) - return builder.build_scenario() diff --git a/tests/integration/scenario_3.yaml b/tests/integration/scenario_3.yaml index d550e3a..42777b1 100644 --- a/tests/integration/scenario_3.yaml +++ b/tests/integration/scenario_3.yaml @@ -1,5 +1,5 @@ # Test scenario 3: Complex 3-tier Clos network with nested blueprints -# Tests advanced blueprint nesting, node/link overrides, and capacity probing +# Tests advanced blueprint nesting, node/link rules, and max-flow analysis seed: 3003 blueprints: @@ -80,7 +80,7 @@ network: source: {component: "400G-LR4", count: 1} target: {component: "400G-LR4", count: 1} - # Node overrides for facility-based risk groups + # Node rules for facility-based risk groups node_rules: - path: my_clos1/b1/t1 risk_groups: ["PowerZone_Clos1_B1_PZA"] @@ -160,7 +160,7 @@ workflow: - type: BuildGraph name: build_graph - # Forward direction analysis - equivalent to capacity_probe + # Forward direction, PROPORTIONAL placement - type: MaxFlow name: capacity_analysis_forward source: my_clos1/b.*/t1 @@ -171,7 +171,7 @@ workflow: iterations: 1 failure_policy: null - # Reverse direction analysis - equivalent to capacity_probe with probe_reverse + # Reverse direction, PROPORTIONAL placement - type: MaxFlow name: capacity_analysis_reverse source: my_clos2/b.*/t1 @@ -182,7 +182,7 @@ workflow: iterations: 1 failure_policy: null - # Forward direction with EQUAL_BALANCED - equivalent to capacity_probe2 + # Forward direction, EQUAL_BALANCED placement - type: MaxFlow name: capacity_analysis_forward_balanced source: my_clos1/b.*/t1 @@ -193,7 +193,7 @@ workflow: iterations: 1 failure_policy: null - # Reverse direction with EQUAL_BALANCED - equivalent to capacity_probe2 with probe_reverse + # Reverse direction, EQUAL_BALANCED placement - type: MaxFlow name: capacity_analysis_reverse_balanced source: my_clos2/b.*/t1 diff --git a/tests/integration/scenario_4.yaml b/tests/integration/scenario_4.yaml index 6c51cca..5d00218 100644 --- a/tests/integration/scenario_4.yaml +++ b/tests/integration/scenario_4.yaml @@ -1,5 +1,5 @@ # Test scenario 4: Advanced DSL features demonstration -# Tests components system, variable expansion, bracket expansion, complex overrides +# Tests components system, variable expansion, bracket expansion, node/link rules # and risk groups in a realistic data center fabric scenario seed: 4004 @@ -204,8 +204,8 @@ blueprints: # Spine switches share room-level risk (cooling/power in spine room) links: # Variable expansion for leaf-spine connectivity using $var syntax - - source: "leaf-${leaf_id}" - target: "spine-${spine_id}" + - source: "leaf/leaf-${leaf_id}" + target: "spine/spine-${spine_id}" expand: vars: leaf_id: [1, 2] @@ -272,7 +272,7 @@ network: conduit_id: "DC1-DC2-C1" distance_km: 50 - # Complex node overrides with regex patterns + # Node rules with regex patterns node_rules: # Override DC1 spine switches - assign to DC1 spine room - path: "dc1_fabric/spine/spine-[1-2]" @@ -321,7 +321,7 @@ network: attrs: maintenance_status: "scheduled" - # Complex link overrides + # Link rules link_rules: # Higher capacity for inter-DC links - both conduit and path level risk - source: "dc1_fabric/spine/.*" @@ -403,28 +403,27 @@ failures: mode: "choice" count: 1 -# Multi-step workflow demonstrating various workflow steps +# BuildGraph, then MaxFlow runs without and with failure policies workflow: - type: BuildGraph name: build_graph - # Capacity analysis with different traffic patterns - # Forward intra-DC capacity analysis + # Forward intra-DC capacity analysis: pod a to pod b - type: MaxFlow name: intra_dc_capacity_forward - source: "dc1_pod[ab]_rack.*/servers/.*" - target: "dc1_pod[ab]_rack.*/servers/.*" + source: "dc1_poda_rack.*/servers/.*" + target: "dc1_podb_rack.*/servers/.*" mode: "combine" shortest_path: false flow_placement: "PROPORTIONAL" iterations: 1 failure_policy: null - # Reverse intra-DC capacity analysis + # Reverse intra-DC capacity analysis: pod b to pod a - type: MaxFlow name: intra_dc_capacity_reverse - source: "dc1_pod[ab]_rack.*/servers/.*" - target: "dc1_pod[ab]_rack.*/servers/.*" + source: "dc1_podb_rack.*/servers/.*" + target: "dc1_poda_rack.*/servers/.*" mode: "combine" shortest_path: false flow_placement: "PROPORTIONAL" @@ -456,8 +455,8 @@ workflow: # Failure analysis with different policies - type: MaxFlow name: rack_failure_analysis - source: "dc1_pod[ab]_rack.*/servers/.*" - target: "dc1_pod[ab]_rack.*/servers/.*" + source: "dc1_poda_rack.*/servers/.*" + target: "dc1_podb_rack.*/servers/.*" mode: "combine" failure_policy: "single_link_failure" iterations: 10 # 10 iterations for test efficiency diff --git a/tests/integration/test_data_templates.py b/tests/integration/test_data_templates.py index 6a8beb8..afdfd37 100644 --- a/tests/integration/test_data_templates.py +++ b/tests/integration/test_data_templates.py @@ -1,48 +1,20 @@ """ -Modular test data templates and components for scenario testing. +Test data templates for scenario testing. -This module provides reusable, composable templates for creating NetGraph -test scenarios with consistent patterns. The templates reduce code duplication, -improve test maintainability, and enable rapid creation of test scenarios. - -Key Template Categories: -- NetworkTemplates: Common network topologies (linear, star, mesh, ring, tree) +Template categories: +- NetworkTemplates: Common network topologies (linear, star, mesh, tree) - BlueprintTemplates: Reusable blueprint patterns for hierarchies - FailurePolicyTemplates: Standard failure scenario configurations - TrafficDemandTemplates: Traffic demand patterns and distributions - WorkflowTemplates: Common analysis workflow configurations - ScenarioTemplateBuilder: High-level builder for complete scenarios - CommonScenarios: Pre-built scenarios for typical use cases - -Design Principles: -- Composability: Templates can be combined and layered -- Parameterization: All templates accept configuration parameters -- Consistency: Similar interfaces across all template types -- Reusability: Templates can be used across multiple test scenarios -- Maintainability: Centralized definitions reduce duplication - -Usage Patterns: -1. Basic topology creation with NetworkTemplates -2. Hierarchies with BlueprintTemplates -3. Complete scenarios with ScenarioTemplateBuilder -4. Quick test setups with CommonScenarios """ from typing import Any, Dict, List, Optional from .helpers import ScenarioDataBuilder -# Template configuration constants for consistent testing -DEFAULT_LINK_CAPACITY = 10.0 # Default capacity for template-generated links -DEFAULT_LINK_COST = 1 # Default cost for template-generated links -DEFAULT_TRAFFIC_DEMAND = 1.0 # Default traffic demand value -DEFAULT_BLUEPRINT_CAPACITY = 10.0 # Default capacity for blueprint links - -# Network template size limits for safety -MAX_MESH_NODES = 20 # Prevent accidentally creating huge meshes -MAX_TREE_DEPTH = 10 # Prevent deep recursion in tree generation -MAX_BRANCHING_FACTOR = 20 # Prevent excessive tree branching - class NetworkTemplates: """Templates for common network topologies.""" @@ -107,26 +79,6 @@ def mesh_network( return network_data - @staticmethod - def ring_network( - node_names: List[str], link_capacity: float = 10.0 - ) -> Dict[str, Any]: - """Create a ring network topology (nodes connected in a circle).""" - network_data = {"nodes": {name: {} for name in node_names}, "links": []} - - for i in range(len(node_names)): - next_i = (i + 1) % len(node_names) - network_data["links"].append( - { - "source": node_names[i], - "target": node_names[next_i], - "capacity": link_capacity, - "cost": 1, - } - ) - - return network_data - @staticmethod def tree_network( depth: int, branching_factor: int, link_capacity: float = 10.0 @@ -135,7 +87,6 @@ def tree_network( nodes = {} links = [] - # Generate nodes node_id = 0 queue = [(f"node_{node_id}", 0)] # (node_name, current_depth) nodes[f"node_{node_id}"] = {} @@ -149,7 +100,6 @@ def tree_network( child_name = f"node_{node_id}" nodes[child_name] = {} - # Add link from parent to child links.append( { "source": parent_name, @@ -202,60 +152,6 @@ def two_tier_blueprint( ], } - @staticmethod - def three_tier_clos_blueprint( - leaf_count: int = 4, - spine_count: int = 4, - super_spine_count: int = 2, - link_capacity: float = 10.0, - ) -> Dict[str, Any]: - """Create a three-tier Clos blueprint.""" - return { - "nodes": { - "leaf": {"count": leaf_count, "template": "leaf-{n}"}, - "spine": { - "count": spine_count, - "template": "spine-{n}", - }, - "super_spine": { - "count": super_spine_count, - "template": "ss-{n}", - }, - }, - "links": [ - { - "source": "/leaf", - "target": "/spine", - "pattern": "mesh", - "capacity": link_capacity, - "cost": 1, - }, - { - "source": "/spine", - "target": "/super_spine", - "pattern": "mesh", - "capacity": link_capacity, - "cost": 1, - }, - ], - } - - @staticmethod - def nested_blueprint( - inner_blueprint_name: str, - wrapper_group_name: str = "wrapper", - additional_groups: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """Create a blueprint that wraps another blueprint with additional components.""" - blueprint_data = { - "nodes": {wrapper_group_name: {"blueprint": inner_blueprint_name}} - } - - if additional_groups: - blueprint_data["nodes"].update(additional_groups) - - return blueprint_data - class FailurePolicyTemplates: """Templates for common failure policy patterns.""" @@ -290,65 +186,6 @@ def single_node_failure() -> Dict[str, Any]: ], } - @staticmethod - def multiple_failure(scope: str, count: int) -> Dict[str, Any]: - """Template for multiple simultaneous failures.""" - return { - "attrs": { - "description": f"Multiple {scope} failure scenario", - }, - "modes": [ - { - "weight": 1.0, - "rules": [ - { - "scope": scope, - "mode": "choice", - "count": count, - } - ], - } - ], - } - - @staticmethod - def all_links_failure() -> Dict[str, Any]: - """Template for all links failure policy.""" - return { - "attrs": { - "description": "All links failure scenario", - }, - "modes": [{"weight": 1.0, "rules": [{"scope": "link", "mode": "all"}]}], - } - - @staticmethod - def risk_group_failure(risk_group_name: str) -> Dict[str, Any]: - """Template for risk group-based failure policy.""" - return { - "attrs": { - "description": f"Failure of risk group {risk_group_name}", - }, - "expand_groups": True, - "modes": [ - { - "weight": 1.0, - "rules": [ - { - "scope": "link", - "mode": "all", - "conditions": [ - { - "attr": "risk_groups", - "op": "contains", - "value": risk_group_name, - } - ], - } - ], - } - ], - } - class TrafficDemandTemplates: """Templates for common traffic demand patterns.""" @@ -371,85 +208,6 @@ def all_to_all_uniform( ) return demands - @staticmethod - def star_traffic( - center_node: str, leaf_nodes: List[str], demand_value: float = 1.0 - ) -> List[Dict[str, Any]]: - """Create star traffic pattern (all traffic to/from center node).""" - demands = [] - - # Traffic from leaves to center - for leaf in leaf_nodes: - demands.append( - {"source": leaf, "target": center_node, "volume": demand_value} - ) - - # Traffic from center to leaves - for leaf in leaf_nodes: - demands.append( - {"source": center_node, "target": leaf, "volume": demand_value} - ) - - return demands - - @staticmethod - def random_demands( - node_names: List[str], - num_demands: int, - min_demand: float = 1.0, - max_demand: float = 10.0, - seed: int = 42, - ) -> List[Dict[str, Any]]: - """Create random traffic demands between nodes.""" - import random - - random.seed(seed) - demands = [] - - for _ in range(num_demands): - source = random.choice(node_names) - target = random.choice([n for n in node_names if n != source]) - demand_value = random.uniform(min_demand, max_demand) - - demands.append({"source": source, "target": target, "volume": demand_value}) - - return demands - - @staticmethod - def hotspot_traffic( - hotspot_nodes: List[str], - other_nodes: List[str], - hotspot_demand: float = 10.0, - normal_demand: float = 1.0, - ) -> List[Dict[str, Any]]: - """Create traffic with hotspot patterns (high demand to/from certain nodes).""" - demands = [] - - # High demand traffic to hotspots - for source in other_nodes: - for hotspot in hotspot_nodes: - demands.append( - { - "source": source, - "target": hotspot, - "volume": hotspot_demand, - } - ) - - # Normal demand for other traffic - for source in other_nodes: - for target in other_nodes: - if source != target: - demands.append( - { - "source": source, - "target": target, - "volume": normal_demand, - } - ) - - return demands - class WorkflowTemplates: """Templates for common workflow patterns.""" @@ -485,56 +243,6 @@ def capacity_analysis_workflow( return workflow - @staticmethod - def failure_analysis_workflow( - source_pattern: str, target_pattern: str - ) -> List[Dict[str, Any]]: - """Workflow for analyzing network under failures.""" - return [ - {"type": "BuildGraph", "name": "build_graph"}, - { - "type": "MaxFlow", - "name": "failure_analysis", - "source": source_pattern, - "target": target_pattern, - "iterations": 100, - "parallelism": 4, - }, - ] - - @staticmethod - def comprehensive_analysis_workflow( - source_pattern: str, target_pattern: str - ) -> List[Dict[str, Any]]: - """Comprehensive workflow with multiple analysis steps.""" - return [ - {"type": "BuildGraph", "name": "build_graph"}, - { - "type": "MaxFlow", - "name": "capacity_analysis_combine", - "source": source_pattern, - "target": target_pattern, - "mode": "combine", - "iterations": 1, - }, - { - "type": "MaxFlow", - "name": "capacity_analysis_pairwise", - "source": source_pattern, - "target": target_pattern, - "mode": "pairwise", - "shortest_path": True, - "iterations": 1, - }, - { - "type": "MaxFlow", - "name": "envelope_analysis", - "source": source_pattern, - "target": target_pattern, - "iterations": 50, - }, - ] - class ScenarioTemplateBuilder: """High-level builder for complete scenario templates.""" @@ -576,33 +284,6 @@ def with_linear_backbone( self.builder.data["network"] = network_data return self - def with_clos_fabric( - self, - fabric_name: str, - leaf_count: int = 4, - spine_count: int = 4, - link_capacity: float = 100.0, - ) -> "ScenarioTemplateBuilder": - """Add a Clos fabric using blueprints.""" - # Create the Clos blueprint - clos_blueprint = BlueprintTemplates.two_tier_blueprint( - tier1_count=leaf_count, tier2_count=spine_count, link_capacity=link_capacity - ) - - self.builder.with_blueprint("clos_fabric", clos_blueprint) - - # Add to network - if "network" not in self.builder.data: - self.builder.data["network"] = {"name": self.name, "version": self.version} - if "nodes" not in self.builder.data["network"]: - self.builder.data["network"]["nodes"] = {} - - self.builder.data["network"]["nodes"][fabric_name] = { - "blueprint": "clos_fabric" - } - - return self - def with_uniform_traffic( self, node_patterns: List[str], demand_value: float = 50.0 ) -> "ScenarioTemplateBuilder": @@ -646,56 +327,12 @@ def build(self) -> str: return self.builder.build_yaml() -# Pre-built scenario templates for common use cases class CommonScenarios: """Pre-built scenario templates for common testing patterns.""" - @staticmethod - def simple_linear_with_failures(count: int = 4) -> str: - """Simple linear network with single link failure analysis.""" - nodes = [f"Node{i}" for i in range(1, count + 1)] - - return ( - ScenarioTemplateBuilder("simple_linear", "1.0") - .with_linear_backbone(nodes, link_capacity=10.0, add_coordinates=False) - .with_uniform_traffic(nodes, demand_value=5.0) - .with_single_link_failures() - .with_capacity_analysis(nodes[0], nodes[-1]) - .build() - ) - - @staticmethod - def dual_clos_interconnect() -> str: - """Two Clos fabrics interconnected via spine links.""" - return ( - ScenarioTemplateBuilder("dual_clos", "1.0") - .with_clos_fabric("fabric_east", leaf_count=4, spine_count=4) - .with_clos_fabric("fabric_west", leaf_count=4, spine_count=4) - .with_uniform_traffic(["fabric_east", "fabric_west"], demand_value=25.0) - .with_single_link_failures() - .with_capacity_analysis("fabric_east/.*", "fabric_west/.*") - .build() - ) - - @staticmethod - def us_backbone_network() -> str: - """US backbone network with major cities.""" - cities = ["NYC", "CHI", "DEN", "SFO", "SEA", "LAX", "MIA", "ATL"] - - return ( - ScenarioTemplateBuilder("us_backbone", "1.0") - .with_linear_backbone(cities, link_capacity=200.0, add_coordinates=True) - .with_uniform_traffic( - cities[:4], demand_value=75.0 - ) # Focus on major routes - .with_single_link_failures() - .with_capacity_analysis("NYC|CHI", "SFO|SEA") - .build() - ) - @staticmethod def minimal_test_scenario() -> str: - """Minimal scenario for basic functionality testing.""" + """Three-node A-B-C line with a BuildGraph step.""" from typing import Any, Dict from .helpers import ScenarioDataBuilder @@ -704,376 +341,7 @@ def minimal_test_scenario() -> str: builder.with_simple_nodes(["A", "B", "C"]) builder.with_simple_links([("A", "B", 1.0), ("B", "C", 1.0)]) builder.with_workflow_step("BuildGraph", "build_graph") - # Set network metadata network_data: Dict[str, Any] = builder.data["network"] network_data["name"] = "minimal_test" network_data["version"] = "1.0" return builder.build_yaml() - - -class ErrorInjectionTemplates: - """Templates for injecting common error conditions into scenarios.""" - - @staticmethod - def invalid_node_builder() -> ScenarioDataBuilder: - """Create scenario builder with invalid node configuration.""" - builder = ScenarioDataBuilder() - # Create nodes that will cause validation errors - return builder - - @staticmethod - def missing_nodes_builder() -> ScenarioDataBuilder: - """Create scenario builder with links referencing missing nodes.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["NodeA"]) - # Add link to nonexistent node - will cause error during execution - builder.data["network"]["links"] = [ - { - "source": "NodeA", - "target": "NonexistentNode", - "capacity": 10, - "cost": 1, - } - ] - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def circular_blueprint_builder() -> ScenarioDataBuilder: - """Create scenario builder with circular blueprint references.""" - builder = ScenarioDataBuilder() - builder.with_blueprint( - "blueprint_a", {"nodes": {"group_a": {"blueprint": "blueprint_b"}}} - ) - builder.with_blueprint( - "blueprint_b", {"nodes": {"group_b": {"blueprint": "blueprint_a"}}} - ) - builder.data["network"] = { - "name": "circular_test", - "nodes": {"test_group": {"blueprint": "blueprint_a"}}, - } - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def invalid_failure_policy_builder() -> ScenarioDataBuilder: - """Create scenario builder with invalid failure policy.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["A", "B"]) - builder.with_simple_links([("A", "B", 10.0)]) - builder.with_failure_policy( - "invalid_policy", - { - "rules": [ - { - "scope": "invalid_scope", # Invalid scope - "mode": "choice", - "count": 1, - } - ] - }, - ) - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def negative_demand_builder() -> ScenarioDataBuilder: - """Create scenario builder with negative traffic demands.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["Source", "Sink"]) - builder.with_simple_links([("Source", "Sink", 10.0)]) - builder.with_traffic_demand("Source", "Sink", -50.0) # Negative demand - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def missing_workflow_params_builder() -> ScenarioDataBuilder: - """Create scenario builder with incomplete workflow step parameters.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["A", "B"]) - builder.with_simple_links([("A", "B", 10.0)]) - # Add CapacityEnvelopeAnalysis without required parameters - builder.data["workflow"] = [ - { - "type": "CapacityEnvelopeAnalysis", - "name": "incomplete_analysis", - # Missing source and sink - } - ] - return builder - - @staticmethod - def large_network_builder(count: int = 1000) -> ScenarioDataBuilder: - """Create scenario builder for stress testing with large networks.""" - builder = ScenarioDataBuilder() - - # Create many nodes - node_names = [f"Node_{i:04d}" for i in range(count)] - builder.with_simple_nodes(node_names) - - # Create star topology to avoid O(nยฒ) mesh complexity - if count > 1: - center_node = node_names[0] - leaf_nodes = node_names[1:] - - links = [ - (center_node, leaf, 1.0) - for leaf in leaf_nodes[: min(100, len(leaf_nodes))] - ] - builder.with_simple_links(links) - - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def deep_blueprint_nesting_builder(depth: int = 15) -> ScenarioDataBuilder: - """Create scenario builder with deeply nested blueprints.""" - builder = ScenarioDataBuilder() - - # Create nested blueprints - for i in range(depth): - if i == 0: - builder.with_blueprint( - f"level_{i}", - { - "nodes": { - "leaf": { - "count": 1, - "template": f"level_{i}_node_{{n}}", - } - } - }, - ) - else: - builder.with_blueprint( - f"level_{i}", - {"nodes": {"nested": {"blueprint": f"level_{i - 1}"}}}, - ) - - # Use the deepest blueprint - builder.data["network"] = { - "name": "deep_nesting_test", - "nodes": {"deep_group": {"blueprint": f"level_{depth - 1}"}}, - } - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - -class EdgeCaseTemplates: - """Templates for edge case scenarios and boundary conditions.""" - - @staticmethod - def empty_network_builder() -> ScenarioDataBuilder: - """Create scenario builder with completely empty network.""" - builder = ScenarioDataBuilder() - builder.data["network"] = {"name": "empty", "nodes": {}, "links": []} - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def single_node_builder(node_name: str = "LonelyNode") -> ScenarioDataBuilder: - """Create scenario builder with single isolated node.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes([node_name]) - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def isolated_nodes_builder(count: int = 5) -> ScenarioDataBuilder: - """Create scenario builder with multiple isolated nodes.""" - builder = ScenarioDataBuilder() - node_names = [f"Isolated_{i}" for i in range(count)] - builder.with_simple_nodes(node_names) - # No links - all nodes isolated - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def zero_capacity_links_builder() -> ScenarioDataBuilder: - """Create scenario builder with zero-capacity links.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["A", "B", "C"]) - builder.data["network"]["links"] = [ - {"source": "A", "target": "B", "capacity": 0, "cost": 1}, - {"source": "B", "target": "C", "capacity": 0, "cost": 1}, - ] - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def extreme_values_builder() -> ScenarioDataBuilder: - """Create scenario builder with extreme numeric values.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["NodeA", "NodeB"]) - builder.data["network"]["links"] = [ - { - "source": "NodeA", - "target": "NodeB", - "capacity": 999999999999, # Very large capacity - "cost": 999999999999, # Very large cost - } - ] - builder.with_traffic_demand("NodeA", "NodeB", 888888888888.0) # Large demand - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def special_characters_builder() -> ScenarioDataBuilder: - """Create scenario builder with special characters in names.""" - builder = ScenarioDataBuilder() - special_names = ["node-with-dashes", "node.with.dots", "node_with_underscores"] - builder.with_simple_nodes(special_names) - - # Add links between nodes with special characters - if len(special_names) >= 2: - builder.with_simple_links([(special_names[0], special_names[1], 10.0)]) - - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def duplicate_links_builder() -> ScenarioDataBuilder: - """Create scenario builder with multiple links between same nodes.""" - builder = ScenarioDataBuilder() - builder.with_simple_nodes(["A", "B"]) - - # Add multiple links with different parameters - builder.data["network"]["links"] = [ - {"source": "A", "target": "B", "capacity": 10, "cost": 1}, - {"source": "A", "target": "B", "capacity": 20, "cost": 2}, - {"source": "A", "target": "B", "capacity": 15, "cost": 3}, - ] - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - -class PerformanceTestTemplates: - """Templates for performance and stress testing scenarios.""" - - @staticmethod - def large_star_network_builder(leaf_count: int = 100) -> ScenarioDataBuilder: - """Create large star network for performance testing.""" - builder = ScenarioDataBuilder() - - center = "HUB" - leaves = [f"LEAF_{i:03d}" for i in range(leaf_count)] - all_nodes = [center] + leaves - - builder.with_simple_nodes(all_nodes) - - # Create star links - star_links = [(center, leaf, 10.0) for leaf in leaves] - builder.with_simple_links(star_links) - - # Add some traffic demands - demands = [(center, leaf, 1.0) for leaf in leaves[: min(10, len(leaves))]] - for source, sink, demand in demands: - builder.with_traffic_demand(source, sink, demand) - - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def large_mesh_blueprint_builder(side_size: int = 20) -> ScenarioDataBuilder: - """Create large mesh using blueprints for performance testing.""" - builder = ScenarioDataBuilder() - - # Create large mesh blueprint - large_mesh_blueprint = { - "nodes": { - "side_a": {"count": side_size, "template": "a-{n}"}, - "side_b": {"count": side_size, "template": "b-{n}"}, - }, - "links": [ - { - "source": "/side_a", - "target": "/side_b", - "pattern": "mesh", - "capacity": 1, - "cost": 1, - } - ], - } - - builder.with_blueprint("large_mesh", large_mesh_blueprint) - builder.data["network"] = { - "name": "large_mesh_test", - "nodes": {"mesh_group": {"blueprint": "large_mesh"}}, - } - builder.with_workflow_step("BuildGraph", "build_graph") - return builder - - @staticmethod - def complex_multi_blueprint_builder() -> ScenarioDataBuilder: - """Create complex scenario with multiple interacting blueprints.""" - builder = ScenarioDataBuilder() - - # Create basic building blocks - basic_brick = BlueprintTemplates.two_tier_blueprint(4, 4, "mesh", 10.0) - builder.with_blueprint("basic_brick", basic_brick) - - # Create aggregation layer - agg_layer = { - "nodes": { - "brick1": {"blueprint": "basic_brick"}, - "brick2": {"blueprint": "basic_brick"}, - "agg_spine": {"count": 8, "template": "agg-{n}"}, - }, - "links": [ - { - "source": "brick1/tier2", - "target": "agg_spine", - "pattern": "mesh", - "capacity": 20, - "cost": 1, - }, - { - "source": "brick2/tier2", - "target": "agg_spine", - "pattern": "mesh", - "capacity": 20, - "cost": 1, - }, - ], - } - builder.with_blueprint("agg_layer", agg_layer) - - # Create core layer - core_layer = { - "nodes": { - "agg1": {"blueprint": "agg_layer"}, - "agg2": {"blueprint": "agg_layer"}, - "core_spine": {"count": 4, "template": "core-{n}"}, - }, - "links": [ - { - "source": "agg1/agg_spine", - "target": "core_spine", - "pattern": "mesh", - "capacity": 40, - "cost": 1, - }, - { - "source": "agg2/agg_spine", - "target": "core_spine", - "pattern": "mesh", - "capacity": 40, - "cost": 1, - }, - ], - } - builder.with_blueprint("core_layer", core_layer) - - # Use in network - builder.data["network"] = { - "name": "complex_multi_blueprint", - "nodes": {"datacenter": {"blueprint": "core_layer"}}, - } - - # Add capacity analysis workflow - workflow = WorkflowTemplates.capacity_analysis_workflow( - "datacenter/agg1/brick1/tier1/.*", "datacenter/agg2/brick2/tier1/.*" - ) - builder.data["workflow"] = workflow - - return builder diff --git a/tests/integration/test_error_cases.py b/tests/integration/test_error_cases.py index 58a792e..e923f03 100644 --- a/tests/integration/test_error_cases.py +++ b/tests/integration/test_error_cases.py @@ -1,8 +1,8 @@ """ Error case tests for scenario processing and validation. -Tests malformed YAML scenarios, invalid configurations, edge cases, and error -handling to ensure error reporting and graceful degradation. +Covers malformed YAML, invalid configurations, and degenerate topologies +(empty, single node, self-loops, parallel and zero-capacity links). """ import pytest @@ -18,7 +18,7 @@ class TestMalformedYAML: """Tests for malformed YAML and parsing errors.""" def test_invalid_yaml_syntax(self): - """Test that invalid YAML syntax raises appropriate error.""" + """Test that invalid YAML syntax raises ParserError.""" # Use raw YAML for syntax error testing (can't build with builder) invalid_yaml = """ network: @@ -30,12 +30,11 @@ def test_invalid_yaml_syntax(self): Scenario.from_yaml(invalid_yaml) def test_missing_required_fields(self): - """Test scenarios with missing required fields.""" + """Test that an empty builder scenario loads with a network object.""" # Empty scenario using builder builder = ScenarioDataBuilder() scenario = builder.build_scenario() - # Empty scenario should be handled gracefully assert scenario.network is not None def test_invalid_node_definitions(self): @@ -49,7 +48,7 @@ def test_invalid_node_definitions(self): disabled: "not_a_boolean" # Should be boolean """ - # Schema validation now catches invalid keys + # Schema validation rejects unknown node keys import jsonschema.exceptions with pytest.raises( @@ -58,11 +57,6 @@ def test_invalid_node_definitions(self): ): _scenario = Scenario.from_yaml(invalid_node_yaml) - def test_invalid_link_definitions(self): - """Test invalid link definitions.""" - # Removed: behavior varies by validation layer and produced flaky outcomes. - assert True - def test_nonexistent_link_endpoints(self): """Test links referencing nonexistent nodes are silently skipped.""" # Use raw YAML since builder would validate node existence @@ -130,12 +124,7 @@ def test_circular_blueprint_references(self): scenario = builder.build_scenario() scenario.run() - def test_invalid_blueprint_parameters(self): - """Test invalid blueprint parameter overrides.""" - # Removed: behavior varies by validation layer and produced flaky outcomes. - assert True - - def test_malformed_adjacency_patterns(self): + def test_malformed_link_patterns(self): """Test malformed link patterns.""" import jsonschema.exceptions @@ -188,7 +177,7 @@ def test_nonexistent_workflow_step_type(self): @pytest.mark.slow class TestEdgeCases: - """Tests for edge cases and boundary conditions.""" + """Degenerate topologies and extreme parameter values.""" def test_empty_network(self): """Test scenario with no nodes or links.""" @@ -276,7 +265,6 @@ def test_duplicate_links(self): scenario = builder.build_scenario() scenario.run() - # Should handle parallel links correctly exported = scenario.results.to_dict() graph_data = exported["steps"]["build_graph"]["data"]["graph"] assert len(graph_data.get("nodes", [])) == 2 @@ -306,7 +294,6 @@ def test_zero_capacity_links(self): scenario = builder.build_scenario() scenario.run() - # Should handle zero capacity links appropriately exported = scenario.results.to_dict() graph_data = exported["steps"]["build_graph"]["data"]["graph"] assert len(graph_data.get("nodes", [])) == 2 @@ -345,7 +332,6 @@ def test_special_characters_in_node_names(self): scenario = builder.build_scenario() scenario.run() - # Verify all nodes were created exported = scenario.results.to_dict() graph_data = exported["steps"]["build_graph"]["data"]["graph"] nodes = graph_data.get("nodes", []) diff --git a/tests/integration/test_scenario_1.py b/tests/integration/test_scenario_1.py index 81c0c47..9406ed3 100644 --- a/tests/integration/test_scenario_1.py +++ b/tests/integration/test_scenario_1.py @@ -1,18 +1,14 @@ """ Integration tests for scenario 1: Basic 6-node L3 US backbone network. -This module tests the fundamental building blocks of NetGraph integration: -- Basic network definition with explicit nodes and links -- Single link failure scenario configuration -- Traffic matrix setup and validation -- Network topology correctness verification - -Scenario 1 serves as the baseline test for the integration framework, -validating that simple network topologies work correctly before testing -more complex blueprint-based scenarios. - -Uses the modular testing approach with validation helpers from the -integration.helpers module. +Covers: +- Network definition with explicit nodes and links +- Single link failure policy +- Traffic demand set +- Link topology, attributes and graph connectivity + +Scenario 1 is the baseline for the integration suite: explicit nodes and +links, no blueprints. Validation helpers come from integration.helpers. """ import pytest @@ -23,7 +19,7 @@ @pytest.mark.slow class TestScenario1: - """Tests for scenario 1 using modular validation approach.""" + """Tests for scenario 1.""" @pytest.fixture def scenario_1(self): @@ -105,7 +101,7 @@ def test_parallel_links_between_den_dfw(self, helper): def test_link_capacities_and_costs(self, helper): """Test that links have expected capacities and costs from YAML.""" - # Test a few specific links to ensure YAML parsing worked correctly + # Spot-check a few links against values in scenario_1.yaml test_cases = [ ("SEA", "DEN", 200, 6846), ("SFO", "DEN", 200, 7754), @@ -130,13 +126,11 @@ def test_link_capacities_and_costs(self, helper): ) def test_traffic_demands_configuration(self, helper): - """Test that traffic demands are correctly configured.""" + """Test that the default demand set holds the four 50-unit demands.""" helper.validate_traffic_demands(expected_count=4) - # Verify specific demands from the YAML - default_demands = helper.scenario.demand_set.get_default_set() + default_demands = helper.scenario.demand_set.get_set("default") - # Convert to a more testable format demands_dict = { (demand.source, demand.target): demand.volume for demand in default_demands } @@ -159,13 +153,11 @@ def test_traffic_demands_configuration(self, helper): ) def test_failure_policy_configuration(self, helper): - """Test that failure policy is correctly configured.""" + """Test the single link-choice rule and description of the failure policy.""" helper.validate_failure_policy(expected_rules=1, expected_scopes=["link"]) - # Additional validation of the specific rule policies = helper.scenario.failure_policy_set.get_all_policies() - policy = policies[0] # Get first policy for validation - # Access first rule via modes-based API + policy = policies[0] rule = policy.modes[0].rules[0] assert rule.logic == "or", f"Expected rule logic 'or', found '{rule.logic}'" @@ -181,15 +173,15 @@ def test_failure_policy_configuration(self, helper): ) def test_topology_semantic_correctness(self, helper): - """Test that the network topology is semantically correct.""" + """Test that all edges have non-negative capacity and cost.""" helper.validate_topology_semantics() def test_graph_connectivity(self, helper): - """Test that the graph has expected connectivity properties.""" + """Test that the graph is weakly connected with no isolated nodes.""" # For this backbone network, all nodes should be reachable from any other node import networkx as nx - # Check weak connectivity (appropriate for directed graphs) + # Weak connectivity, since the graph is directed assert nx.is_weakly_connected(helper.graph), ( "Network should be weakly connected" ) @@ -199,7 +191,7 @@ def test_graph_connectivity(self, helper): assert len(isolated_nodes) == 0, f"Found isolated nodes: {isolated_nodes}" def test_node_attributes_from_yaml(self, helper): - """Test that node attributes from YAML are correctly parsed.""" + """Test that node coords from the YAML are parsed.""" # Test coordinate attributes for a few nodes test_nodes = { "SEA": [47.6062, -122.3321], @@ -211,7 +203,7 @@ def test_node_attributes_from_yaml(self, helper): helper.validate_node_attributes(node_name, {"coords": expected_coords}) def test_link_attributes_from_yaml(self, helper): - """Test that link attributes from YAML are correctly parsed.""" + """Test that link distance_km values from the YAML are parsed.""" # Test distance attributes for specific links helper.validate_link_attributes( source_pattern="^SEA$", @@ -224,6 +216,3 @@ def test_link_attributes_from_yaml(self, helper): target_pattern="^DEN$", expected_attrs={"distance_km": 1550.77}, ) - - -# Removed redundant smoke test; class-based tests already cover these checks. diff --git a/tests/integration/test_scenario_2.py b/tests/integration/test_scenario_2.py index 4a4ffd4..0eec075 100644 --- a/tests/integration/test_scenario_2.py +++ b/tests/integration/test_scenario_2.py @@ -1,19 +1,14 @@ """ Integration tests for scenario 2: Hierarchical DSL with blueprints and multi-node expansions. -This module tests advanced NetGraph features including: +Covers: - Network blueprints with nested hierarchies -- Blueprint parameter overrides and customization +- Blueprint parameter overrides - Mesh pattern connectivity between blueprint groups - Sub-topology composition and reuse - Hierarchical DSL path resolution -Scenario 2 validates that NetGraph's blueprint system can create network -topologies with proper expansion, naming, and connectivity patterns. -It demonstrates the hierarchical DSL for defining reusable network components. - -Uses the modular testing approach with validation helpers from the -integration.helpers module. +Validation helpers come from integration.helpers. """ import pytest @@ -24,7 +19,7 @@ @pytest.mark.slow class TestScenario2: - """Tests for scenario 2 using modular validation approach.""" + """Tests for scenario 2.""" @pytest.fixture def scenario_2(self): @@ -76,7 +71,7 @@ def test_blueprint_expansion_validation(self, helper): ) def test_hierarchical_node_naming(self, helper): - """Test that hierarchical node naming from blueprints works correctly.""" + """Test that expanded nodes get hierarchical group/subgroup/template names.""" # Test specific expanded node names from the blueprint hierarchy expected_nodes = { "SEA/clos_instance/spine/myspine-6", # Overridden spine with custom naming @@ -90,7 +85,7 @@ def test_hierarchical_node_naming(self, helper): f"Expected hierarchical node '{node_name}' not found" ) - def test_mesh_pattern_adjacency(self, helper): + def test_mesh_link_pattern(self, helper): """Test that mesh patterns create full connectivity between groups.""" # In the clos_2tier blueprint, leaf should mesh with spine # With 4 leaf and 6 spine nodes, we expect 4 * 6 = 24 connections @@ -102,8 +97,8 @@ def test_mesh_pattern_adjacency(self, helper): f"Expected 24 leaf-to-spine mesh links, found {len(leaf_to_spine_links)}" ) - def test_blueprint_parameter_overrides(self, helper): - """Test that blueprint parameter overrides work correctly.""" + def test_blueprint_parameters(self, helper): + """Test that params override the SEA spine count (6) and name template.""" # The city_cloud blueprint overrides spine.node_count to 6 and spine.name_template spine_nodes = [ node @@ -169,7 +164,7 @@ def test_single_node_blueprint(self, helper): ) def test_link_capacities_and_costs(self, helper): - """Test that link parameters from blueprints and direct definitions are correct.""" + """Test capacity and cost on blueprint mesh links and on direct links.""" # Test blueprint-generated links leaf_spine_links = helper.network.find_links( source_regex=r"SEA/clos_instance/leaf/.*", @@ -199,11 +194,11 @@ def test_link_capacities_and_costs(self, helper): ) def test_traffic_demands_configuration(self, helper): - """Test traffic demands are correctly configured.""" + """Test that the default demand set holds the four 50-unit demands.""" helper.validate_traffic_demands(expected_count=4) # Same traffic demands as scenario 1 - default_demands = helper.scenario.demand_set.get_default_set() + default_demands = helper.scenario.demand_set.get_set("default") demands_dict = { (demand.source, demand.target): demand.volume for demand in default_demands } @@ -230,11 +225,11 @@ def test_failure_policy_configuration(self, helper): helper.validate_failure_policy(expected_rules=1, expected_scopes=["link"]) def test_topology_semantic_correctness(self, helper): - """Test that the expanded network topology is semantically correct.""" + """Test that all edges have non-negative capacity and cost.""" helper.validate_topology_semantics() def test_blueprint_nesting_structure(self, helper): - """Test that nested blueprint references work correctly.""" + """Test that a blueprint nested in another expands to leaf-1..leaf-4.""" # city_cloud blueprint contains clos_instance which uses clos_2tier blueprint # Verify the full nesting path exists nested_leaf_nodes = [ @@ -251,13 +246,22 @@ def test_blueprint_nesting_structure(self, helper): ) def test_node_coordinate_attributes(self, helper): - """Test that node coordinate attributes are preserved through blueprint expansion.""" - # The SEA group should have coordinates that propagate to expanded nodes - # (This depends on the implementation - may need adjustment based on actual behavior) - sea_nodes = [node for node in helper.network.nodes if node.startswith("SEA/")] - - # At minimum, check that SEA-related nodes exist and have some structure - assert len(sea_nodes) > 0, "SEA blueprint expansion should create nodes" - - -# Removed redundant smoke test; class-based tests already cover these checks. + """Test that group coords reach every node a blueprint expands into.""" + expected_coords = { + "SEA/": [47.6062, -122.3321], + "SFO/": [37.7749, -122.4194], + "DEN": [39.7392, -104.9903], + "DFW": [32.8998, -97.0403], + "JFK": [40.641766, -73.780968], + "DCA": [38.907192, -77.036871], + } + nodes = helper.network.nodes + for prefix, coords in expected_coords.items(): + matched = [name for name in nodes if name.startswith(prefix)] + assert matched, f"No nodes under {prefix}" + for name in matched: + assert nodes[name].attrs.get("coords") == coords, ( + f"{name} has coords {nodes[name].attrs.get('coords')}, " + f"expected {coords}" + ) + assert len([n for n in nodes if n.startswith("SEA/")]) == 14 diff --git a/tests/integration/test_scenario_3.py b/tests/integration/test_scenario_3.py index 512f624..07ba235 100644 --- a/tests/integration/test_scenario_3.py +++ b/tests/integration/test_scenario_3.py @@ -1,20 +1,14 @@ """ Integration tests for scenario 3: 3-tier Clos network with nested blueprints. -This module tests the most advanced NetGraph capabilities including: +Covers: - Deep blueprint nesting with multiple levels of hierarchy - 3-tier Clos fabric topology with brick-spine-spine architecture -- Node and link override mechanisms for customization -- Capacity probing with different flow placement algorithms -- Network analysis workflows with multiple steps -- Risk group assignment and validation +- Node and link rules +- Max-flow probing with PROPORTIONAL and EQUAL_BALANCED placement +- Risk group assignment -Scenario 3 represents the most complex network topology in the test suite, -validating NetGraph's ability to handle large network definitions with -relationships and analysis requirements. - -Uses the modular testing approach with validation helpers from the -integration.helpers module. +Validation helpers come from integration.helpers. """ import pytest @@ -25,7 +19,7 @@ @pytest.mark.slow class TestScenario3: - """Tests for scenario 3 using modular validation approach.""" + """Tests for scenario 3.""" @pytest.fixture def scenario_3(self): @@ -56,7 +50,7 @@ def test_network_structure_validation(self, helper): helper.validate_network_structure(SCENARIO_3_EXPECTATIONS) def test_nested_blueprint_structure(self, helper): - """Test complex nested blueprint expansions work correctly.""" + """Test that each nested Clos blueprint instance expands to 32 nodes.""" # Each 3-tier Clos should have 32 nodes total clos1_nodes = [ node for node in helper.network.nodes if node.startswith("my_clos1/") @@ -102,25 +96,22 @@ def test_3tier_clos_blueprint_structure(self, helper): f"my_clos1/spine should have 16 nodes, found {len(spine_nodes)}" ) - def test_one_to_one_pattern_adjacency(self, helper): - """Test that one_to_one patterns create correct pairings.""" - # b1/t2 to spine - check actual behavior (4 t2 nodes * 16 spine nodes in one_to_one pattern) + def test_one_to_one_link_pattern(self, helper): + """Test one_to_one links from b1/t2 to spine and between the two Clos spines.""" + # b1/t2 (4 nodes) to spine (16 nodes) with one_to_one b1_t2_to_spine_links = helper.network.find_links( source_regex=r"my_clos1/b1/t2/.*", target_regex=r"my_clos1/spine/.*" ) - # Count unique t2 source nodes t2_sources = {link.source for link in b1_t2_to_spine_links} - # Verify that we have 4 t2 sources (from brick_2tier blueprint) + # 4 t2 sources from the brick_2tier blueprint assert len(t2_sources) == 4, ( f"Expected 4 t2 source nodes, found {len(t2_sources)}" ) - # Verify that we have links (actual implementation may connect to all spine nodes) assert len(b1_t2_to_spine_links) > 0, "Should have b1/t2->spine connections" - # Verify each t2 node connects to spine nodes for t2_node in t2_sources: t2_links = [link for link in b1_t2_to_spine_links if link.source == t2_node] assert len(t2_links) > 0, f"t2 node {t2_node} should connect to spine nodes" @@ -134,7 +125,7 @@ def test_one_to_one_pattern_adjacency(self, helper): ) def test_mesh_pattern_in_nested_blueprints(self, helper): - """Test that mesh patterns work within nested blueprints.""" + """Test that the t1-t2 mesh inside a nested brick blueprint yields 16 links.""" # Within each brick_2tier blueprint, t1 should mesh with t2 # Each brick has 4 t1 and 4 t2 nodes, so 4 * 4 = 16 mesh links per brick b1_t1_to_t2_links = helper.network.find_links( @@ -144,8 +135,8 @@ def test_mesh_pattern_in_nested_blueprints(self, helper): f"Expected 16 mesh links in b1 brick, found {len(b1_t1_to_t2_links)}" ) - def test_node_overrides_application(self, helper): - """Test that node overrides are correctly applied.""" + def test_node_rules_application(self, helper): + """Test that node_rules set risk_groups and hw_component on matched nodes.""" # Test specific node override from YAML # Uses facility domain model for risk groups helper.validate_node_attributes( @@ -164,8 +155,8 @@ def test_node_overrides_application(self, helper): {"risk_groups": {"Room_Clos1_Spine"}, "hw_component": "SpineHW"}, ) - def test_link_overrides_application(self, helper): - """Test that link overrides are correctly applied.""" + def test_link_rules_application(self, helper): + """Test link_rules: t3-1 capacity and t3-2 risk_groups on spine links.""" # Test specific capacity override override_links = helper.network.find_links( source_regex="my_clos1/spine/t3-1$", target_regex="my_clos2/spine/t3-1$" @@ -187,7 +178,7 @@ def test_link_overrides_application(self, helper): ) def test_link_capacity_configuration(self, helper): - """Test that links have correct capacities from blueprint definitions.""" + """Test blueprint link capacities: 100 inside bricks, 400 between spines.""" # Brick internal links should have capacity 100.0 Gb/s brick_internal_links = helper.network.find_links( source_regex=r"my_clos1/b1/t1/.*", target_regex=r"my_clos1/b1/t2/.*" @@ -209,15 +200,15 @@ def test_link_capacity_configuration(self, helper): ) def test_no_traffic_demands(self, helper): - """Test that this scenario has no traffic demands as expected.""" + """Test that this scenario defines no traffic demands.""" helper.validate_traffic_demands(expected_count=0) def test_no_failure_policy(self, helper): - """Test that this scenario has no failure policy as expected.""" + """Test that this scenario defines no failure policy.""" helper.validate_failure_policy(expected_rules=0) def test_capacity_envelope_proportional_flow_results(self, helper): - """Test capacity envelope results with PROPORTIONAL flow placement.""" + """Test that PROPORTIONAL max flow is ~3200 in both directions.""" # Test forward direction (MaxFlow returns baseline separately, flow_results for failures) exported = helper.scenario.results.to_dict() fwd = exported["steps"].get("capacity_analysis_forward", {}).get("data", {}) @@ -243,7 +234,7 @@ def test_capacity_envelope_proportional_flow_results(self, helper): ) def test_capacity_envelope_equal_balanced_flow_results(self, helper): - """Test capacity envelope results with EQUAL_BALANCED flow placement.""" + """Test that EQUAL_BALANCED max flow is ~3200 in both directions.""" exported = helper.scenario.results.to_dict() fwd = ( exported["steps"] @@ -270,46 +261,38 @@ def test_capacity_envelope_equal_balanced_flow_results(self, helper): rev_total = float(rev_result.get("summary", {}).get("total_placed", 0.0)) assert abs(rev_total - 3200.0) < 0.1 - def test_flow_conservation_properties(self, helper): - """Test that flow results satisfy conservation principles.""" - all_flows: dict[str, float] = {} + def test_max_flow_totals_are_consistent(self, helper): + """Every MaxFlow step reports 3200, within the inter-fabric capacity. + Forward and reverse runs agree because the fabrics are symmetric, and + PROPORTIONAL and EQUAL_BALANCED agree because the equal-cost links have + equal capacity. Each step's per-flow placements sum to its summary. + """ exported = helper.scenario.results.to_dict() + steps = [ + "capacity_analysis_forward", + "capacity_analysis_reverse", + "capacity_analysis_forward_balanced", + "capacity_analysis_reverse_balanced", + ] + inter_fabric_capacity = sum( + link.capacity + for link in helper.network.links.values() + if link.source.startswith("my_clos1/") + != link.target.startswith("my_clos1/") + ) - def total_placed(step: str) -> float | None: - data = exported["steps"].get(step, {}).get("data", {}) - # Check baseline first (no failure policy), then flow_results - result = data.get("baseline") or (data.get("flow_results", []) or [None])[0] - if not result: - return None - return float(result.get("summary", {}).get("total_placed", 0.0)) - - fp = total_placed("capacity_analysis_forward") - if fp is not None: - all_flows["forward_proportional"] = fp - - rp = total_placed("capacity_analysis_reverse") - if rp is not None: - all_flows["reverse_proportional"] = rp - - fb = total_placed("capacity_analysis_forward_balanced") - if fb is not None: - all_flows["forward_balanced"] = fb - - rb = total_placed("capacity_analysis_reverse_balanced") - if rb is not None: - all_flows["reverse_balanced"] = rb - - assert len(all_flows) > 0, "Should have at least some capacity analysis results" - - expected_flow = 3200.0 - for name, value in all_flows.items(): - assert abs(value - expected_flow) < 0.1, ( - f"Flow {name} = {value}, expected ~{expected_flow}" - ) + for step in steps: + baseline = exported["steps"][step]["data"]["baseline"] + total = baseline["summary"]["total_placed"] + assert total == pytest.approx(3200.0), f"{step} placed {total}" + assert sum(f["placed"] for f in baseline["flows"]) == pytest.approx( + total + ), f"{step} flows do not sum to the summary" + assert total <= inter_fabric_capacity def test_topology_semantic_correctness(self, helper): - """Test that the complex nested topology is semantically correct.""" + """Test that all edges have non-negative capacity and cost.""" helper.validate_topology_semantics() def test_inter_clos_connectivity(self, helper): @@ -328,8 +311,8 @@ def test_inter_clos_connectivity(self, helper): f"Inter-Clos link target should be spine: {link.target}" ) - def test_regex_pattern_matching_in_overrides(self, helper): - """Test that regex patterns in overrides work correctly.""" + def test_regex_pattern_matching_in_rules(self, helper): + """Test that node_rules regex my_clos1/spine/t3.* assigns Room_Clos1_Spine.""" # The node override "my_clos1/spine/t3.*" should match all spine nodes spine_nodes_clos1 = [ node @@ -346,23 +329,21 @@ def test_regex_pattern_matching_in_overrides(self, helper): ) def test_workflow_step_execution_order(self, scenario_3_executed): - """Test that workflow steps executed in correct order.""" - # Should have results from BuildGraph step - exported2 = scenario_3_executed.results.to_dict() - graph_result = exported2["steps"]["build_graph"]["data"].get("graph") - assert graph_result is not None, "BuildGraph step should have executed" - - # Should have results from MaxFlow analysis steps (flow_results present) - assert ( - exported2["steps"]["capacity_analysis_forward"]["data"].get("flow_results") - is not None - ) - assert ( - exported2["steps"]["capacity_analysis_forward_balanced"]["data"].get( - "flow_results" - ) - is not None + """Steps run in the order the workflow lists them, and each stored data.""" + exported = scenario_3_executed.results.to_dict() + expected_order = [ + "build_graph", + "capacity_analysis_forward", + "capacity_analysis_reverse", + "capacity_analysis_forward_balanced", + "capacity_analysis_reverse_balanced", + ] + recorded = sorted( + exported["workflow"], + key=lambda name: exported["workflow"][name]["execution_order"], ) + assert recorded == expected_order - -# Removed redundant smoke test; class-based tests already cover these checks. + assert exported["steps"]["build_graph"]["data"].get("graph") is not None + for step in expected_order[1:]: + assert exported["steps"][step]["data"].get("baseline") is not None diff --git a/tests/integration/test_scenario_4.py b/tests/integration/test_scenario_4.py index d3a72f5..81b1f46 100644 --- a/tests/integration/test_scenario_4.py +++ b/tests/integration/test_scenario_4.py @@ -1,7 +1,7 @@ """ Integration tests for scenario 4: Advanced DSL features demonstration. -This module tests the most advanced NetGraph capabilities including: +Covers: - Component system for hardware modeling with cost/power calculations - Variable expansion in adjacency rules (cartesian and zip modes) - Bracket expansion in group names for multiple pattern matching @@ -11,14 +11,11 @@ - NetworkExplorer integration for hierarchy analysis - Large-scale network topology with realistic data center structure -Scenario 4 represents the most complex test of NetGraph's DSL capabilities, -validating the framework's ability to handle enterprise-scale network definitions -with complex relationships and advanced analysis requirements. - -Uses the modular testing approach with validation helpers from the -integration.helpers module. +Validation helpers come from integration.helpers. """ +import re + import pytest from ngraph.explorer import NetworkExplorer @@ -35,7 +32,7 @@ @pytest.mark.slow class TestScenario4: - """Tests for scenario 4 using modular validation approach.""" + """Tests for scenario 4.""" @pytest.fixture(scope="module") def scenario_4(self): @@ -66,10 +63,9 @@ def test_network_structure_validation(self, helper): helper.validate_network_structure(SCENARIO_4_EXPECTATIONS) def test_components_system_integration(self, helper): - """Test that components system works correctly with hardware modeling.""" + """Test component count and the type/capex/power of each component.""" components_lib = helper.scenario.components_library - # Validate component library has expected components expected_components = SCENARIO_4_COMPONENT_EXPECTATIONS assert len(components_lib.components) == expected_components["total_components"] @@ -94,8 +90,8 @@ def test_components_system_integration(self, helper): assert server.capex == 12000.0 def test_component_references_in_nodes(self, helper): - """Test that nodes correctly reference components from the library.""" - # Test ToR switch nodes have correct component references + """Test that ToR and server nodes reference their hardware components.""" + # ToR switch nodes reference ToRSwitch48p tor_nodes = [ node for node in helper.network.nodes.values() @@ -110,7 +106,7 @@ def test_component_references_in_nodes(self, helper): ) == "ToRSwitch48p" assert tor_node.attrs.get("role") == "top_of_rack" - # Test server nodes have correct component references + # Server nodes reference ServerNode server_nodes = [ node for node in helper.network.nodes.values() @@ -127,7 +123,7 @@ def test_component_references_in_nodes(self, helper): def test_bracket_expansion_functionality(self, helper): """Test that bracket expansion creates expected node hierarchies.""" - # Test DC bracket expansion: dc[1-2] - look for actual node patterns + # DC bracket expansion: dc[1-2] all_nodes = list(helper.network.nodes.keys()) dc1_nodes = [node for node in all_nodes if node.startswith("dc1")] @@ -140,7 +136,7 @@ def test_bracket_expansion_functionality(self, helper): f"dc2 bracket expansion should create nodes. Found nodes: {all_nodes[:10]}" ) - # Test pod bracket expansion: pod[a,b] - look for actual patterns + # Pod bracket expansion: pod[a,b] poda_nodes = [node for node in all_nodes if "poda" in node] podb_nodes = [node for node in all_nodes if "podb" in node] @@ -151,50 +147,41 @@ def test_bracket_expansion_functionality(self, helper): f"podb should have nodes from bracket expansion. Found: {podb_nodes[:5]}" ) - # Test rack bracket expansion: rack[01-02] - check actual rack names with underscore + # Rack bracket expansion: rack[01-02] (names contain "_rack") rack_nodes = [node for node in all_nodes if "_rack" in node] assert len(rack_nodes) > 0, ( f"racks should have nodes from bracket expansion. Found: {rack_nodes[:5]}" ) - def test_variable_expansion_adjacency(self, helper): - """Test that variable expansion in adjacency rules creates correct connections.""" - # Test leaf-spine connections created by variable expansion in blueprint - leaf_spine_links = helper.network.find_links( - source_regex=r".*/fabric/leaf/.*", target_regex=r".*/fabric/spine/.*" - ) + def test_variable_expansion_links(self, helper): + """Test fabric and rack-to-fabric links created by variable expansion.""" + links = helper.network.links.values() - # Check if any leaf-spine links exist at all - if len(leaf_spine_links) == 0: - # Try alternative patterns - the fabric might be flattened - fabric_links = helper.network.find_links( - source_regex=r".*fabric.*", target_regex=r".*fabric.*" - ) - assert len(fabric_links) > 0, ( - f"Should have some fabric-related links from variable expansion. " - f"All links: {[(link.source, link.target) for link in list(helper.network.links.values())[:10]]}" - ) - else: - # Verify some links have expected attributes if they exist - for link in leaf_spine_links[:5]: # Check first few - assert link.capacity == 400.0 - assert link.attrs.get("media_type") == "fiber" - assert link.attrs.get("link_type") == "leaf_spine" - - # Test rack-to-fabric connections from top-level variable expansion - rack_fabric_links = helper.network.find_links( - source_regex=r".*rack.*tor.*", target_regex=r".*fabric.*" - ) - - # If no rack-fabric links, at least verify basic connectivity - if len(rack_fabric_links) == 0: - total_links = len(helper.network.links) - assert total_links > 0, ( - "Should have some connections from variable expansion" - ) + # Blueprint expand block: 2 leaves x 2 spines in each of the 2 fabrics. + leaf_spine = [ + link for link in links if link.attrs.get("link_type") == "leaf_spine" + ] + assert len(leaf_spine) == 8 + for link in leaf_spine: + assert re.fullmatch(r"dc[12]_fabric/leaf/leaf-[12]", link.source) + assert re.fullmatch(r"dc[12]_fabric/spine/spine-[12]", link.target) + assert link.source.split("/")[0] == link.target.split("/")[0] + assert link.capacity == 400.0 + assert link.attrs.get("media_type") == "fiber" + + # Top-level expand block: 8 racks, each ToR wired to both leaves of its DC. + rack_fabric = [ + link + for link in links + if link.attrs.get("connection_type") == "rack_to_fabric" + ] + assert len(rack_fabric) == 16 + for link in rack_fabric: + assert re.fullmatch(r"dc[12]_pod[ab]_rack[12]/tor/tor-1", link.source) + assert link.target.startswith(link.source[:3] + "_fabric/leaf/") - def test_complex_node_overrides(self, helper): - """Test complex node override patterns and cleaned-up attributes.""" + def test_complex_node_rules(self, helper): + """Test GPU server node_rules and role/hardware attrs on servers and ToRs.""" # Test GPU server overrides for specific nodes gpu_server_groups = helper.network.select_node_groups_by_path( r"dc1_pod[ab]_rack[12]/servers/srv-[1-4]" @@ -233,7 +220,7 @@ def test_complex_node_overrides(self, helper): assert tor.attrs.get("role") == "top_of_rack" assert (tor.attrs.get("hardware") or {}).get("component") == "ToRSwitch48p" - def test_complex_link_overrides(self, helper): + def test_complex_link_rules(self, helper): """Test complex link override patterns with regex.""" # Test inter-DC link capacity overrides inter_dc_links = helper.network.find_links( @@ -257,11 +244,10 @@ def test_complex_link_overrides(self, helper): assert link.capacity == 200.0 def test_risk_groups_integration(self, helper): - """Test that risk groups are correctly configured and hierarchical.""" + """Test risk group names, Building_DC1 children, and spine membership.""" risk_groups = helper.scenario.network.risk_groups expected_groups = SCENARIO_4_RISK_GROUP_EXPECTATIONS["risk_groups"] - # Validate expected risk groups exist risk_group_names = {rg.name for rg in risk_groups.values()} for expected_group in expected_groups: assert expected_group in risk_group_names, ( @@ -290,7 +276,7 @@ def test_risk_groups_integration(self, helper): ) def test_traffic_matrix_configuration(self, helper): - """Test that traffic matrices are correctly configured.""" + """Test demand set sizes and the mode used for each traffic_type.""" traffic_expectations = SCENARIO_4_TRAFFIC_EXPECTATIONS # Test default matrix @@ -312,10 +298,9 @@ def test_traffic_matrix_configuration(self, helper): assert demand.mode == "combine" def test_failure_policy_configuration(self, helper): - """Test that failure policies are correctly configured.""" + """Test the policy count and the single-rule link and node failure policies.""" failure_expectations = SCENARIO_4_FAILURE_POLICY_EXPECTATIONS - # Test total number of policies all_policies = helper.scenario.failure_policy_set.policies assert len(all_policies) == failure_expectations["total_policies"] @@ -333,57 +318,46 @@ def test_failure_policy_configuration(self, helper): assert sum(len(m.rules) for m in single_node_policy.modes) == 1 def test_advanced_workflow_steps(self, helper): - """Test that advanced workflow steps executed correctly.""" - results = helper.scenario.results - - # Test BuildGraph step - correct API usage with two arguments - exported = results.to_dict() - # graph = node_link_to_graph( - # exported["steps"]["build_graph"]["data"].get("graph") - # ) - # assert graph is not None - - # Test MaxFlow results - check baseline (no failure policy) or flow_results - intra_dc = ( - exported["steps"].get("intra_dc_capacity_forward", {}).get("data", {}) - ) - intra_result = ( - intra_dc.get("baseline") or (intra_dc.get("flow_results", []) or [None])[0] - ) - assert intra_result, ( - "Intra-DC forward capacity analysis should have baseline or flow_results" - ) - assert float(intra_result["summary"].get("total_placed", 0.0)) >= 0.0 - - inter_dc = ( - exported["steps"].get("inter_dc_capacity_forward", {}).get("data", {}) - ) - inter_result = ( - inter_dc.get("baseline") or (inter_dc.get("flow_results", []) or [None])[0] - ) - assert inter_result, ( - "Inter-DC forward capacity analysis should have baseline or flow_results" - ) - assert float(inter_result["summary"].get("total_placed", 0.0)) >= 0.0 - - rack_failure = ( - exported["steps"].get("rack_failure_analysis", {}).get("data", {}) - ) - rack_result = ( - rack_failure.get("baseline") - or (rack_failure.get("flow_results", []) or [None])[0] - ) - assert rack_result, "Rack failure analysis should have baseline or flow_results" + """Test the capacities the MaxFlow steps report. + + Every rack has 8 servers on 25-unit links, which bind before any uplink: + pod-to-pod inside dc1 is 16 servers x 25 = 400 in either direction, and + dc1 to dc2 is limited by dc2's 3 enabled racks (dc2_podb_rack2 is + disabled) to 24 x 25 = 600. A single failure removes at most one + server's 25 units. + """ + steps = helper.scenario.results.to_dict()["steps"] + + def baseline_total(step: str) -> float: + return steps[step]["data"]["baseline"]["summary"]["total_placed"] + + def failure_totals(step: str) -> list[float]: + return [ + r["summary"]["total_placed"] + for r in steps[step]["data"]["flow_results"] + ] + + assert baseline_total("intra_dc_capacity_forward") == pytest.approx(400.0) + assert baseline_total("intra_dc_capacity_reverse") == pytest.approx(400.0) + assert baseline_total("inter_dc_capacity_forward") == pytest.approx(600.0) + assert baseline_total("inter_dc_capacity_reverse") == pytest.approx(600.0) + + assert baseline_total("rack_failure_analysis") == pytest.approx(400.0) + rack = failure_totals("rack_failure_analysis") + assert rack and all(375.0 - 1e-9 <= t <= 400.0 + 1e-9 for t in rack) + + assert baseline_total("spine_failure_analysis") == pytest.approx(600.0) + spine = failure_totals("spine_failure_analysis") + assert spine and all(575.0 - 1e-9 <= t <= 600.0 + 1e-9 for t in spine) def test_network_explorer_integration(self, helper): - """Test NetworkExplorer functionality with complex hierarchy.""" + """Test NetworkExplorer totals: at least 80 nodes, positive capex and power.""" explorer = NetworkExplorer.explore_network( helper.network, helper.scenario.components_library ) assert explorer.root_node is not None - # Verify reasonable network size for test scenario assert ( explorer.root_node.stats.node_count >= 80 ) # Should have substantial node count @@ -393,10 +367,9 @@ def test_network_explorer_integration(self, helper): assert explorer.root_node.stats.total_power > 0 def test_topology_semantic_correctness(self, helper): - """Test semantic correctness of the complex topology.""" + """Test edge attributes and that the graph has at most 20 weak components.""" helper.validate_topology_semantics() - # Additional semantic checks for advanced scenario # Allow for disconnected components due to disabled nodes and variable expansion import networkx as nx @@ -411,8 +384,7 @@ def test_topology_semantic_correctness(self, helper): ) def test_blueprint_nesting_depth(self, helper): - """Test that blueprint nesting works correctly.""" - # Verify that nested node names are correct (adjusted for actual structure) + """Test that nested nodes have at least three path levels starting with dc.""" all_nodes = list(helper.network.nodes.keys()) nested_nodes = [ node @@ -424,7 +396,6 @@ def test_blueprint_nesting_depth(self, helper): f"Should have nested nodes. Found: {all_nodes[:10]}" ) - # Verify naming convention is consistent for node_name in nested_nodes[:10]: # Check first few parts = node_name.split("/") assert len(parts) >= 3 # dc/pod/rack or similar @@ -432,10 +403,9 @@ def test_blueprint_nesting_depth(self, helper): def test_regex_pattern_matching_complexity(self, helper): """Test complex regex patterns in overrides and selections.""" - # Test complex node selection patterns using available API all_nodes = list(helper.network.nodes.keys()) - # Find GPU pattern nodes manually since select_nodes_by_path doesn't exist + # Substring filter for dc1 rack server nodes gpu_pattern_nodes = [ node for node in all_nodes @@ -453,18 +423,16 @@ def test_regex_pattern_matching_complexity(self, helper): assert len(inter_dc_pattern_links) > 0, "Complex link patterns should match" def test_edge_case_handling(self, helper): - """Test edge cases and boundary conditions in complex scenario.""" - # Test disabled node handling (may be enabled by workflow steps) - # Test disabled node handling - # Test empty group handling (if any) - all_nodes = list(helper.network.nodes.keys()) - assert len(all_nodes) > 0, "Should have some nodes" - - # Test node count consistency - allow for larger differences due to disabled nodes and workflow operations - total_nodes = len(helper.network.nodes) - graph_nodes = len(helper.graph.nodes) - node_diff = abs(total_nodes - graph_nodes) - assert node_diff <= 15, ( - f"Network ({total_nodes}) and graph ({graph_nodes}) node counts should be close. " - f"Difference: {node_diff} (some nodes may be disabled and excluded from graph)" - ) + """Test that the exported graph keeps disabled nodes, flagged as disabled. + + BuildGraph exports every node; the 9 nodes of dc2_podb_rack2 (ToR plus + 8 servers) carry ``disabled: True``. + """ + network_nodes = helper.network.nodes + assert set(helper.graph.nodes) == set(network_nodes) + + disabled = {name for name, node in network_nodes.items() if node.disabled} + assert len(disabled) == 9 + assert all(name.startswith("dc2_podb_rack2/") for name in disabled) + for name in network_nodes: + assert helper.graph.nodes[name]["disabled"] is (name in disabled) diff --git a/tests/integration/test_template_examples.py b/tests/integration/test_template_examples.py index 049bc42..1509538 100644 --- a/tests/integration/test_template_examples.py +++ b/tests/integration/test_template_examples.py @@ -1,8 +1,5 @@ """ -Example tests demonstrating the use of modular test data templates. - -Shows how the template system improves test data organization, reduces -duplication, and enables rapid creation of test scenarios. +Tests for the scenario data templates in test_data_templates. """ import pytest @@ -27,7 +24,7 @@ @pytest.mark.slow class TestNetworkTemplates: - """Keep only a minimal sanity check for templates; detailed tests belong to unit level.""" + """Shape checks for the topology templates.""" def test_linear_network_template_minimal(self): nodes = ["A", "B", "C", "D"] @@ -36,12 +33,11 @@ def test_linear_network_template_minimal(self): assert len(network_data["links"]) == 3 def test_star_network_template(self): - """Test star network template creates correct topology.""" + """Test that the star template links the center to each leaf.""" center = "HUB" leaves = ["A", "B", "C"] network_data = NetworkTemplates.star_network(center, leaves, link_capacity=20.0) - # Validate structure assert len(network_data["nodes"]) == 4 # center + 3 leaves assert len(network_data["links"]) == 3 # center connected to each leaf @@ -56,7 +52,6 @@ def test_mesh_network_template(self): nodes = ["A", "B", "C"] network_data = NetworkTemplates.mesh_network(nodes, link_capacity=5.0) - # Validate structure assert len(network_data["nodes"]) == 3 assert len(network_data["links"]) == 6 # 3 nodes = 3*2 = 6 directed links @@ -159,38 +154,30 @@ class TestTemplateComposition: def test_combining_multiple_templates(self): """Test combining different template types in one scenario.""" - # Create a complex scenario using multiple templates builder = ScenarioTemplateBuilder("complex_test", "1.0") - # Add a linear backbone backbone_nodes = ["A", "B", "C"] backbone_data = NetworkTemplates.linear_network(backbone_nodes, 50.0) builder.builder.data["network"] = backbone_data builder.builder.data["network"]["name"] = "complex_test" builder.builder.data["network"]["version"] = "1.0" - # Add Clos fabric blueprint clos_blueprint = BlueprintTemplates.two_tier_blueprint(4, 4, "mesh", 25.0) builder.builder.with_blueprint("clos", clos_blueprint) - # Add traffic demands demands = TrafficDemandTemplates.all_to_all_uniform(backbone_nodes, 10.0) builder.builder.data["demands"] = {"default": demands} - # Add failure policy policy = FailurePolicyTemplates.single_link_failure() builder.builder.with_failure_policy("single_link", policy) - # Add workflow workflow = WorkflowTemplates.capacity_analysis_workflow("A", "C") builder.builder.data["workflow"] = workflow - # Build and test yaml_content = builder.build() scenario = Scenario.from_yaml(yaml_content) scenario.run() - # Validate the complex scenario works helper = create_scenario_helper(scenario) exported = scenario.results.to_dict() graph_dict = exported["steps"]["build_graph"]["data"]["graph"] @@ -203,7 +190,7 @@ def test_combining_multiple_templates(self): assert len(scenario.failure_policy_set.get_all_policies()) > 0 def test_template_parameterization(self): - """Test that templates can be easily parameterized for different scales.""" + """Test linear backbones at three sizes: node count and link capacity.""" scales = [ {"nodes": 3, "capacity": 10.0}, {"nodes": 5, "capacity": 50.0}, @@ -213,14 +200,13 @@ def test_template_parameterization(self): for scale in scales: nodes = [f"N{i}" for i in range(scale["nodes"])] - # Build scenario with explicit workflow step builder = ScenarioTemplateBuilder(f"scale_test_{scale['nodes']}", "1.0") builder.with_linear_backbone( nodes, scale["capacity"], add_coordinates=False ) builder.with_uniform_traffic(nodes, demand_value=scale["capacity"] / 10) - # Ensure BuildGraph step is included + # build_yaml adds BuildGraph only to a non-empty workflow builder.builder.with_workflow_step("BuildGraph", "build_graph") yaml_content = builder.build() @@ -238,19 +224,17 @@ def test_template_parameterization(self): ) assert len(graph.nodes) == scale["nodes"] - # Validate link capacities match scale for _u, _v, data in graph.edges(data=True): assert data.get("capacity") == scale["capacity"] @pytest.mark.slow class TestTemplateValidation: - """Tests for template validation and error handling.""" + """Template behavior for degenerate parameters and repeated calls.""" def test_template_parameter_validation(self): - """Test that templates validate parameters appropriately.""" - # Test edge case parameters that should work (NetGraph is permissive) - # Empty node list should work (creates empty network) + """Test that templates accept empty, zero and negative parameters as given.""" + # Empty node list gives an empty network network_empty = NetworkTemplates.linear_network([]) assert network_empty["nodes"] == {} assert network_empty["links"] == [] @@ -259,18 +243,16 @@ def test_template_parameter_validation(self): blueprint_zero = BlueprintTemplates.two_tier_blueprint(tier1_count=0) assert blueprint_zero["nodes"]["tier1"]["count"] == 0 - # Negative demands might be allowed in NetGraph - test actual behavior + # Templates pass negative volumes through unchanged demands_negative = TrafficDemandTemplates.all_to_all_uniform( ["A", "B"], demand_value=-5.0 ) - # Should create demands but with negative values assert len(demands_negative) == 2 # A->B and B->A for demand in demands_negative: assert demand["volume"] == -5.0 def test_template_consistency(self): - """Test that templates produce consistent results.""" - # Same parameters should produce same results + """Test that templates are deterministic for equal parameters.""" nodes = ["X", "Y", "Z"] network1 = NetworkTemplates.linear_network(nodes, 15.0) @@ -289,8 +271,7 @@ class TestMainScenarioVariants: """Template-based variants of main scenarios for testing different configurations.""" def test_scenario_1_template_variant(self): - """Template-based recreation of scenario 1 functionality.""" - # Recreate scenario 1 using templates + """Rebuild scenario 1 from templates; check SCENARIO_1_EXPECTATIONS.""" backbone_nodes = ["SEA", "SFO", "DEN", "DFW", "JFK", "DCA"] builder = ScenarioTemplateBuilder("scenario_1_template", "1.0") @@ -360,11 +341,9 @@ def test_scenario_1_template_variant(self): ) builder.builder.with_failure_policy("single_link", policy) - # Add workflow workflow = WorkflowTemplates.basic_build_workflow() builder.builder.data["workflow"] = workflow - # Test the template-based scenario scenario = builder.builder.build_scenario() scenario.run() @@ -376,12 +355,11 @@ def test_scenario_1_template_variant(self): graph = nx.node_link_graph(graph_dict, edges="edges") helper.set_graph(graph) - # Validate it matches scenario 1 expectations helper.validate_network_structure(SCENARIO_1_EXPECTATIONS) helper.validate_traffic_demands(4) def test_scenario_2_template_variant(self): - """Template-based recreation of scenario 2 blueprint functionality.""" + """Rebuild scenario 2's blueprints from templates; check expanded size.""" builder = ScenarioTemplateBuilder("scenario_2_template", "1.0") # Create blueprints matching scenario 2 @@ -525,7 +503,6 @@ def test_scenario_2_template_variant(self): workflow = WorkflowTemplates.basic_build_workflow() builder.builder.data["workflow"] = workflow - # Test the template-based scenario scenario = builder.builder.build_scenario() scenario.run() @@ -536,12 +513,12 @@ def test_scenario_2_template_variant(self): graph = nx.node_link_graph(graph_dict, edges="edges") - # Validate basic structure (exact match would require complex blueprint logic) + # Loose bound: the template blueprints do not reproduce scenario 2 exactly assert len(graph.nodes) > 15 # Should have many nodes from blueprint expansion helper.validate_traffic_demands(4) def test_scenario_3_template_variant(self): - """Template-based recreation of scenario 3 Clos functionality.""" + """Rebuild scenario 3's Clos blueprints; check SCENARIO_3_EXPECTATIONS.""" builder = ScenarioTemplateBuilder("scenario_3_template", "1.0") # Create brick_2tier blueprint @@ -636,7 +613,6 @@ def test_scenario_3_template_variant(self): ] builder.builder.data["workflow"] = workflow - # Test the template-based scenario scenario = builder.builder.build_scenario() scenario.run() @@ -648,7 +624,6 @@ def test_scenario_3_template_variant(self): graph = nx.node_link_graph(graph_dict, edges="edges") helper.set_graph(graph) - # Validate basic structure matches scenario 3 helper.validate_network_structure(SCENARIO_3_EXPECTATIONS) helper.validate_traffic_demands(0) # No traffic demands in scenario 3 @@ -679,7 +654,6 @@ def test_parameterized_backbone_scenarios(self): scenario = Scenario.from_yaml(scenario_yaml) scenario.run() - # Validate each configuration helper = create_scenario_helper(scenario) exported = scenario.results.to_dict() graph_dict = exported["steps"]["build_graph"]["data"]["graph"] @@ -687,7 +661,6 @@ def test_parameterized_backbone_scenarios(self): graph = nx.node_link_graph(graph_dict, edges="edges") - # Check for None graph and provide better error message assert graph is not None, ( f"Build graph failed for configuration {i}: {config}" ) @@ -700,6 +673,5 @@ def test_parameterized_backbone_scenarios(self): assert len(graph.nodes) == expected_nodes assert len(graph.edges) == expected_edges - # Validate link capacities for _u, _v, data in graph.edges(data=True): assert data.get("capacity") == config["capacity"] diff --git a/tests/lib/test_nx.py b/tests/lib/test_nx.py index 77e5bac..a0e57ed 100644 --- a/tests/lib/test_nx.py +++ b/tests/lib/test_nx.py @@ -189,7 +189,6 @@ def test_bidirectional_adds_reverse_edges(self): assert graph.num_edges() == 2 # forward + reverse assert len(edge_map) == 2 # Both map to same original ref - # Verify both directions exist src_arr = graph.edge_src_view() dst_arr = graph.edge_dst_view() edges = set(zip(src_arr.tolist(), dst_arr.tolist(), strict=True)) @@ -292,7 +291,6 @@ def test_roundtrip_simple_digraph(self): assert set(G_out.nodes()) == set(G_in.nodes()) assert G_out.number_of_edges() == G_in.number_of_edges() - # Check edge attributes assert G_out["A"]["B"][0]["capacity"] == 100.0 assert G_out["A"]["B"][0]["cost"] == 10 assert G_out["B"]["C"][0]["capacity"] == 50.0 @@ -363,7 +361,7 @@ def test_nodes_preserved_when_no_edges(self): class TestMappingCorrectness: - """Tests that verify mappings are functionally correct.""" + """NodeMap and EdgeMap agree with Core graph node indices and edge IDs.""" def test_node_map_matches_core_graph_indices(self): """NodeMap indices correspond to actual Core graph node indices.""" @@ -373,7 +371,6 @@ def test_node_map_matches_core_graph_indices(self): graph, node_map, edge_map = from_networkx(G) - # Get actual edge data from Core graph src_arr = graph.edge_src_view() dst_arr = graph.edge_dst_view() @@ -387,7 +384,6 @@ def test_node_map_matches_core_graph_indices(self): assert len(xy_edge_ids) == 1 xy_core_idx = xy_edge_ids[0] - # Verify Core graph has correct src/dst for this edge assert int(src_arr[xy_core_idx]) == x_idx assert int(dst_arr[xy_core_idx]) == y_idx @@ -410,9 +406,7 @@ def test_edge_map_matches_ext_edge_ids(self): # Each ext_edge_id should be the key in edge_map.to_ref for core_idx in range(graph.num_edges()): ext_id = int(ext_ids[core_idx]) - # ext_id should exist in edge_map.to_ref assert ext_id in edge_map.to_ref - # And should point back to valid edge reference u, v, key = edge_map.to_ref[ext_id] assert G.has_edge(u, v) @@ -433,10 +427,8 @@ def test_roundtrip_edge_attributes_via_edge_map(self): for core_idx in range(graph.num_edges()): ext_id = int(graph.ext_edge_ids_view()[core_idx]) u, v, key = edge_map.to_ref[ext_id] - # Write some computed value back G.edges[u, v, key]["computed_capacity"] = float(capacity_arr[core_idx]) - # Verify values were written correctly assert G.edges["S", "A", 0]["computed_capacity"] == 100.0 assert G.edges["A", "T", 0]["computed_capacity"] == 100.0 assert G.edges["S", "B", 0]["computed_capacity"] == 50.0 @@ -449,10 +441,8 @@ def test_bidirectional_edge_map_both_directions_work(self): graph, node_map, edge_map = from_networkx(G, bidirectional=True) - # Both edges should map to same original edge assert graph.num_edges() == 2 - # Get src/dst for both edges src_arr = graph.edge_src_view() dst_arr = graph.edge_dst_view() @@ -486,7 +476,6 @@ def test_spf_on_converted_graph(self): """SPF algorithm works on converted graph.""" import netgraph_core - # Create NetworkX graph G = nx.DiGraph() G.add_edge("A", "B", capacity=100.0, cost=10) G.add_edge("B", "C", capacity=100.0, cost=10) @@ -494,7 +483,6 @@ def test_spf_on_converted_graph(self): graph, node_map, _ = from_networkx(G) - # Run SPF backend = netgraph_core.Backend.cpu() algorithms = netgraph_core.Algorithms(backend) handle = algorithms.build_graph(graph) @@ -511,7 +499,6 @@ def test_max_flow_on_converted_graph(self): """Max flow algorithm works on converted graph.""" import netgraph_core - # Create NetworkX graph with capacity constraints G = nx.DiGraph() G.add_edge("S", "A", capacity=10.0, cost=1) G.add_edge("S", "B", capacity=10.0, cost=1) @@ -520,7 +507,6 @@ def test_max_flow_on_converted_graph(self): graph, node_map, _ = from_networkx(G) - # Run max flow backend = netgraph_core.Backend.cpu() algorithms = netgraph_core.Algorithms(backend) handle = algorithms.build_graph(graph) @@ -564,14 +550,12 @@ def test_edge_map_flow_attribution(self): """EdgeMap enables flow attribution back to original edges.""" import netgraph_core - # Create NetworkX graph G = nx.MultiDiGraph() G.add_edge("S", "T", capacity=100.0, cost=1, key="link1") G.add_edge("S", "T", capacity=50.0, cost=2, key="link2") graph, node_map, edge_map = from_networkx(G) - # Run max flow backend = netgraph_core.Backend.cpu() algorithms = netgraph_core.Algorithms(backend) handle = algorithms.build_graph(graph) @@ -598,7 +582,6 @@ def test_edge_map_flow_attribution(self): ref = edge_map.to_ref[edge_id] flow_by_ref[ref] = flow - # Verify we can identify which original edges got flow assert len(flow_by_ref) > 0 for ref in flow_by_ref: u, v, key = ref diff --git a/tests/lib/test_nx_regressions.py b/tests/lib/test_nx_regressions.py index 51febdc..80bd4bb 100644 --- a/tests/lib/test_nx_regressions.py +++ b/tests/lib/test_nx_regressions.py @@ -1,4 +1,4 @@ -"""Regression tests for ngraph.lib.nx conversion fixes. +"""Regression tests for ngraph.lib.nx conversion. Covers: - Fractional edge costs raise ValueError instead of silent int() truncation. @@ -122,7 +122,7 @@ def test_directed_graph_default_single_arc(self): assert len(edge_map) == 1 def test_directed_explicit_true_adds_reverse(self): - """Explicit bidirectional=True still works for directed inputs.""" + """Explicit bidirectional=True adds a reverse arc for directed inputs.""" G = nx.DiGraph() G.add_edge("A", "B", capacity=10.0, cost=1) @@ -146,7 +146,7 @@ def test_multigraph_default_creates_arc_pairs_per_parallel_edge(self): class TestDocstringExampleSemantics: - """Pin the corrected from_networkx docstring example outputs.""" + """Pin the from_networkx docstring example outputs.""" def test_node_indices_sorted_edge_refs_original_orientation(self): """Node indices follow sorted names; edge refs keep (u, v, key).""" diff --git a/tests/logging/test_library_logging_pattern.py b/tests/logging/test_library_logging_pattern.py index 14edf53..6b248ee 100644 --- a/tests/logging/test_library_logging_pattern.py +++ b/tests/logging/test_library_logging_pattern.py @@ -1,9 +1,8 @@ """Regression tests for the library logging pattern (NullHandler at import). -Importing ngraph must not install stream handlers or set levels: a bare -``import ngraph`` previously attached a StreamHandler(sys.stdout) to the -'ngraph' logger, duplicating records in host applications and corrupting -machine-readable stdout (``ngraph run --stdout``). +Importing ngraph must not install stream handlers or set levels: a +StreamHandler(sys.stdout) on the 'ngraph' logger would duplicate records in +host applications and corrupt machine-readable stdout (``ngraph run --stdout``). """ import io diff --git a/tests/logging/test_logging.py b/tests/logging/test_logging.py index 15aec94..a2f4bbd 100644 --- a/tests/logging/test_logging.py +++ b/tests/logging/test_logging.py @@ -1,4 +1,4 @@ -"""Tests for centralized logging behavior and configuration.""" +"""Tests for ngraph.logging configuration and handler setup.""" import logging from io import StringIO @@ -6,8 +6,6 @@ import pytest from ngraph.logging import ( - disable_debug_logging, - enable_debug_logging, get_logger, reset_logging, set_global_log_level, @@ -45,14 +43,14 @@ def test_effective_levels_enable_disable(): assert "debug-1" not in capture.getvalue() # After enabling debug, DEBUG should pass - enable_debug_logging() + set_global_log_level(logging.DEBUG) logger.debug("debug-2") assert "debug-2" in capture.getvalue() # After disabling debug, DEBUG should be filtered again capture.seek(0) capture.truncate(0) - disable_debug_logging() + set_global_log_level(logging.INFO) logger.debug("debug-3") assert "debug-3" not in capture.getvalue() @@ -97,7 +95,7 @@ def test_setup_root_logger_idempotent_no_duplicate_handlers(): def test_custom_format_string_applied(): """Custom format string is respected by the root handler.""" - # Ensure clean state, then install a custom format and handler + # Install a custom format and handler capture = StringIO() handler = logging.StreamHandler(capture) fmt = "LEVEL:%(levelname)s|NAME:%(name)s|MSG:%(message)s" diff --git a/tests/model/components/test_components.py b/tests/model/components/test_components.py index d8eec1d..b3584cd 100644 --- a/tests/model/components/test_components.py +++ b/tests/model/components/test_components.py @@ -99,7 +99,6 @@ def test_component_as_dict() -> None: attrs={"location": "rack1"}, ) - # Include children parent_dict_incl = parent.as_dict(include_children=True) assert parent_dict_incl["name"] == "Parent" assert parent_dict_incl["capex"] == 100.0 @@ -110,28 +109,24 @@ def test_component_as_dict() -> None: assert parent_dict_incl["children"]["Child"]["name"] == "Child" assert parent_dict_incl["children"]["Child"]["capex"] == 10.0 - # Exclude children parent_dict_excl = parent.as_dict(include_children=False) assert parent_dict_excl["name"] == "Parent" assert "children" not in parent_dict_excl -def test_components_library_from_yaml_attrs_and_leftovers() -> None: - """Unknown component fields are merged into attrs; YAML path covered.""" +def test_components_library_from_yaml_attrs() -> None: + """Custom data lives under attrs; YAML path covered.""" yaml_str = """ components: Mod: component_type: module - cost: 3 attrs: vendor: acme - custom_field: value """ lib = ComponentsLibrary.from_yaml(yaml_str) comp = lib.get("Mod") assert comp is not None - assert comp.attrs["vendor"] == "acme" - assert comp.attrs["custom_field"] == "value" + assert comp.attrs == {"vendor": "acme"} def test_components_library_merge_override_true() -> None: @@ -156,11 +151,9 @@ def test_components_library_merge_override_true() -> None: ) lib1.merge(lib2, override=True) - # The "Overlap" component is replaced by lib2's version (cost=200). + # The "Overlap" component is replaced by lib2's version (capex=200). assert lib1.get("Overlap") is new_comp - # The merged library includes components from lib2. assert "UniqueLib2" in lib1.components - # The original unique component remains. assert "UniqueLib1" in lib1.components @@ -186,9 +179,8 @@ def test_components_library_merge_override_false() -> None: ) lib1.merge(lib2, override=False) - # The "Overlap" component remains the original (cost=100). + # The "Overlap" component remains the original (capex=100). assert lib1.get("Overlap") is original_comp - # The merged library includes components from lib2. assert "UniqueLib2" in lib1.components @@ -367,9 +359,8 @@ def test_components_library_yaml_boolean_keys(): assert my_chassis is not None assert my_chassis.capex == 1000 - # All true-like YAML values become "True" component (last one wins) - # NOTE: When multiple YAML keys collapse to the same boolean value, - # only the last one wins (standard YAML/dict behavior) + # All true-like YAML values become the "True" component. When several keys + # collapse to the same boolean, the last one wins (standard YAML/dict behavior). true_comp = lib.get("True") assert true_comp is not None assert true_comp.component_type == "module" # from 'on:', the last true-like key @@ -383,7 +374,7 @@ def test_components_library_yaml_boolean_keys(): def test_components_library_yaml_boolean_child_keys(): - """Test that YAML boolean keys in child components are handled correctly.""" + """YAML boolean keys in child components become "True"/"False" names.""" yaml_str = """ components: ParentChassis: @@ -435,7 +426,7 @@ def test_components_library_yaml_boolean_child_keys(): def test_helper_resolve_and_totals_with_multiplier() -> None: - """Helpers return component and apply count multiplier correctly.""" + """Helpers resolve the node's component and multiply its totals by count.""" from ngraph.model.components import resolve_node_hardware, totals_with_multiplier lib = ComponentsLibrary() @@ -450,3 +441,32 @@ def test_helper_resolve_and_totals_with_multiplier() -> None: assert capex == 15.0 assert power == 6.0 assert capacity == 30.0 + + +@pytest.mark.parametrize("count", [0, -1, "2", True, float("inf"), float("nan")]) +def test_hardware_count_must_be_positive_number(count) -> None: + """Invalid hardware counts raise instead of silently becoming 1.""" + from ngraph.model.components import ( + resolve_link_end_components, + resolve_node_hardware, + ) + + lib = ComponentsLibrary() + with pytest.raises(ValueError, match="must be a finite positive number"): + resolve_node_hardware({"hardware": {"component": "box", "count": count}}, lib) + with pytest.raises(ValueError, match="must be a finite positive number"): + resolve_link_end_components( + {"hardware": {"source": {"component": "o", "count": count}}}, lib + ) + + +def test_as_dict_round_trips_through_from_dict() -> None: + """as_dict output (which carries 'name') loads back via from_dict.""" + child = Component(name="Card", capex=5.0) + parent = Component(name="Chassis", capex=10.0, children={"Card": child}) + lib = ComponentsLibrary.from_dict({"Chassis": parent.as_dict()}) + rebuilt = lib.get("Chassis") + assert rebuilt is not None + assert rebuilt.total_capex() == parent.total_capex() + with pytest.raises(ValueError, match="mismatched name"): + ComponentsLibrary.from_dict({"Other": parent.as_dict()}) diff --git a/tests/model/components/test_components_yaml_edge_cases.py b/tests/model/components/test_components_yaml_edge_cases.py index 62aba32..3919a81 100644 --- a/tests/model/components/test_components_yaml_edge_cases.py +++ b/tests/model/components/test_components_yaml_edge_cases.py @@ -1,11 +1,13 @@ """Edge-case tests for ComponentsLibrary YAML parsing. Covers presence-based dispatch of the top-level 'components' key and the -warning emitted when a component definition uses 'cost' instead of 'capex'. +rejection of keys outside the component fields. """ import logging +import pytest + from ngraph.model.components import ComponentsLibrary @@ -32,28 +34,20 @@ def test_from_yaml_empty_components_ignores_sibling_keys() -> None: assert lib.components == {} -def test_build_component_warns_on_cost_key(caplog) -> None: - """A leftover 'cost' key logs a warning and contributes 0 to capex.""" +def test_build_component_rejects_unknown_keys() -> None: + """Keys outside the component fields raise; 'cost' gets a capex hint.""" yaml_str = """ components: Switch: component_type: chassis cost: 20000 """ - with caplog.at_level(logging.WARNING, logger="ngraph.model.components"): - lib = ComponentsLibrary.from_yaml(yaml_str) + with pytest.raises(ValueError, match="unrecognized key.*cost.*Use 'capex'"): + ComponentsLibrary.from_yaml(yaml_str) - comp = lib.get("Switch") - assert comp is not None - assert comp.capex == 0.0 - assert comp.attrs["cost"] == 20000 - assert any( - "'cost'" in record.message - and "Switch" in str(record.args or ()) - or "Switch" in record.getMessage() - for record in caplog.records - ) - assert any("capex" in record.getMessage() for record in caplog.records) + nested = {"Chassis": {"children": {"Card": {"vendor": "x"}}}} + with pytest.raises(ValueError, match="Component 'Card'.*vendor"): + ComponentsLibrary.from_dict(nested) def test_build_component_no_warning_with_capex(caplog) -> None: diff --git a/tests/model/demand/test_builder.py b/tests/model/demand/test_builder.py index 0ab9cd7..ddf06bd 100644 --- a/tests/model/demand/test_builder.py +++ b/tests/model/demand/test_builder.py @@ -96,8 +96,8 @@ def test_build_demand_set_with_flow_policy_string(): assert demands[0].flow_policy == FlowPolicyPreset.SHORTEST_PATHS_ECMP -def test_build_demand_set_with_flow_policy_int(): - """Test building with FlowPolicyPreset as integer.""" +def test_build_demand_set_rejects_integer_flow_policy(): + """Presets are named; integer values are not accepted.""" raw = { "tm1": [ { @@ -109,9 +109,8 @@ def test_build_demand_set_with_flow_policy_int(): ] } - tms = build_demand_set(raw) - demands = tms.get_set("tm1") - assert demands[0].flow_policy == FlowPolicyPreset.SHORTEST_PATHS_ECMP + with pytest.raises(ValueError, match="Invalid flow_policy"): + build_demand_set(raw) def test_build_demand_set_invalid_raw_type(): @@ -150,15 +149,6 @@ def test_coerce_flow_policy_enum(): assert coerce_flow_policy(preset) == preset -def test_coerce_flow_policy_int(): - """Test coercing integer to enum.""" - assert coerce_flow_policy(1) == FlowPolicyPreset.SHORTEST_PATHS_ECMP - assert coerce_flow_policy(2) == FlowPolicyPreset.SHORTEST_PATHS_WCMP - assert coerce_flow_policy(3) == FlowPolicyPreset.TE_WCMP_UNLIM - assert coerce_flow_policy(4) == FlowPolicyPreset.TE_ECMP_UP_TO_256_LSP - assert coerce_flow_policy(5) == FlowPolicyPreset.TE_ECMP_16_LSP - - def test_coerce_flow_policy_string(): """Test coercing string to enum.""" assert ( @@ -181,35 +171,18 @@ def test_coerce_flow_policy_string(): assert coerce_flow_policy("TE_ECMP_16_LSP") == FlowPolicyPreset.TE_ECMP_16_LSP -def test_coerce_flow_policy_string_numeric(): - """Test coercing numeric string to enum.""" - assert coerce_flow_policy("1") == FlowPolicyPreset.SHORTEST_PATHS_ECMP - assert coerce_flow_policy("2") == FlowPolicyPreset.SHORTEST_PATHS_WCMP - assert coerce_flow_policy("3") == FlowPolicyPreset.TE_WCMP_UNLIM - - -def test_coerce_flow_policy_empty_string(): - """Test coercing empty string.""" - assert coerce_flow_policy("") is None - assert coerce_flow_policy(" ") is None - - def test_coerce_flow_policy_invalid_string(): - """Test error handling for invalid string.""" - with pytest.raises(ValueError, match="Unknown flow policy"): - coerce_flow_policy("INVALID_POLICY") - + """Unknown, empty, and numeric strings are rejected.""" + for value in ("INVALID_POLICY", "", " ", "1"): + with pytest.raises(ValueError, match="Invalid flow_policy"): + coerce_flow_policy(value) -def test_coerce_flow_policy_invalid_numeric_string(): - """Test error handling for invalid numeric string.""" - with pytest.raises(ValueError, match="Unknown flow policy value"): - coerce_flow_policy("999") - -def test_coerce_flow_policy_invalid_int(): - """Test error handling for invalid integer.""" - with pytest.raises(ValueError, match="Unknown flow policy value"): - coerce_flow_policy(999) +def test_coerce_flow_policy_rejects_int(): + """Integer preset values are not accepted; presets are named.""" + for value in (1, 999): + with pytest.raises(ValueError, match="Invalid flow_policy"): + coerce_flow_policy(value) def test_coerce_flow_policy_rejects_bool(): @@ -265,8 +238,16 @@ def test_build_demand_set_rejects_bool_flow_policy(): def test_coerce_flow_policy_lossy_ecmp_preset(): - assert coerce_flow_policy(6) == FlowPolicyPreset.SHORTEST_PATHS_ECMP_LOSSY assert ( coerce_flow_policy("shortest_paths_ecmp_lossy") == FlowPolicyPreset.SHORTEST_PATHS_ECMP_LOSSY ) + + +def test_build_demand_set_rejects_unknown_keys() -> None: + """A mistyped key (here 'demand' for 'volume') raises instead of volume 0.""" + raw = {"tm1": [{"source": "A", "target": "B", "demand": 5}]} + with pytest.raises( + ValueError, match="Unrecognized key.*demand in set .tm1.: demand" + ): + build_demand_set(raw) diff --git a/tests/model/demand/test_spec.py b/tests/model/demand/test_spec.py index 16a0bf8..5c50b48 100644 --- a/tests/model/demand/test_spec.py +++ b/tests/model/demand/test_spec.py @@ -1,14 +1,7 @@ -import dataclasses +import pytest from ngraph.model.demand.spec import TrafficDemand -from ngraph.model.flow.policy_config import FlowPolicyPreset as FlowPolicyConfig - - -def test_removed_legacy_fields_absent() -> None: - """Dead pre-netgraph-core fields are not part of the dataclass.""" - field_names = {f.name for f in dataclasses.fields(TrafficDemand)} - assert "volume_placed" not in field_names - assert "flow_policy_obj" not in field_names +from ngraph.model.flow.policy_config import FlowPolicyPreset def test_defaults_and_id_generation() -> None: @@ -90,7 +83,7 @@ def test_custom_assignment_including_policy_config() -> None: volume=42.5, attrs={"description": "test"}, mode="pairwise", - flow_policy=FlowPolicyConfig.SHORTEST_PATHS_ECMP, + flow_policy=FlowPolicyPreset.SHORTEST_PATHS_ECMP, ) assert demand.source == "SourceNode" @@ -99,4 +92,11 @@ def test_custom_assignment_including_policy_config() -> None: assert demand.volume == 42.5 assert demand.attrs == {"description": "test"} assert demand.mode == "pairwise" - assert demand.flow_policy == FlowPolicyConfig.SHORTEST_PATHS_ECMP + assert demand.flow_policy == FlowPolicyPreset.SHORTEST_PATHS_ECMP + + +def test_flow_policy_must_be_preset() -> None: + """Preset names and integers are parsed by the builder, not the dataclass.""" + for value in ("SHORTEST_PATHS_ECMP", 1): + with pytest.raises(ValueError, match="flow_policy must be a FlowPolicyPreset"): + TrafficDemand(source="A", target="B", flow_policy=value) diff --git a/tests/model/failure/test_failure_trace.py b/tests/model/failure/test_failure_trace.py index f5dfece..14fa4fd 100644 --- a/tests/model/failure/test_failure_trace.py +++ b/tests/model/failure/test_failure_trace.py @@ -12,16 +12,12 @@ from ngraph.model.network import Link, Network, Node from ngraph.model.selectors import Condition -# ----------------------------------------------------------------------------- -# FailurePolicy.apply_failures trace tests -# ----------------------------------------------------------------------------- - class TestFailureTracePolicyLevel: """Test failure_trace capture in FailurePolicy.apply_failures.""" def test_trace_captures_mode_index(self) -> None: - """Test that mode_index is correctly captured.""" + """mode_index and mode_attrs come from the selected positive-weight mode.""" rule = FailureRule(scope="node", mode="all") policy = FailurePolicy( modes=[ @@ -49,7 +45,6 @@ def test_trace_captures_mode_attrs(self) -> None: policy.apply_failures({"N1": {}}, {}, failure_trace=trace) assert trace["mode_attrs"] == attrs - # Verify it's a copy, not a reference assert trace["mode_attrs"] is not attrs def test_trace_captures_selection_fields(self) -> None: @@ -157,34 +152,9 @@ def test_trace_deterministic_with_seed(self) -> None: assert trace1 == trace2 -# ----------------------------------------------------------------------------- -# FailureManager integration tests -# ----------------------------------------------------------------------------- - - -@pytest.fixture -def network_with_risk_groups() -> Network: - """Create a network with risk groups for expansion testing.""" - network = Network() - n1 = Node("N1", attrs={"type": "router"}) - n1.risk_groups = ["rg1"] - n2 = Node("N2", attrs={"type": "router"}) - n2.risk_groups = ["rg1"] - n3 = Node("N3", attrs={"type": "server"}) - network.add_node(n1) - network.add_node(n2) - network.add_node(n3) - - link = Link("N1", "N2", capacity=100.0) - link.risk_groups = ["rg1"] - network.add_link(link) - network.add_link(Link("N2", "N3", capacity=100.0)) - return network - - @pytest.fixture def simple_network() -> Network: - """Create a simple network for testing.""" + """Chain N1-N2-N3: two routers and one server.""" network = Network() network.add_node(Node("N1", attrs={"type": "router"})) network.add_node(Node("N2", attrs={"type": "router"})) @@ -310,10 +280,8 @@ def mock_analysis(network, excluded_nodes, excluded_links, **kwargs): seed=42, ) - # Compare unique patterns count assert len(result1["results"]) == len(result2["results"]) - # Compare metadata assert ( result1["metadata"]["unique_patterns"] == result2["metadata"]["unique_patterns"] diff --git a/tests/model/failure/test_policy.py b/tests/model/failure/test_policy.py index 67f98c9..3dd7438 100644 --- a/tests/model/failure/test_policy.py +++ b/tests/model/failure/test_policy.py @@ -14,7 +14,6 @@ def _single_mode_policy(rule: FailureRule, **kwargs) -> FailurePolicy: def test_failure_rule_invalid_probability(): """Test FailureRule validation for invalid probability values.""" - # Test probability > 1.0 with pytest.raises(ValueError, match="probability=1.5 must be within \\[0,1\\]"): FailureRule( scope="node", @@ -24,7 +23,6 @@ def test_failure_rule_invalid_probability(): probability=1.5, ) - # Test probability < 0.0 with pytest.raises(ValueError, match="probability=-0.1 must be within \\[0,1\\]"): FailureRule( scope="node", @@ -366,7 +364,7 @@ def test_multiple_rules(): def test_condition_operators(): - """Test various condition operators.""" + """Test the '!=' operator and a condition on a missing attribute.""" # Test '!=' operator rule_neq = FailureRule( scope="node", @@ -418,7 +416,6 @@ def test_serialization(): policy_dict = policy.to_dict() assert "seed" not in policy_dict - assert "expand_children" not in policy_dict assert "modes" in policy_dict and len(policy_dict["modes"]) == 1 mode_dict = policy_dict["modes"][0] assert len(mode_dict["rules"]) == 1 @@ -487,9 +484,8 @@ def test_empty_entities(): def test_multi_rule_independence(): """Multi-rule policies must produce statistically independent selections. - Verifies the fix for the correlated-seed bug: each rule in a mode - must draw from the same RNG stream sequentially rather than each - creating a fresh RNG from the same seed. + Each rule in a mode must draw sequentially from one shared RNG stream. + A fresh RNG per rule built from the same seed would correlate the rules. """ link_rule = FailureRule(scope="link", mode="random", probability=0.5) node_rule = FailureRule(scope="node", mode="random", probability=0.5) @@ -504,7 +500,7 @@ def test_multi_rule_independence(): N = 2000 # Count how often the *first* link (L00) and *first* node (N00) both fail. # Under independence P(both) โ‰ˆ 0.5 * 0.5 = 0.25 - # Under the old correlated bug P(both) โ‰ˆ 0.5 (draws are identical) + # With correlated draws (identical RNG streams) P(both) โ‰ˆ 0.5 joint_fail = 0 link0_fail = 0 node0_fail = 0 @@ -526,8 +522,8 @@ def test_multi_rule_independence(): p_expected_independent = p_link * p_node # Joint probability should be close to the product (independent). - # Allow generous tolerance for finite sample size, but catch the 2x - # correlation that the old bug produced. + # Allow generous tolerance for finite sample size, but catch a 2x + # correlation from shared RNG streams. assert abs(p_joint - p_expected_independent) < 0.06, ( f"Joint failure rate {p_joint:.4f} deviates too much from independent " f"expectation {p_expected_independent:.4f} (p_link={p_link:.4f}, " @@ -538,9 +534,8 @@ def test_multi_rule_independence(): def test_multi_mode_entity_independence(): """Entity failure probability must be independent of which mode was selected. - Verifies the fix for the mode-entity correlation bug: the RNG draw - that selects the mode must not be the same draw that determines - entity[0] failure. + The RNG draw that selects the mode must not also decide whether + entity[0] fails. """ # Two modes with asymmetric weights rule_mode0 = FailureRule(scope="node", mode="random", probability=0.3) diff --git a/tests/model/failure/test_policy_parser_keys.py b/tests/model/failure/test_policy_parser_keys.py new file mode 100644 index 0000000..26b37de --- /dev/null +++ b/tests/model/failure/test_policy_parser_keys.py @@ -0,0 +1,57 @@ +"""Programmatic failure-policy parsing rejects keys it would otherwise ignore.""" + +from __future__ import annotations + +import pytest + +from ngraph.model.failure.parser import build_failure_policy_set + + +def _build(policy: dict) -> None: + build_failure_policy_set({"p": policy}, derive_seed=lambda _name: None) + + +def test_unknown_policy_key_raises() -> None: + with pytest.raises(ValueError, match="failure policy 'p'.*expand_children"): + _build({"modes": [{"weight": 1.0, "rules": []}], "expand_children": True}) + + +def test_unknown_mode_key_raises() -> None: + with pytest.raises(ValueError, match="failure mode.*probability"): + _build({"modes": [{"weight": 1.0, "rules": [], "probability": 0.5}]}) + + +def test_rule_level_conditions_raise() -> None: + """Rule-level `conditions` outside `match` raise instead of failing every entity.""" + rule = {"scope": "node", "mode": "all", "conditions": [{"attr": "x"}]} + with pytest.raises(ValueError, match="failure rule.*conditions"): + _build({"modes": [{"weight": 1.0, "rules": [rule]}]}) + + +def test_risk_group_builders_reject_unknown_keys() -> None: + """Risk groups, children, membership and generate blocks reject stray keys.""" + from ngraph.model.failure.generate import parse_generate_spec + from ngraph.model.failure.membership import _parse_membership_spec + from ngraph.model.failure.parser import build_risk_groups + + with pytest.raises(ValueError, match="risk group 'X': bogus"): + build_risk_groups([{"name": "X", "bogus": 1}]) + with pytest.raises(ValueError, match="risk group 'Y': bogus"): + build_risk_groups([{"name": "X", "children": [{"name": "Y", "bogus": 1}]}]) + with pytest.raises(ValueError, match="generate entry: name"): + build_risk_groups([{"generate": {}, "name": "X"}]) + with pytest.raises(ValueError, match="membership rule: bogus"): + _parse_membership_spec({"scope": "node", "path": ".*", "bogus": 1}) + with pytest.raises(ValueError, match="generate block: bogus"): + parse_generate_spec( + {"scope": "node", "group_by": "g", "name": "G_${value}", "bogus": 1} + ) + + +def test_match_spec_rejects_unknown_keys() -> None: + from ngraph.model.selectors import parse_match_spec + + with pytest.raises(ValueError, match="in match: bogus"): + parse_match_spec({"conditions": [], "bogus": 1}) + with pytest.raises(ValueError, match="condition in match: bogus"): + parse_match_spec({"conditions": [{"attr": "a", "op": "==", "bogus": 1}]}) diff --git a/tests/model/failure/test_policy_serialization_roundtrip.py b/tests/model/failure/test_policy_serialization_roundtrip.py index 43f7009..5dffc8e 100644 --- a/tests/model/failure/test_policy_serialization_roundtrip.py +++ b/tests/model/failure/test_policy_serialization_roundtrip.py @@ -85,7 +85,6 @@ def test_to_dict_excludes_seed_and_nests_match() -> None: data = policy.to_dict() assert "seed" not in data - assert "expand_children" not in data rule_dict = data["modes"][0]["rules"][0] assert "conditions" not in rule_dict assert "logic" not in rule_dict diff --git a/tests/model/failure/test_policy_set.py b/tests/model/failure/test_policy_set.py index fe4fce9..8c93137 100644 --- a/tests/model/failure/test_policy_set.py +++ b/tests/model/failure/test_policy_set.py @@ -52,7 +52,6 @@ def test_to_dict_serialization(self): """Test serialization to dictionary.""" fps = FailurePolicySet() - # Create a policy with some rules and attributes rule = FailureRule(scope="node", mode="choice", count=1) from ngraph.model.failure.policy import FailureMode @@ -70,10 +69,8 @@ def test_to_dict_serialization(self): assert "modes" in result["test"] assert "attrs" in result["test"] assert result["test"]["expand_groups"] is True - # Modes present assert "modes" in result["test"] and len(result["test"]["modes"]) == 1 - # Check rule serialization inside modes mode = result["test"]["modes"][0] assert len(mode["rules"]) == 1 rule_dict = mode["rules"][0] diff --git a/tests/model/failure/test_risk_group_parser.py b/tests/model/failure/test_risk_group_parser.py index 1bbf0c1..d1c64e3 100644 --- a/tests/model/failure/test_risk_group_parser.py +++ b/tests/model/failure/test_risk_group_parser.py @@ -101,7 +101,7 @@ def test_grandchild_membership_rejected(self): build_risk_groups(rg_data) def test_child_generate_rejected(self): - """A 'generate' block on a child raises (existing behavior).""" + """A 'generate' block on a child raises.""" rg_data = [ { "name": "Parent", @@ -113,7 +113,7 @@ def test_child_generate_rejected(self): build_risk_groups(rg_data) def test_top_level_membership_and_disabled_still_accepted(self): - """Top-level entries keep full support for membership and disabled.""" + """Top-level entries accept membership and disabled.""" rg_data = [ { "name": "Parent", diff --git a/tests/model/flow/test_policy_config.py b/tests/model/flow/test_policy_config.py index be7f1fc..4e00b19 100644 --- a/tests/model/flow/test_policy_config.py +++ b/tests/model/flow/test_policy_config.py @@ -1,12 +1,8 @@ """Tests for flow policy preset configurations.""" +import netgraph_core import pytest -try: - import netgraph_core -except ImportError: - pytest.skip("netgraph_core not available", allow_module_level=True) - from ngraph.model.flow.policy_config import FlowPolicyPreset, create_flow_policy @@ -41,7 +37,7 @@ def simple_graph(): def test_flow_policy_preset_enum_values(): - """Test that FlowPolicyPreset enum has expected values.""" + """FlowPolicyPreset members have integer values 1 through 5.""" assert FlowPolicyPreset.SHORTEST_PATHS_ECMP == 1 assert FlowPolicyPreset.SHORTEST_PATHS_WCMP == 2 assert FlowPolicyPreset.TE_WCMP_UNLIM == 3 diff --git a/tests/model/test_flow.py b/tests/model/test_flow.py index 08f47b3..622f988 100644 --- a/tests/model/test_flow.py +++ b/tests/model/test_flow.py @@ -1,7 +1,4 @@ -"""Tests for flow analysis using the AnalysisContext API. - -This module tests maximum flow calculations using the new analyze() API. -""" +"""Tests for max flow calculations through analyze() and AnalysisContext.""" import pytest @@ -99,7 +96,7 @@ def test_max_flow_with_mixed_attr_and_regex(self): assert val >= 0.0 def test_max_flow_overlap_detection_coverage(self): - """Test specific overlap detection logic in max_flow combine mode for coverage.""" + """Combine mode returns 0 flow when source and sink groups overlap.""" net = Network() net.add_node(Node("A")) net.add_node(Node("B")) @@ -107,7 +104,6 @@ def test_max_flow_overlap_detection_coverage(self): net.add_link(Link("A", "B", capacity=5.0)) net.add_link(Link("B", "C", capacity=3.0)) - # Create a scenario where there are valid groups but they overlap flow_result = analyze(net).max_flow( r"^(A|B)$", # Matches A and B r"^(B|C)$", # Matches B and C (B overlaps!) @@ -119,7 +115,7 @@ def test_max_flow_overlap_detection_coverage(self): assert list(flow_result.values())[0] == 0.0 def test_max_flow_disabled_nodes_coverage(self): - """Test max_flow with disabled source nodes for coverage.""" + """max_flow raises when the only matching source node is disabled.""" net = Network() net.add_node(Node("A", disabled=True)) # Disabled source net.add_node(Node("B")) @@ -132,7 +128,7 @@ def test_max_flow_disabled_nodes_coverage(self): analyze(net).max_flow("^A$", "^C$", mode=Mode.COMBINE) def test_max_flow_disabled_link_coverage(self): - """Test max_flow with disabled links for coverage.""" + """A disabled link on the only path yields 0 flow.""" net = Network() net.add_node(Node("A")) net.add_node(Node("B")) @@ -270,7 +266,7 @@ def test_exclude_link(self): ab_link_id = link_id break - # Exclude A->B link + # Without A->B only the A-C-D path (capacity 3) remains reduced_flow = analyze(net).max_flow( "^A$", "^D$", mode=Mode.COMBINE, excluded_links={ab_link_id} ) diff --git a/tests/model/test_layering.py b/tests/model/test_layering.py index b4f9eb9..bf9710e 100644 --- a/tests/model/test_layering.py +++ b/tests/model/test_layering.py @@ -2,8 +2,8 @@ The runtime selector engine lives in ``ngraph.model.selectors`` so that the model layer (failure policies in particular) evaluates selectors without -importing the DSL package. ``ngraph.dsl.selectors`` keeps YAML-facing parsing -and re-exports the moved names for backward compatibility. +importing the DSL package. ``ngraph.dsl.selectors`` keeps only YAML-facing +parsing. The subprocess tests stub parent packages with path-only modules so that importing a model module does not execute ``ngraph/__init__.py`` (which pulls @@ -89,18 +89,3 @@ def test_model_packages_do_not_load_dsl_selectors() -> None: """ ) _run_python(code) - - -def test_dsl_selectors_reexports_model_selector_names() -> None: - """ngraph.dsl.selectors re-exports the moved names for backward compatibility.""" - import ngraph.dsl.selectors as dsl_selectors - import ngraph.model.selectors as model_selectors - - for name in model_selectors.__all__: - assert getattr(dsl_selectors, name) is getattr(model_selectors, name), ( - f"ngraph.dsl.selectors.{name} is not the ngraph.model.selectors object" - ) - - # Parsing entry points remain in the DSL layer. - assert callable(dsl_selectors.normalize_selector) - assert callable(dsl_selectors.parse_match_spec) diff --git a/tests/model/test_network_basics.py b/tests/model/test_network_basics.py index 8a4cbcc..ab5c6ed 100644 --- a/tests/model/test_network_basics.py +++ b/tests/model/test_network_basics.py @@ -1,13 +1,4 @@ -""" -Tests for basic network construction, utilities, nodes, and links. - -This module contains tests for the fundamental building blocks of the network: -- Utility functions (UUID generation) -- Node creation and management -- Link creation and management -- Basic network construction -- Node/link enabling/disabling -""" +"""Tests for basic network construction, utilities, nodes, and links.""" import pytest @@ -106,7 +97,7 @@ def simple_network(self): return network def test_network_add_node_and_link(self, empty_network): - """Adding nodes and links to a Network should store them correctly.""" + """Added nodes and links are stored in Network.nodes and Network.links.""" node_a = Node("A") node_b = Node("B") @@ -213,7 +204,7 @@ def test_disable_link_does_not_exist(self): net.enable_link("xyz") def test_enable_all_disable_all(self, basic_network): - """Test enable_all and disable_all correctly toggle all nodes and links.""" + """Test enable_all and disable_all toggle all nodes and links.""" net, link = basic_network # Everything enabled by default @@ -221,13 +212,11 @@ def test_enable_all_disable_all(self, basic_network): assert net.nodes["B"].disabled is False assert net.links[link.id].disabled is False - # Disable all net.disable_all() assert net.nodes["A"].disabled is True assert net.nodes["B"].disabled is True assert net.links[link.id].disabled is True - # Enable all net.enable_all() assert net.nodes["A"].disabled is False assert net.nodes["B"].disabled is False diff --git a/tests/model/test_network_integration.py b/tests/model/test_network_integration.py index 209759c..7b6d025 100644 --- a/tests/model/test_network_integration.py +++ b/tests/model/test_network_integration.py @@ -1,11 +1,4 @@ -"""Tests for integration scenarios and complex network operations. - -This module contains tests for: -- Complex multi-component network scenarios -- Integration between different network features -- End-to-end workflow testing -- Performance and scalability edge cases -""" +"""Integration tests combining network features with flow analysis.""" import pytest @@ -13,7 +6,7 @@ class TestNetworkIntegration: - """Tests for complex integration scenarios.""" + """Network features combined with flow analysis.""" @pytest.fixture def diamond_network(self): @@ -29,7 +22,7 @@ def diamond_network(self): return net def test_risk_group_with_flow_analysis(self): - """Test integration of risk groups with flow analysis.""" + """Disabling an all-node risk group blocks max flow; re-enabling restores it.""" net = Network() nodes = ["A", "B", "C", "D"] for node in nodes: @@ -41,7 +34,7 @@ def test_risk_group_with_flow_analysis(self): net.risk_groups["critical"] = RiskGroup("critical") - # Flow should work normally when risk group is enabled + # Risk group enabled: the unit-capacity chain carries 1.0 flow = analyze(net).max_flow("^A$", "^D$", mode=Mode.COMBINE) assert flow[("^A$", "^D$")] == 1.0 diff --git a/tests/model/test_risk_group_generation.py b/tests/model/test_risk_group_generation.py index c176028..e913279 100644 --- a/tests/model/test_risk_group_generation.py +++ b/tests/model/test_risk_group_generation.py @@ -11,7 +11,7 @@ class TestRiskGroupGenerationBasic: - """Basic tests for risk group generation.""" + """Generate risk groups from link and node attributes.""" def test_generate_from_link_attribute(self) -> None: """Generate conduit risk groups from unique fiber.path_id values on links.""" @@ -174,7 +174,7 @@ def test_generate_with_nested_attribute(self) -> None: class TestRiskGroupGenerationEdgeCases: - """Edge cases for risk group generation.""" + """Missing or null attributes, explicit groups, and multiple generate blocks.""" def test_generate_no_matches(self) -> None: """No groups generated when attribute is missing from all entities.""" diff --git a/tests/model/test_risk_group_membership.py b/tests/model/test_risk_group_membership.py index bd26d2b..6023c94 100644 --- a/tests/model/test_risk_group_membership.py +++ b/tests/model/test_risk_group_membership.py @@ -11,7 +11,7 @@ class TestMembershipRulesBasic: - """Basic tests for membership rule resolution.""" + """Membership rules assign nodes, links, and child risk groups.""" def test_node_membership_simple(self) -> None: """Nodes matching facility.power_zone are added to power zone risk group.""" @@ -96,7 +96,6 @@ def test_link_membership_simple(self) -> None: """ scenario = Scenario.from_yaml(yaml_content) - # Find links and check membership for link in scenario.network.links.values(): if link.attrs.get("fiber", {}).get("conduit_id") == "NYC-CHI-C1": assert "Conduit_NYC_CHI_C1" in link.risk_groups @@ -288,10 +287,10 @@ def test_dot_notation_in_match(self) -> None: class TestMembershipRulesEdgeCases: - """Edge cases for membership rules.""" + """Membership with no rule, no matches, self-reference, and explicit assignment.""" def test_no_membership_rule(self) -> None: - """Risk groups without membership rules work normally with explicit assignment.""" + """Risk groups without membership rules keep explicitly assigned members.""" yaml_content = """ network: nodes: @@ -386,7 +385,7 @@ def test_membership_combines_with_explicit(self) -> None: class TestMembershipRulesOperators: - """Tests for various operators in membership conditions.""" + """contains, in, and numeric comparison operators in membership conditions.""" def test_contains_operator(self) -> None: """Contains operator for list attributes like fiber pair IDs.""" @@ -600,7 +599,7 @@ def test_transitive_cycle_detected(self) -> None: assert "circular" in error_msg.lower() or "cycle" in error_msg.lower() def test_valid_hierarchy_no_cycle(self) -> None: - """Valid tree hierarchy without cycles works correctly.""" + """An acyclic hierarchy loads and attaches matching groups as children.""" yaml_content = """ network: nodes: diff --git a/tests/model/test_risk_groups.py b/tests/model/test_risk_groups.py index af7484b..a5d3127 100644 --- a/tests/model/test_risk_groups.py +++ b/tests/model/test_risk_groups.py @@ -1,11 +1,7 @@ -""" -Tests for risk group management in the network module. +"""Tests for enabling and disabling Network risk groups. -This module contains tests for: -- Risk group creation and hierarchy -- Enabling/disabling risk groups (recursive and non-recursive) -- Multi-membership risk group scenarios -- Risk group effects on nodes and links +Covers recursive and non-recursive disabling, nested hierarchies, and nodes +or links that belong to several groups. """ from ngraph.model.network import Link, Network, Node, RiskGroup @@ -38,7 +34,6 @@ def test_disable_risk_group_recursive(self): "top", children=[RiskGroup("child1"), RiskGroup("child2")] ) - # Disable top group recursively net.disable_risk_group("top", recursive=True) assert net.nodes["A"].disabled is True @@ -109,7 +104,6 @@ def test_risk_group_hierarchy_deep_nesting(self): "level1", children=[RiskGroup("level2", children=[RiskGroup("level3")])] ) - # Disable top level recursively net.disable_risk_group("level1", recursive=True) assert net.nodes["A"].disabled is True diff --git a/tests/model/test_selection.py b/tests/model/test_selection.py index 787bb87..674c5f6 100644 --- a/tests/model/test_selection.py +++ b/tests/model/test_selection.py @@ -1,11 +1,4 @@ -""" -Tests for node selection and pattern matching in the network module. - -This module contains tests for: -- Node selection by path patterns (exact, prefix, wildcard, regex) -- Link finding by source/target patterns -- Network traversal and search operations -""" +"""Tests for node selection by path pattern and link lookup by endpoint.""" import pytest @@ -97,8 +90,8 @@ def test_select_node_groups_multiple_capture_groups(self, complex_network): # Should have groups for each combination found assert len(node_groups) >= 2 - # For attribute-based grouping, use the unified selector system with - # {"group_by": "attr_name"} dict selectors via normalize_selector/select_nodes. + # Attribute-based grouping uses {"group_by": "attr_name"} dict selectors + # via normalize_selector/select_nodes, not path patterns. class TestLinkUtilities: @@ -148,17 +141,14 @@ def test_find_links(self): assert len(all_links) == 2 assert set(link.id for link in all_links) == {link_a_c.id, link_b_c.id} - # Filter by source regex src_a_links = net.find_links(source_regex="srcA") assert len(src_a_links) == 1 assert src_a_links[0].id == link_a_c.id - # Filter by target regex to_c_links = net.find_links(target_regex="C") assert len(to_c_links) == 2 assert set(link.id for link in to_c_links) == {link_a_c.id, link_b_c.id} - # Filter by both source and target specific_links = net.find_links(source_regex="srcB", target_regex="C") assert len(specific_links) == 1 assert specific_links[0].id == link_b_c.id @@ -213,12 +203,11 @@ def test_find_links_with_disabled_links(self): links = net.find_links() assert len(links) == 2 # Still finds both links - # Verify one is disabled and one is not found_states = [link.disabled for link in links] assert True in found_states and False in found_states def test_find_links_regex_patterns(self): - """Test find_links with various regex patterns.""" + """find_links with wildcard source or target patterns and an exact pair.""" net = Network() nodes = ["router-1", "router-2", "switch-1", "switch-2"] for node in nodes: @@ -232,14 +221,11 @@ def test_find_links_regex_patterns(self): net.add_link(link) links.append(link) - # Find all links from routers router_links = net.find_links(source_regex="router-.*") assert len(router_links) == 4 - # Find all links to switches switch_links = net.find_links(target_regex="switch-.*") assert len(switch_links) == 4 - # Find specific router to specific switch specific = net.find_links(source_regex="router-1", target_regex="switch-2") assert len(specific) == 1 diff --git a/tests/model/test_types_base.py b/tests/model/test_types_base.py index f28e336..cd534ce 100644 --- a/tests/model/test_types_base.py +++ b/tests/model/test_types_base.py @@ -2,18 +2,9 @@ import pytest -import ngraph.types from ngraph.types import FlowPlacement, Mode -def test_min_cap_min_flow_removed() -> None: - """Dead MIN_CAP/MIN_FLOW constants are no longer exported.""" - assert not hasattr(ngraph.types, "MIN_CAP") - assert not hasattr(ngraph.types, "MIN_FLOW") - assert "MIN_CAP" not in ngraph.types.__all__ - assert "MIN_FLOW" not in ngraph.types.__all__ - - def test_mode_from_string_valid() -> None: """Mode.from_string parses names case-insensitively.""" assert Mode.from_string("combine") is Mode.COMBINE @@ -28,7 +19,7 @@ def test_mode_from_string_invalid() -> None: def test_flow_placement_from_string_still_works() -> None: - """FlowPlacement.from_string remains the parsing counterpart.""" + """FlowPlacement.from_string parses known names and rejects unknown ones.""" assert FlowPlacement.from_string("proportional") is FlowPlacement.PROPORTIONAL with pytest.raises(ValueError, match="Invalid flow_placement"): FlowPlacement.from_string("bogus") diff --git a/tests/profiling/test_profiling.py b/tests/profiling/test_profiling.py index 5aeff09..cc51352 100644 --- a/tests/profiling/test_profiling.py +++ b/tests/profiling/test_profiling.py @@ -17,15 +17,14 @@ class TestPerformanceProfiler: """Test the PerformanceProfiler class.""" def test_scenario_end_without_start(self): - """Test ending scenario profiling without starting.""" + """end_scenario() without start_scenario() leaves total_wall_time at 0.""" profiler = PerformanceProfiler() - # Should handle gracefully profiler.end_scenario() assert profiler.results.total_wall_time == 0.0 def test_step_profiling_basic(self): - """Test basic step profiling.""" + """profile_step records step name, type, wall time and call counts.""" profiler = PerformanceProfiler() with profiler.profile_step("test_step", "TestStep"): @@ -55,7 +54,6 @@ def test_step_profiling_with_memory(self): @patch("cProfile.Profile") def test_step_profiling_detail(self, mock_profile_class): """Test step profiling with detail mode.""" - # Setup mock cProfile mock_profiler = MagicMock() mock_profile_class.return_value = mock_profiler @@ -117,7 +115,6 @@ def test_analyze_performance_with_bottlenecks(self): assert bottleneck["step_name"] == "slow_step" assert bottleneck["percentage"] == 80.0 - # Check analysis summary summary = profiler.results.analysis_summary assert summary["total_steps"] == 2 assert summary["slowest_step"] == "slow_step" @@ -198,12 +195,10 @@ def test_generate_report_basic(self): results.total_cpu_time = 4.5 results.total_function_calls = 1000 - # Add step profiles step1 = StepProfile("step1", "Step1", 2.0, 1.8, 400) step2 = StepProfile("step2", "Step2", 3.0, 2.7, 600) results.step_profiles = [step1, step2] - # Setup analysis summary results.analysis_summary = { "total_steps": 2, "slowest_step": "step2", @@ -231,12 +226,10 @@ def test_generate_report_with_bottlenecks(self): results = ProfileResults() results.total_wall_time = 10.0 - # Add step profiles step1 = StepProfile("fast_step", "FastStep", 1.0, 0.9, 100) step2 = StepProfile("slow_step", "SlowStep", 8.0, 7.5, 800) results.step_profiles = [step1, step2] - # Add bottleneck bottleneck = { "step_name": "slow_step", "step_type": "SlowStep", @@ -248,7 +241,6 @@ def test_generate_report_with_bottlenecks(self): } results.bottlenecks = [bottleneck] - # Setup analysis summary results.analysis_summary = { "bottleneck_count": 1, "cpu_efficiency": 0.84, @@ -288,7 +280,6 @@ def test_generate_report_detailed(self, mock_stats_class): ) results.step_profiles = [step_profile] - # Add bottleneck bottleneck = { "step_name": "slow_step", "step_type": "SlowStep", @@ -343,29 +334,24 @@ class TestProfilerIntegration: """Integration tests for the profiling system.""" def test_end_to_end_profiling(self): - """Test complete profiling workflow.""" + """Test start, two profiled steps, end, analysis, and report.""" profiler = PerformanceProfiler() - # Start scenario profiling profiler.start_scenario() - # Profile some steps with profiler.profile_step("step1", "Step1"): time.sleep(0.01) with profiler.profile_step("step2", "Step2"): time.sleep(0.02) - # End scenario profiling profiler.end_scenario() profiler.analyze_performance() - # Verify results assert len(profiler.results.step_profiles) == 2 assert profiler.results.total_wall_time > 0 assert profiler.results.analysis_summary["total_steps"] == 2 - # Generate report reporter = PerformanceReporter(profiler.results) report = reporter.generate_report() assert "step1" in report diff --git a/tests/profiling/test_worker_profile_merge.py b/tests/profiling/test_worker_profile_merge.py index fa17971..ad11655 100644 --- a/tests/profiling/test_worker_profile_merge.py +++ b/tests/profiling/test_worker_profile_merge.py @@ -1,8 +1,8 @@ """Regression tests for worker-profile merging. -merge_child_profiles() previously globbed ``*_worker_*.pstats`` while workers -in analysis/failure_manager.py write ``{analysis_name}_thread_{tid}_{uuid}.pstats``, -so worker profiles were never merged into step profiles. +merge_child_profiles() must pick up the ``{analysis_name}_thread_{tid}_{uuid}.pstats`` +files that workers in analysis/failure_manager.py write, so worker profiles +end up merged into step profiles. """ import cProfile diff --git a/tests/results/test_capacity_envelope_flow_summary.py b/tests/results/test_capacity_envelope_flow_summary.py deleted file mode 100644 index 3bfe00b..0000000 --- a/tests/results/test_capacity_envelope_flow_summary.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for CapacityEnvelope flow summary aggregation. - -Validates aggregation of cost distributions and min-cut frequencies from -provided FlowSummary-like objects. -""" - -from __future__ import annotations - -import json -from collections import namedtuple - -from ngraph.results.artifacts import CapacityEnvelope - - -def test_aggregate_flow_summaries_cost_and_min_cut() -> None: - """Aggregate multiple summaries and validate computed statistics.""" - Summary = namedtuple("Summary", ["cost_distribution", "min_cut"]) - - # Two summaries with overlapping costs and min-cut edges - s1 = Summary(cost_distribution={1.0: 5.0, 2.0: 3.0}, min_cut=[("u", "v", "e1")]) - s2 = Summary( - cost_distribution={1.0: 7.0, 3.0: 2.0}, - min_cut=[("u", "v", "e1"), ("x", "y", "e2")], - ) - - env = CapacityEnvelope.from_values( - source_pattern="S", - sink_pattern="T", - mode="combine", - values=[10.0, 20.0], # arbitrary values, not used by aggregation - flow_summaries=[s1, s2], - ) - - stats = env.flow_summary_stats - assert "cost_distribution_stats" in stats - cds = stats["cost_distribution_stats"] - - # Validate means over volumes - # cost 1.0 has [5.0, 7.0] -> mean 6.0, min 5.0, max 7.0, total_samples 2 - assert 1.0 in cds - assert cds[1.0]["mean"] == 6.0 - assert cds[1.0]["min"] == 5.0 - assert cds[1.0]["max"] == 7.0 - assert cds[1.0]["total_samples"] == 2 - # frequencies should count occurrences - assert cds[1.0]["frequencies"][5.0] == 1 - assert cds[1.0]["frequencies"][7.0] == 1 - - # cost 2.0 only appears once - assert cds[2.0]["mean"] == 3.0 - assert cds[2.0]["min"] == 3.0 - assert cds[2.0]["max"] == 3.0 - assert cds[2.0]["total_samples"] == 1 - - # Validate min-cut edge frequency counting - mcf = stats["min_cut_frequencies"] - assert mcf[str(("u", "v", "e1"))] == 2 # appears in both summaries - assert mcf[str(("x", "y", "e2"))] == 1 - - # Ensure the whole envelope dict is JSON-serializable with stats included - d = env.to_dict() - assert "flow_summary_stats" in d - json.dumps(d) diff --git a/tests/results/test_capacity_envelope_unit.py b/tests/results/test_capacity_envelope_unit.py index d3ce059..ff71928 100644 --- a/tests/results/test_capacity_envelope_unit.py +++ b/tests/results/test_capacity_envelope_unit.py @@ -1,35 +1,12 @@ -"""Unit tests for CapacityEnvelope aggregation and deserialization edge cases.""" +"""Unit tests for CapacityEnvelope.from_dict frequency-key handling.""" from __future__ import annotations -from collections import namedtuple - import pytest from ngraph.results.artifacts import CapacityEnvelope -def test_aggregate_frequencies_count_duplicates() -> None: - """Frequency counting (Counter-based) matches duplicate volumes exactly.""" - Summary = namedtuple("Summary", ["cost_distribution", "min_cut"]) - summaries = [ - Summary(cost_distribution={1.0: 5.0}, min_cut=[]), - Summary(cost_distribution={1.0: 5.0}, min_cut=[]), - Summary(cost_distribution={1.0: 7.0}, min_cut=[]), - ] - - env = CapacityEnvelope.from_values( - source_pattern="S", - sink_pattern="T", - mode="combine", - values=[1.0, 2.0, 3.0], - flow_summaries=summaries, - ) - - freqs = env.flow_summary_stats["cost_distribution_stats"][1.0]["frequencies"] - assert freqs == {5.0: 2, 7.0: 1} - - def test_from_dict_rejects_non_numeric_frequency_key() -> None: with pytest.raises(ValueError): CapacityEnvelope.from_dict( diff --git a/tests/results/test_result.py b/tests/results/test_result.py index d646b76..c8b3a42 100644 --- a/tests/results/test_result.py +++ b/tests/results/test_result.py @@ -44,7 +44,7 @@ def test_get_with_default_missing_step(): def test_get_all_single_key_multiple_steps(): """ - Ensure both steps present under steps map. + Each step's data is exported under its own key in the steps map. """ results = Results() results.put_step_metadata("Step1", "Dummy", 0) @@ -98,7 +98,6 @@ def test_empty_results(): def test_results_to_dict_includes_workflow_and_step_data(): results = Results() - # Simulate metadata results.put_step_metadata("stepA", "DummyStep", 0) results.enter_step("stepA") results.put("metadata", {}) diff --git a/tests/results/test_serialisation.py b/tests/results/test_serialisation.py index 464bc51..db38f2e 100644 --- a/tests/results/test_serialisation.py +++ b/tests/results/test_serialisation.py @@ -20,10 +20,8 @@ def test_results_to_dict_converts_objects(): d = res.to_dict() - # Check scalar value is preserved assert d["steps"]["S"]["data"]["scalar"] == 1.23 - # Check that CapacityEnvelope was converted to dict assert isinstance(d["steps"]["S"]["data"]["env"], dict) assert d["steps"]["S"]["data"]["env"]["max"] == 4 assert d["steps"]["S"]["data"]["env"]["source"] == "X" @@ -64,7 +62,6 @@ def test_results_to_dict_json_serializable(): assert parsed["steps"]["Analysis"]["data"]["envelope"]["source"] == "src" assert parsed["steps"]["Analysis"]["metadata"]["version"] == "1.0" - # Construct an envelope back from dict and validate env2 = CapacityEnvelope.from_dict(parsed["steps"]["Analysis"]["data"]["envelope"]) assert env2.source_pattern == "src" assert env2.sink_pattern == "dst" diff --git a/tests/results/test_store_deep_convert.py b/tests/results/test_store_deep_convert.py index 1498613..3be4f35 100644 --- a/tests/results/test_store_deep_convert.py +++ b/tests/results/test_store_deep_convert.py @@ -1,7 +1,7 @@ """Regression tests for Results.to_dict deep conversion. -Covers recursion into ``to_dict()`` output and conversion of the scenario -snapshot section, which previously escaped JSON-safe normalization. +Covers recursion into ``to_dict()`` output and JSON-safe normalization of +the scenario snapshot section. """ from __future__ import annotations diff --git a/tests/results/test_store_unit.py b/tests/results/test_store_unit.py index 1ab4fe3..8648e33 100644 --- a/tests/results/test_store_unit.py +++ b/tests/results/test_store_unit.py @@ -18,7 +18,6 @@ def _sample_results_doc() -> dict: scenario_seed=123, step_seed=None, seed_source="scenario-derived", - active_seed=123, ) r.set_scenario_snapshot({"name": "demo"}) return r.to_dict() diff --git a/tests/scenario/test_scenario.py b/tests/scenario/test_scenario.py index fbdc46c..13e2a47 100644 --- a/tests/scenario/test_scenario.py +++ b/tests/scenario/test_scenario.py @@ -190,7 +190,7 @@ def unrecognized_step_type_yaml() -> str: def extra_param_yaml() -> str: """ Returns a YAML string that attempts to pass an unsupported 'extra_param' - to a known workflow step type, which should raise a TypeError. + to a known workflow step type, which should raise a ValueError. """ return """ network: @@ -219,7 +219,7 @@ def extra_param_yaml() -> str: def minimal_scenario_yaml() -> str: """ Returns a YAML string with only a single workflow step, no network, - no failure_policy, and no traffic_matrix_set. Should be valid but minimal. + no failures, and no demands. Should be valid but minimal. """ return """ workflow: @@ -233,7 +233,7 @@ def minimal_scenario_yaml() -> str: def empty_yaml() -> str: """ Returns an empty YAML string; from_yaml should still construct - a Scenario object but with none/empty fields if possible. + a Scenario object with empty fields. """ return "" @@ -241,8 +241,8 @@ def empty_yaml() -> str: def test_scenario_from_yaml_valid(valid_scenario_yaml: str) -> None: """ Tests that a Scenario can be constructed from a valid YAML string. - Ensures that: - - Network has correct nodes/links + Checks that: + - Network has the expected nodes/links - FailurePolicy is set with multiple rules - TrafficDemands are parsed - Workflow steps are instantiated @@ -297,7 +297,7 @@ def test_scenario_from_yaml_valid(valid_scenario_yaml: str) -> None: assert r1.scope == "node" and r1.mode == "choice" and r1.count == 1 assert r2.scope == "link" and r2.mode == "all" - # Check traffic matrix set + # Check demand set assert len(scenario.demand_set.sets) == 1 assert "default" in scenario.demand_set.sets default_demands = scenario.demand_set.sets["default"] @@ -322,7 +322,6 @@ def test_scenario_from_yaml_valid(valid_scenario_yaml: str) -> None: assert step2.name == "Step2" assert cast(DoSmthElse, step2).factor == 2.0 - # Check results assert isinstance(scenario.results, Results) @@ -365,10 +364,10 @@ def test_scenario_from_yaml_unrecognized_step_type( def test_scenario_from_yaml_unsupported_param(extra_param_yaml: str) -> None: """ - Tests that Scenario.from_yaml raises TypeError if a workflow step + Tests that Scenario.from_yaml names the step and key when a workflow step has an unsupported parameter in the YAML. """ - with pytest.raises(TypeError): + with pytest.raises(ValueError, match="workflow step .*: extra_param"): Scenario.from_yaml(extra_param_yaml) @@ -382,7 +381,7 @@ def test_scenario_minimal(minimal_scenario_yaml: str) -> None: assert len(scenario.network.nodes) == 0 assert len(scenario.network.links) == 0 - # If no failure_policy_set block, scenario.failure_policy_set has no policies + # Without a failures block, failure_policy_set has no policies assert len(scenario.failure_policy_set.get_all_policies()) == 0 assert len(scenario.demand_set.sets) == 0 @@ -457,18 +456,12 @@ def test_scenario_risk_group_missing_name() -> None: """ with pytest.raises(ValueError) as excinfo: Scenario.from_yaml(scenario_yaml) - # The loader now validates for string, dict with 'name', or dict with 'generate' + # Entries must be a string, a dict with 'name', or a dict with 'generate' assert "RiskGroup entry must be" in str(excinfo.value) -## Removed two tests that depended on docstring-extracted YAML and a private -## builder API. These were brittle and tested documentation rather than -## functionality. Coverage for failure policy parsing and behavior remains in -## other tests within this module and in schema validation tests. - - def test_yaml_anchors_and_aliases(): - """Test that YAML anchors and aliases work correctly with the vars section.""" + """Test that YAML anchors in vars resolve into node attrs and link capacity.""" scenario_yaml = """ vars: default_capacity: &default_cap 100 @@ -497,10 +490,8 @@ def test_yaml_anchors_and_aliases(): default: [] """ - # Should load without errors scenario = Scenario.from_yaml(scenario_yaml) - # Verify the anchors were properly expanded n1_attrs = scenario.network.nodes["N1"].attrs n2_attrs = scenario.network.nodes["N2"].attrs @@ -520,15 +511,11 @@ def test_yaml_anchors_and_aliases(): assert link.capacity == 100 -## Removed redundant anchor test without assertions on attribute merging. The -## remaining anchor test validates both anchors and attribute overrides. - - def test_scenario_snapshot_serialization_format(): """Scenario snapshot must serialize policies in YAML format and presets by name. Regression tests: the snapshot delegates failure-policy serialization to - FailurePolicy.to_dict (conditions nested under "match", no expand_children) + FailurePolicy.to_dict (conditions nested under "match") and stores flow_policy as the preset name string instead of a raw IntEnum. """ import json @@ -569,7 +556,6 @@ def test_scenario_snapshot_serialization_format(): # Failure policies use the parser-compatible to_dict shape policy_dict = snapshot["failures"]["default"] - assert "expand_children" not in policy_dict assert "seed" not in policy_dict rule_dict = policy_dict["modes"][0]["rules"][0] assert rule_dict["match"]["logic"] == "and" @@ -581,3 +567,31 @@ def test_scenario_snapshot_serialization_format(): # The whole export must be JSON-serializable json.dumps(exported) + + +def test_scenario_seed_rejects_float() -> None: + """The schema admits 5.0 as an integer; the loader must not.""" + yaml_str = """ +network: + nodes: + A: {} +seed: 5.0 +""" + with pytest.raises(ValueError, match="'seed' must be an integer"): + Scenario.from_yaml(yaml_str) + + +def test_duplicate_risk_group_names_raise() -> None: + """A repeated top-level risk group would silently replace the first one.""" + yaml_str = """ +network: + nodes: + A: {} +risk_groups: + - name: RG1 + attrs: {kind: a} + - name: RG1 + attrs: {kind: b} +""" + with pytest.raises(ValueError, match="Duplicate risk group name 'RG1'"): + Scenario.from_yaml(yaml_str) diff --git a/tests/scenario/test_scenario_disabled_risk_groups.py b/tests/scenario/test_scenario_disabled_risk_groups.py index f72dcc2..6ff16e9 100644 --- a/tests/scenario/test_scenario_disabled_risk_groups.py +++ b/tests/scenario/test_scenario_disabled_risk_groups.py @@ -125,7 +125,7 @@ def test_disabled_parent_cascades_to_membership_rule_children() -> None: def test_disabled_group_still_disables_direct_members() -> None: - """Moving the cascade later keeps direct-member disabling intact.""" + """Direct members of a disabled group are disabled as well.""" yaml_content = """ network: nodes: diff --git a/tests/scenario/test_scenario_modes.py b/tests/scenario/test_scenario_modes.py index 8b79a76..a0f27ea 100644 --- a/tests/scenario/test_scenario_modes.py +++ b/tests/scenario/test_scenario_modes.py @@ -37,7 +37,5 @@ def test_scenario_parses_modes_and_weight_by() -> None: scenario = Scenario.from_yaml(scenario_yaml) policy = scenario.failure_policy_set.get_policy("weighted_modes") - # Ensure modes parsed and stored assert policy.modes and len(policy.modes) == 2 - # Ensure weight_by propagated into rule assert policy.modes[0].rules[0].weight_by == "cost" diff --git a/tests/scenario/test_schema_validation.py b/tests/scenario/test_schema_validation.py index 2bf4782..38668cc 100644 --- a/tests/scenario/test_schema_validation.py +++ b/tests/scenario/test_schema_validation.py @@ -3,16 +3,15 @@ import json from pathlib import Path +import jsonschema import pytest import yaml from ngraph.scenario import Scenario -jsonschema = pytest.importorskip("jsonschema") - class TestSchemaValidation: - """Tests for JSON schema validation functionality.""" + """Tests that validate scenario data against the JSON schema.""" @pytest.fixture def schema(self): @@ -66,12 +65,11 @@ def test_schema_validates_simple_scenario(self, schema): workflow: - type: BuildGraph name: build_graph - - type: CapacityEnvelopeAnalysis + - type: MaxFlow name: capacity_test source: "A" target: "C" iterations: 1 - baseline: false failure_policy: null mode: "combine" """ @@ -101,7 +99,7 @@ def test_schema_rejects_invalid_top_level_key(self, schema): jsonschema.validate(invalid_data, schema) def test_schema_validates_risk_groups_structure(self, schema): - """Test that the schema correctly validates risk groups structure.""" + """Test that the schema accepts risk groups with attrs and children.""" valid_data = { "network": {"nodes": {}, "links": []}, "risk_groups": [ @@ -393,12 +391,11 @@ def test_schema_validates_traffic_matrices(self, schema): workflow: - type: BuildGraph name: build_graph - - type: CapacityEnvelopeAnalysis + - type: MaxFlow name: capacity_test source: "spine1" target: "leaf1" iterations: 1 - baseline: false failure_policy: null mode: "combine" """ @@ -442,8 +439,7 @@ def test_schema_validates_variable_expansion(self, schema): jsonschema.validate(data, schema) def test_schema_consistency_with_netgraph_validation(self, schema): - """Test that schema validation is consistent with NetGraph's validation.""" - # Test data that should be valid for both schema and NetGraph + """Test that a scenario valid under the schema also loads in NetGraph.""" valid_yaml = """ network: name: Test Network @@ -466,7 +462,6 @@ def test_schema_consistency_with_netgraph_validation(self, schema): """ data = yaml.safe_load(valid_yaml) - # Should validate with both our schema and NetGraph jsonschema.validate(data, schema) scenario = Scenario.from_yaml(valid_yaml) assert scenario is not None diff --git a/tests/utils/test_boolean_keys.py b/tests/utils/test_boolean_keys.py index 39c989b..73d44fc 100644 --- a/tests/utils/test_boolean_keys.py +++ b/tests/utils/test_boolean_keys.py @@ -1,4 +1,4 @@ -"""Test YAML boolean key handling - both utility functions and integration tests.""" +"""Tests for YAML boolean keys: normalize_yaml_dict_keys and demand set names.""" import textwrap @@ -45,7 +45,7 @@ def test_normalize_yaml_dict_keys_all_strings(): def test_normalize_yaml_dict_keys_empty_dict(): - """Test that empty dictionary is handled correctly.""" + """Test that an empty dictionary maps to an empty dictionary.""" result = normalize_yaml_dict_keys({}) assert result == {} @@ -72,7 +72,7 @@ def test_normalize_yaml_dict_keys_preserves_values(): # ============================================================================= -# Integration Tests for traffic_matrix_set boolean key handling +# Integration Tests for boolean keys under demands # ============================================================================= diff --git a/tests/utils/test_seed_manager.py b/tests/utils/test_seed_manager.py index c3f295d..9043027 100644 --- a/tests/utils/test_seed_manager.py +++ b/tests/utils/test_seed_manager.py @@ -1,10 +1,10 @@ -"""Tests for seed management functionality.""" +"""Tests for SeedManager.""" from ngraph.utils.seed_manager import SeedManager class TestSeedManager: - """Test SeedManager functionality.""" + """Tests for SeedManager seed derivation.""" def test_init_with_master_seed(self): """Test SeedManager initialization with master seed.""" @@ -53,7 +53,7 @@ def test_derive_seed_different_master_seeds(self): assert seed1 != seed2 def test_derive_seed_various_component_types(self): - """Test seed derivation with various component types.""" + """Test that str, int and mixed components each derive distinct seeds.""" seed_mgr = SeedManager(42) # Test with strings @@ -82,10 +82,9 @@ def test_seed_derivation_consistency(self): assert seed1 == seed2 def test_seed_distribution(self): - """Test that derived seeds have good distribution.""" + """Test that 1000 derived seeds are nearly all unique.""" seed_mgr = SeedManager(42) - # Generate many seeds seeds = [] for i in range(1000): seed = seed_mgr.derive_seed("test", i) diff --git a/tests/workflow/test_alpha_resolution_errors.py b/tests/workflow/test_alpha_resolution_errors.py index 1ce621b..2446998 100644 --- a/tests/workflow/test_alpha_resolution_errors.py +++ b/tests/workflow/test_alpha_resolution_errors.py @@ -1,8 +1,8 @@ """Regression tests for TrafficMatrixPlacement._resolve_alpha error reporting. -A missing/misordered producer step must be reported as such, instead of the +A missing/misordered producer step must be reported as such, instead of a misleading alpha_from_field error (Results.get_step returns {} for unknown -steps, so the old isinstance guard was dead code). +steps, so an isinstance guard alone cannot detect it). """ from __future__ import annotations @@ -59,3 +59,11 @@ def test_missing_field_in_existing_step_reports_field_error() -> None: ) with pytest.raises(ValueError, match="alpha_from_field 'data.alpha_star' missing"): step.execute(scenario) + + +def test_alpha_and_alpha_from_step_are_exclusive() -> None: + """An explicit alpha next to alpha_from_step would be ignored, so it raises.""" + with pytest.raises(ValueError, match="either alpha or alpha_from_step"): + TrafficMatrixPlacement( + name="tm", demand_set="default", alpha=2.0, alpha_from_step="msd" + ) diff --git a/tests/workflow/test_base.py b/tests/workflow/test_base.py index 3ffd048..adf1e22 100644 --- a/tests/workflow/test_base.py +++ b/tests/workflow/test_base.py @@ -22,7 +22,7 @@ def test_workflow_step_is_abstract() -> None: def test_register_workflow_step_decorator() -> None: """ Verify that using the @register_workflow_step decorator registers - the subclass in the WORKFLOW_STEP_REGISTRY with the correct key. + the subclass in the WORKFLOW_STEP_REGISTRY under the decorator's key. """ @register_workflow_step("TestStep") @@ -30,7 +30,6 @@ class TestStep(WorkflowStep): def run(self, scenario) -> None: pass - # Check if the class is registered correctly assert "TestStep" in WORKFLOW_STEP_REGISTRY assert WORKFLOW_STEP_REGISTRY["TestStep"] == TestStep @@ -49,7 +48,6 @@ def run(self, scenario) -> None: step_instance = ConcreteStep(name="test_step") step_instance.run(mock_scenario) - # Check if run() was actually invoked assert getattr(step_instance, "_ran", False) is True assert step_instance.name == "test_step" diff --git a/tests/workflow/test_build_graph_attrs.py b/tests/workflow/test_build_graph_attrs.py index 0e3f338..4e042e2 100644 --- a/tests/workflow/test_build_graph_attrs.py +++ b/tests/workflow/test_build_graph_attrs.py @@ -1,10 +1,10 @@ """Regression tests for BuildGraph with reserved-key collisions in attrs. -BuildGraph previously crashed with TypeError ("got multiple values for -keyword argument") when node attrs contained "disabled" or link attrs -contained "id", "capacity", "cost", or "disabled". Reserved keys must win -over user attrs, matching the precedence documented for flatten_node_attrs -and flatten_link_attrs. +Node attrs containing "disabled", or link attrs containing "id", +"capacity", "cost" or "disabled", must not make BuildGraph fail with +TypeError ("got multiple values for keyword argument"). Reserved keys must +win over user attrs, matching the precedence documented for +flatten_node_attrs and flatten_link_attrs. """ from unittest.mock import MagicMock @@ -102,7 +102,7 @@ def test_reserved_link_keys_win_over_user_attrs(scenario_with_reserved_attrs): def test_build_graph_without_reserved_keys_unchanged(): - """Plain attrs still pass through unchanged.""" + """Attrs without reserved keys pass through unchanged.""" scenario = MagicMock() scenario.seed = None scenario._execution_counter = 0 diff --git a/tests/workflow/test_cost_power.py b/tests/workflow/test_cost_power.py index 49cd843..39524ea 100644 --- a/tests/workflow/test_cost_power.py +++ b/tests/workflow/test_cost_power.py @@ -184,10 +184,9 @@ class _Scenario: def test_cost_power_runs_despite_hardware_capacity_violation() -> None: """CostPower aggregates costs even when hardware validation would fail. - Previously the step built a NetworkExplorer with strict validation as a - side effect, so a node whose attached link capacity exceeded its hardware - capacity crashed the cost aggregation. The step must not depend on the - explorer and must complete regardless of hardware violations. + The step must not depend on a strict-validation NetworkExplorer: a node + whose attached link capacity exceeds its hardware capacity must not stop + the cost aggregation. """ net = Network() net.add_node( @@ -207,8 +206,8 @@ def test_cost_power_runs_despite_hardware_capacity_violation() -> None: comps = _build_simple_components() - # Sanity check: explorer strict validation rejects this network, which is - # exactly what made the old CostPower implementation crash. + # Sanity check: explorer strict validation rejects this network, so the + # step cannot be relying on it. with pytest.raises(ValueError, match="exceeds hardware"): NetworkExplorer.explore_network(net, components_library=comps) diff --git a/tests/workflow/test_capacity_envelope_analysis.py b/tests/workflow/test_max_flow_step.py similarity index 69% rename from tests/workflow/test_capacity_envelope_analysis.py rename to tests/workflow/test_max_flow_step.py index 95586d8..930bf6a 100644 --- a/tests/workflow/test_capacity_envelope_analysis.py +++ b/tests/workflow/test_max_flow_step.py @@ -8,6 +8,7 @@ from ngraph.model.failure.policy_set import FailurePolicySet from ngraph.model.network import Link, Network, Node from ngraph.results import Results +from ngraph.results.flow import FlowEntry, FlowIterationResult, FlowSummary from ngraph.scenario import Scenario from ngraph.types.base import FlowPlacement from ngraph.workflow.max_flow_step import MaxFlow @@ -15,7 +16,7 @@ @pytest.fixture def simple_network() -> Network: - """Create a simple test network.""" + """A -> B (cap 10) -> C (cap 5).""" network = Network() network.add_node(Node("A")) network.add_node(Node("B")) @@ -44,12 +45,11 @@ def simple_failure_policy() -> FailurePolicy: @pytest.fixture def mock_scenario(simple_network, simple_failure_policy) -> Scenario: - """Create a mock scenario for testing.""" + """MagicMock Scenario with simple_network and failure policy 'test_policy'.""" scenario = MagicMock(spec=Scenario) scenario.network = simple_network scenario.results = Results() - # Create failure policy set policy_set = FailurePolicySet() policy_set.add("test_policy", simple_failure_policy) scenario.failure_policy_set = policy_set @@ -105,18 +105,22 @@ def test_initialization_custom_values(self): assert step.include_flow_details is True def test_validation_errors(self): - """Test parameter validation.""" + """Invalid iterations, parallelism and mode raise ValueError.""" with pytest.raises(ValueError, match="iterations must be >= 0"): MaxFlow(source="^A", target="^C", iterations=-1) with pytest.raises(ValueError, match="parallelism must be >= 1"): MaxFlow(source="^A", target="^C", parallelism=0) + for bad in (2.5, True, "4"): + with pytest.raises(ValueError, match="integer or 'auto'"): + MaxFlow(source="^A", target="^C", parallelism=bad) + with pytest.raises(ValueError, match="Invalid mode"): MaxFlow(source="^A", target="^C", mode="invalid") def test_flow_placement_enum_usage(self): - """Test that FlowPlacement enum is used correctly.""" + """flow_placement keeps the FlowPlacement enum value passed in.""" step = MaxFlow( source="^A", target="^C", flow_placement=FlowPlacement.PROPORTIONAL ) @@ -127,43 +131,34 @@ def test_run_with_mock_failure_manager( self, mock_failure_manager_class, mock_scenario ): """Test running the workflow step with mocked FailureManager.""" - # Setup mock FailureManager mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager - # Mock the convenience method results returning unified flow_results - # Baseline is separate, results contains only failure iterations + # Baseline is separate; results holds only failure iterations + entry = FlowEntry( + source="A", + destination="C", + priority=0, + demand=5.0, + placed=5.0, + dropped=0.0, + ) mock_raw = { - "baseline": { - "failure_id": "", - "failure_state": {"excluded_nodes": [], "excluded_links": []}, - "failure_trace": None, - "flows": [ - { - "source": "A", - "destination": "C", - "priority": 0, - "demand": 5.0, - "placed": 5.0, - "dropped": 0.0, - "cost_distribution": {}, - "data": {}, - } - ], - "summary": { - "total_demand": 5.0, - "total_placed": 5.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 1, - }, - }, + "baseline": FlowIterationResult( + flows=[entry], + summary=FlowSummary( + total_demand=5.0, + total_placed=5.0, + overall_ratio=1.0, + dropped_flows=0, + num_flows=1, + ), + ), "results": [], # No failure iterations for this test "metadata": {"iterations": 1, "parallelism": 1}, } mock_failure_manager.run_max_flow_monte_carlo.return_value = mock_raw - # Create and run the step step = MaxFlow( source="^A", target="^C", @@ -174,14 +169,12 @@ def test_run_with_mock_failure_manager( step.name = "envelope" step.execute(mock_scenario) - # Verify FailureManager was created correctly mock_failure_manager_class.assert_called_once_with( network=mock_scenario.network, failure_policy_set=mock_scenario.failure_policy_set, policy_name="test_policy", ) - # Verify convenience method was called with correct parameters _, kwargs = mock_failure_manager.run_max_flow_monte_carlo.call_args assert kwargs["source"] == "^A" assert kwargs["target"] == "^C" @@ -192,9 +185,8 @@ def test_run_with_mock_failure_manager( assert kwargs["flow_placement"] == step.flow_placement assert kwargs["seed"] is None assert kwargs["store_failure_patterns"] is False - assert kwargs["include_flow_summary"] is False + assert kwargs["include_flow_details"] is False - # Verify results were processed into metadata + data with flow_results exported = mock_scenario.results.to_dict() data = exported["steps"]["envelope"]["data"] assert isinstance(data, dict) @@ -205,42 +197,23 @@ def test_run_with_mock_failure_manager( @patch("ngraph.workflow.max_flow_step.FailureManager") def test_run_with_failure_patterns(self, mock_failure_manager_class, mock_scenario): """Test running with failure pattern storage enabled.""" - # Setup mock FailureManager mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager - # Mock raw results with failure_trace on each result mock_raw = { + "baseline": FlowIterationResult(), "results": [ - MagicMock( + FlowIterationResult( failure_id="deadbeef", failure_state={"excluded_nodes": ["node1"], "excluded_links": []}, failure_trace={"mode_index": 0}, occurrence_count=2, - to_dict=lambda: { - "failure_id": "deadbeef", - "failure_state": { - "excluded_nodes": ["node1"], - "excluded_links": [], - }, - "failure_trace": {"mode_index": 0}, - "occurrence_count": 2, - "flows": [], - "summary": { - "total_demand": 0.0, - "total_placed": 0.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 0, - }, - }, ) ], "metadata": {"iterations": 2, "parallelism": 1, "unique_patterns": 1}, } mock_failure_manager.run_max_flow_monte_carlo.return_value = mock_raw - # Create and run the step with failure pattern storage step = MaxFlow( source="^A", target="^C", @@ -250,13 +223,12 @@ def test_run_with_failure_patterns(self, mock_failure_manager_class, mock_scenar ) step.execute(mock_scenario) - # Verify parameters passed _, kwargs = mock_failure_manager.run_max_flow_monte_carlo.call_args assert kwargs["store_failure_patterns"] is True - assert kwargs["include_flow_summary"] is False + assert kwargs["include_flow_details"] is False def test_capacity_envelope_with_failures_mocked(self): - """Test capacity envelope step with mocked FailureManager.""" + """MaxFlow in a real Scenario stores metadata and data.""" step = MaxFlow( source="^A", target="^C", @@ -273,44 +245,21 @@ def test_capacity_envelope_with_failures_mocked(self): results=Results(), ) - # Mock the convenience method call results (unified flow_results) - # Baseline is separate, results contains only failures + # Baseline is separate; results holds only failure iterations mock_raw = { - "baseline": { - "failure_id": "", - "failure_state": {"excluded_nodes": [], "excluded_links": []}, - "failure_trace": None, - "flows": [], - "summary": { - "total_demand": 0.0, - "total_placed": 0.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 0, - }, - }, + "baseline": FlowIterationResult( + failure_state={"excluded_nodes": [], "excluded_links": []} + ), "results": [ - { - "failure_id": "abc123", - "failure_state": { - "excluded_nodes": [], - "excluded_links": ["link1"], - }, - "failure_trace": {"mode_index": 0}, - "flows": [], - "summary": { - "total_demand": 0.0, - "total_placed": 0.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 0, - }, - } + FlowIterationResult( + failure_id="abc123", + failure_state={"excluded_nodes": [], "excluded_links": ["link1"]}, + failure_trace={"mode_index": 0}, + ) ], "metadata": {"iterations": 2, "parallelism": 1}, } - # Mock the FailureManager class and its convenience method with patch("ngraph.workflow.max_flow_step.FailureManager") as mock_fm_class: mock_fm_instance = mock_fm_class.return_value mock_fm_instance.run_max_flow_monte_carlo.return_value = mock_raw @@ -318,55 +267,43 @@ def test_capacity_envelope_with_failures_mocked(self): step.name = "envelope" step.execute(scenario) - # Check that results were stored under metadata/data keys exported = scenario.results.to_dict() assert exported["steps"]["envelope"]["metadata"] is not None assert exported["steps"]["envelope"]["data"] is not None @patch("ngraph.workflow.max_flow_step.FailureManager") - def test_include_flow_summary_functionality( + def test_include_flow_details_functionality( self, mock_failure_manager_class, mock_scenario ): - """Test that include_flow_details parameter is passed through correctly.""" - # Setup mock FailureManager + """include_flow_details=True is forwarded to run_max_flow_monte_carlo.""" mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager # Mock results with flow details (cost_distribution and min_cut edges) + entry = FlowEntry( + source="A", + destination="C", + priority=0, + demand=5.0, + placed=5.0, + dropped=0.0, + cost_distribution={3.0: 5.0}, + data={"edges": ["A|B|0:fwd"], "edges_kind": "min_cut"}, + ) + summary = FlowSummary( + total_demand=5.0, + total_placed=5.0, + overall_ratio=1.0, + dropped_flows=0, + num_flows=1, + ) mock_raw = { - "results": [ - { - "failure_id": "", - "failure_state": {"excluded_nodes": [], "excluded_links": []}, - "flows": [ - { - "source": "A", - "destination": "C", - "priority": 0, - "demand": 5.0, - "placed": 5.0, - "dropped": 0.0, - "cost_distribution": {"3": 5.0}, - "data": { - "edges": ["('A','B','k')"], - "edges_kind": "min_cut", - }, - } - ], - "summary": { - "total_demand": 5.0, - "total_placed": 5.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 1, - }, - } - ], - "metadata": {"iterations": 1, "parallelism": 1, "baseline": False}, + "baseline": FlowIterationResult(flows=[entry], summary=summary), + "results": [FlowIterationResult(flows=[entry], summary=summary)], + "metadata": {"iterations": 1, "parallelism": 1, "unique_patterns": 1}, } mock_failure_manager.run_max_flow_monte_carlo.return_value = mock_raw - # Test with include_flow_details=True step = MaxFlow( source="^A", target="^C", @@ -376,11 +313,8 @@ def test_include_flow_summary_functionality( ) step.execute(mock_scenario) - # Verify the parameter was passed through correctly _, kwargs = mock_failure_manager.run_max_flow_monte_carlo.call_args - assert kwargs["include_flow_summary"] is True - - # Verify run without error; detailed stats are embedded in flow_results entries + assert kwargs["include_flow_details"] is True @patch("ngraph.workflow.max_flow_step.FailureManager") def test_failure_trace_persisted_on_results( @@ -389,7 +323,6 @@ def test_failure_trace_persisted_on_results( """Test that failure_trace is persisted on flow_results.""" mock_failure_manager = mock_failure_manager_class.return_value - # Create mock result with failure_trace mock_result = MagicMock() mock_result.failure_id = "abc123" mock_result.failure_state = {"excluded_nodes": [], "excluded_links": ["link1"]} @@ -405,7 +338,7 @@ def test_failure_trace_persisted_on_results( "selected_ids": ["link1"], } ], - "expansion": {"nodes": [], "links": [], "risk_groups": []}, + "expansion": {"nodes": [], "links": []}, } mock_result.occurrence_count = 2 mock_result.to_dict.return_value = { @@ -423,7 +356,6 @@ def test_failure_trace_persisted_on_results( }, } - # Mock baseline mock_baseline = MagicMock() mock_baseline.to_dict.return_value = { "failure_id": "", @@ -457,18 +389,16 @@ def test_failure_trace_persisted_on_results( ) step.execute(mock_scenario) - # Verify results are persisted exported = mock_scenario.results.to_dict() data = exported["steps"]["test_step"]["data"] - # Verify flow_results contains failure_trace assert len(data["flow_results"]) == 1 result = data["flow_results"][0] assert result["failure_id"] == "abc123" assert result["failure_trace"]["mode_index"] == 0 assert result["occurrence_count"] == 2 - # Verify baseline is stored separately in data + # Baseline is stored separately from flow_results assert "baseline" in data assert data["baseline"]["failure_id"] == "" @@ -498,6 +428,7 @@ def test_no_failure_trace_when_disabled( } mock_raw = { + "baseline": FlowIterationResult(), "results": [mock_result], "metadata": {"iterations": 1, "parallelism": 1, "unique_patterns": 1}, } @@ -513,7 +444,6 @@ def test_no_failure_trace_when_disabled( ) step.execute(mock_scenario) - # Verify flow_results exist but have no trace exported = mock_scenario.results.to_dict() data = exported["steps"]["test_step_disabled"]["data"] assert len(data["flow_results"]) == 1 diff --git a/tests/workflow/test_maximum_supported_demand.py b/tests/workflow/test_maximum_supported_demand.py index 6eed8c0..76128bc 100644 --- a/tests/workflow/test_maximum_supported_demand.py +++ b/tests/workflow/test_maximum_supported_demand.py @@ -50,7 +50,7 @@ def _eval(cache, alpha): alpha_star = exported["steps"]["msd_step"]["data"]["alpha_star"] assert abs(alpha_star - 1.3) <= 0.02 ctx = exported["steps"]["msd_step"]["data"].get("context", {}) - assert ctx.get("acceptance_rule") == "hard" + assert ctx.get("demand_set") == "default" base = exported["steps"]["msd_step"]["data"].get("base_demands", []) assert base and base[0]["source"] == "A" @@ -147,7 +147,7 @@ def _scaled_config(alpha: float) -> list[dict]: # --------------------------------------------------------------------------- -# Edge-case tests for _binary_search: all-feasible / bracket-exhaustion bugs +# _binary_search boundaries: every probe feasible, bracket iterations exhausted # --------------------------------------------------------------------------- @@ -203,7 +203,7 @@ def test_msd_bracket_exhausted_alpha_max_feasible() -> None: """Bracket iters exhaust before alpha_max, but alpha_max IS feasible -> return alpha_max.""" step = _make_step(alpha_max=1e6, max_bracket_iters=4) # With 4 iters: probes 1,2,4,8,16 -> lower=16, upper=None - # Fix probes alpha_max=1e6 directly -> feasible -> returns 1e6 + # Then alpha_max=1e6 is probed directly -> feasible -> returns 1e6 result = step._binary_search(_threshold_probe(threshold=None)) assert result == 1e6 @@ -213,7 +213,7 @@ def test_msd_bracket_exhausted_alpha_max_infeasible() -> None: threshold = 500.0 step = _make_step(alpha_max=1e6, max_bracket_iters=4, resolution=0.01) # With 4 iters: probes 1,2,4,8,16 -> lower=16, upper=None - # Fix probes alpha_max=1e6 -> infeasible -> bracket [16, 1e6] -> bisect to ~500 + # Then alpha_max=1e6 is probed -> infeasible -> bracket [16, 1e6] -> bisect to ~500 result = step._binary_search(_threshold_probe(threshold=threshold)) assert abs(result - threshold) <= 0.02 @@ -223,47 +223,3 @@ def test_msd_threshold_exactly_at_alpha_max() -> None: step = _make_step(alpha_max=10.0) result = step._binary_search(_threshold_probe(threshold=10.0)) assert result == 10.0 - - -def test_msd_auto_vs_one_equivalence_single_link() -> None: - """Test that MSD with auto vs 1 placement rounds produces equivalent results.""" - from ngraph.workflow.maximum_supported_demand_step import ( - MaximumSupportedDemand as MSD, - ) - from tests.integration.helpers import ScenarioDataBuilder - - # Same single-link scenario; compare auto vs 1 rounds - scenario = ( - ScenarioDataBuilder() - .with_simple_nodes(["A", "B"]) - .with_simple_links([("A", "B", 10.0)]) - .with_traffic_demand("A", "B", 5.0, demand_set="default") - .build_scenario() - ) - - step_auto = MSD( - name="msd_auto", - demand_set="default", - alpha_start=1.0, - growth_factor=2.0, - resolution=0.01, - placement_rounds="auto", - ) - step_one = MSD( - name="msd_one", - demand_set="default", - alpha_start=1.0, - growth_factor=2.0, - resolution=0.01, - placement_rounds=1, - ) - - scenario.results = Results() - step_auto.execute(scenario) - step_one.execute(scenario) - - exported = scenario.results.to_dict() - alpha_auto = float(exported["steps"]["msd_auto"]["data"]["alpha_star"]) - alpha_one = float(exported["steps"]["msd_one"]["data"]["alpha_star"]) - # Both should find approximately the same alpha* for this simple case - assert abs(alpha_auto - alpha_one) <= 0.02 diff --git a/tests/workflow/test_msd_perf_safety.py b/tests/workflow/test_msd_perf_safety.py index b9f25e7..b22f83f 100644 --- a/tests/workflow/test_msd_perf_safety.py +++ b/tests/workflow/test_msd_perf_safety.py @@ -15,7 +15,6 @@ def __init__(self, network: Any, demand_set: Any, results: Any) -> None: def test_msd_deterministic_evaluation(monkeypatch): - # Build a tiny scenario from ngraph.model.demand.matrix import DemandSet from ngraph.model.demand.spec import TrafficDemand from ngraph.model.network import Link, Network, Node @@ -36,7 +35,6 @@ def test_msd_deterministic_evaluation(monkeypatch): scenario = _ScenarioStub(net, demand_set, Results()) - # Run MSD with a simple scenario msd = MaximumSupportedDemand( demand_set="default", alpha_start=1.0, diff --git a/tests/workflow/test_placement_rounds_deprecated.py b/tests/workflow/test_placement_rounds_deprecated.py deleted file mode 100644 index 3defdf4..0000000 --- a/tests/workflow/test_placement_rounds_deprecated.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Regression tests for the deprecated placement_rounds parameter. - -placement_rounds never affected placement (the core engine handles -optimization internally). It must remain accepted for YAML backward -compatibility but emit a deprecation warning, must not be forwarded to -FailureManager, and must not be exported in result contexts as if it -influenced the run. -""" - -from __future__ import annotations - -import logging -from unittest.mock import MagicMock, patch - -from ngraph.results.store import Results -from ngraph.workflow.maximum_supported_demand_step import MaximumSupportedDemand -from ngraph.workflow.traffic_matrix_placement_step import TrafficMatrixPlacement - - -def test_msd_placement_rounds_warns_when_set(caplog) -> None: - with caplog.at_level( - logging.WARNING, logger="ngraph.workflow.maximum_supported_demand_step" - ): - MaximumSupportedDemand(name="msd", demand_set="default", placement_rounds=2) - assert any("placement_rounds" in rec.message for rec in caplog.records) - - -def test_tm_placement_rounds_warns_when_set(caplog) -> None: - with caplog.at_level( - logging.WARNING, logger="ngraph.workflow.traffic_matrix_placement_step" - ): - TrafficMatrixPlacement(name="tm", demand_set="default", placement_rounds=2) - assert any("placement_rounds" in rec.message for rec in caplog.records) - - -def test_default_placement_rounds_does_not_warn(caplog) -> None: - with caplog.at_level(logging.WARNING, logger="ngraph.workflow"): - MaximumSupportedDemand(name="msd", demand_set="default") - TrafficMatrixPlacement(name="tm", demand_set="default") - assert not any("placement_rounds" in rec.message for rec in caplog.records) - - -@patch.object(MaximumSupportedDemand, "_evaluate_alpha") -@patch.object(MaximumSupportedDemand, "_build_cache") -def test_msd_context_omits_placement_rounds( - mock_build_cache: MagicMock, mock_eval: MagicMock -) -> None: - mock_build_cache.return_value = MagicMock() - mock_eval.side_effect = lambda cache, alpha: ( - alpha <= 1.0, - {"placement_ratio": 1.0}, - ) - - mock_scenario = MagicMock() - td = MagicMock() - td.source = "A" - td.target = "B" - td.volume = 10.0 - td.mode = "pairwise" - td.priority = 0 - td.flow_policy = None - mock_scenario.demand_set.get_set.return_value = [td] - mock_scenario.results = Results() - - step = MaximumSupportedDemand(name="msd", demand_set="default", placement_rounds=3) - step.execute(mock_scenario) - - context = mock_scenario.results.to_dict()["steps"]["msd"]["data"]["context"] - assert "placement_rounds" not in context - - -@patch("ngraph.workflow.traffic_matrix_placement_step.FailureManager") -def test_tm_does_not_forward_placement_rounds(mock_fm_class) -> None: - mock_scenario = MagicMock() - td = MagicMock() - td.source = "A" - td.target = "B" - td.volume = 10.0 - td.mode = "pairwise" - td.priority = 0 - td.flow_policy = None - mock_scenario.demand_set.get_set.return_value = [td] - mock_scenario.results = Results() - - mock_fm = MagicMock() - mock_fm_class.return_value = mock_fm - mock_fm.run_demand_placement_monte_carlo.return_value = { - "results": [], - "metadata": {"iterations": 1, "unique_patterns": 0}, - } - - step = TrafficMatrixPlacement( - name="tm", demand_set="default", iterations=1, placement_rounds=5 - ) - step.execute(mock_scenario) - - _, kwargs = mock_fm.run_demand_placement_monte_carlo.call_args - assert "placement_rounds" not in kwargs - - context = mock_scenario.results.to_dict()["steps"]["tm"]["data"]["context"] - assert "placement_rounds" not in context diff --git a/tests/workflow/test_seed_provenance.py b/tests/workflow/test_seed_provenance.py index 46095ec..b3396e2 100644 --- a/tests/workflow/test_seed_provenance.py +++ b/tests/workflow/test_seed_provenance.py @@ -1,6 +1,6 @@ """Regression tests for seed provenance metadata in WorkflowStep.execute(). -The recorded seed_source/active_seed must reflect the seed the step actually +The recorded seed_source must reflect the seed the step actually uses (self.seed), not the scenario-level seed it never consumes. """ @@ -39,7 +39,6 @@ def test_unseeded_step_with_scenario_seed_reports_none() -> None: assert md.scenario_seed == 42 assert md.step_seed is None assert md.seed_source == "none" - assert md.active_seed is None def test_directly_seeded_step_reports_explicit() -> None: @@ -51,7 +50,6 @@ def test_directly_seeded_step_reports_explicit() -> None: assert md.scenario_seed is None assert md.step_seed == 99 assert md.seed_source == "explicit-step" - assert md.active_seed == 99 def test_scenario_derived_seed_reports_derived() -> None: @@ -65,4 +63,3 @@ def test_scenario_derived_seed_reports_derived() -> None: assert md.scenario_seed == 7 assert md.step_seed == 1234 assert md.seed_source == "scenario-derived" - assert md.active_seed == 1234 diff --git a/tests/workflow/test_step_name_collision.py b/tests/workflow/test_step_name_collision.py index 33708e3..d83ee39 100644 --- a/tests/workflow/test_step_name_collision.py +++ b/tests/workflow/test_step_name_collision.py @@ -1,7 +1,7 @@ """Regression tests for workflow step-name collision detection. -Programmatic scenarios with two unnamed steps of the same type previously -wrote to the same results namespace, silently dropping the first step's data. +Two unnamed steps of the same type in a programmatic scenario must not share +a results namespace, which would silently drop the first step's data. """ from __future__ import annotations diff --git a/tests/workflow/test_tm_analysis_perf_safety.py b/tests/workflow/test_tm_analysis_perf_safety.py index 9ab6960..5aaf26a 100644 --- a/tests/workflow/test_tm_analysis_perf_safety.py +++ b/tests/workflow/test_tm_analysis_perf_safety.py @@ -54,7 +54,7 @@ def put(self, key: str, value: Any) -> None: self._store[self._active][key] = value def get_all_step_metadata(self): - # Return empty mapping; caller code should handle gracefully + # Empty mapping: the step must not depend on prior step metadata return {} class _FailurePolicySetStub: @@ -65,7 +65,6 @@ class _FailurePolicySetStub: step = TrafficMatrixPlacement( demand_set="default", iterations=2, - placement_rounds="auto", include_flow_details=False, ) step.name = "tm_placement" diff --git a/tests/workflow/test_traffic_matrix_placement.py b/tests/workflow/test_traffic_matrix_placement.py index a241d4c..6864fa0 100644 --- a/tests/workflow/test_traffic_matrix_placement.py +++ b/tests/workflow/test_traffic_matrix_placement.py @@ -5,17 +5,51 @@ import pytest from ngraph.model.demand.spec import TrafficDemand +from ngraph.results.flow import FlowEntry, FlowIterationResult, FlowSummary from ngraph.results.store import Results from ngraph.workflow.traffic_matrix_placement_step import ( TrafficMatrixPlacement, ) +def _iteration( + placed: float = 10.0, demand: float = 10.0, data: dict | None = None +) -> FlowIterationResult: + """One A->B placement iteration as FailureManager returns it.""" + entry = FlowEntry( + source="A", + destination="B", + priority=0, + demand=demand, + placed=placed, + dropped=demand - placed, + data=data or {}, + ) + return FlowIterationResult( + flows=[entry], + summary=FlowSummary( + total_demand=demand, + total_placed=placed, + overall_ratio=placed / demand, + dropped_flows=int(placed < demand), + num_flows=1, + ), + ) + + +def _raw(results: list[FlowIterationResult]) -> dict: + """FailureManager Monte Carlo output around the given failure iterations.""" + return { + "baseline": _iteration(), + "results": results, + "metadata": {"iterations": len(results), "unique_patterns": len(results)}, + } + + @patch("ngraph.workflow.traffic_matrix_placement_step.FailureManager") def test_traffic_matrix_placement_stores_core_outputs( mock_failure_manager_class, ) -> None: - # Prepare mock scenario with traffic matrix and results store mock_scenario = MagicMock() mock_td = TrafficDemand( source="A", @@ -26,62 +60,7 @@ def test_traffic_matrix_placement_stores_core_outputs( mock_scenario.demand_set.get_set.return_value = [mock_td] # Mock FailureManager return value: baseline separate, failure iterations in results - mock_raw = { - "baseline": { - "demands": [ - { - "src": "A", - "dst": "B", - "priority": 0, - "offered_gbps": 10.0, - "placed_gbps": 10.0, - "placement_ratio": 1.0, - } - ], - "summary": { - "total_offered_gbps": 10.0, - "total_placed_gbps": 10.0, - "overall_ratio": 1.0, - }, - }, - "results": [ - { - "demands": [ - { - "src": "A", - "dst": "B", - "priority": 0, - "offered_gbps": 10.0, - "placed_gbps": 8.0, - "placement_ratio": 0.8, - } - ], - "summary": { - "total_offered_gbps": 10.0, - "total_placed_gbps": 8.0, - "overall_ratio": 0.8, - }, - }, - { - "demands": [ - { - "src": "A", - "dst": "B", - "priority": 0, - "offered_gbps": 10.0, - "placed_gbps": 10.0, - "placement_ratio": 1.0, - } - ], - "summary": { - "total_offered_gbps": 10.0, - "total_placed_gbps": 10.0, - "overall_ratio": 1.0, - }, - }, - ], - "metadata": {"iterations": 2, "unique_patterns": 1}, - } + mock_raw = _raw([_iteration(placed=8.0), _iteration()]) mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager mock_failure_manager.run_demand_placement_monte_carlo.return_value = mock_raw @@ -94,12 +73,10 @@ def test_traffic_matrix_placement_stores_core_outputs( mock_scenario.results = Results() step.execute(mock_scenario) - # Verify schema outputs exist and have expected shapes exported = mock_scenario.results.to_dict() data = exported["steps"]["tm_step"]["data"] assert isinstance(data, dict) assert "flow_results" in data and isinstance(data["flow_results"], list) - # example iteration-level sanity: ensure summaries present for it in data["flow_results"]: assert "summary" in it @@ -108,7 +85,6 @@ def test_traffic_matrix_placement_stores_core_outputs( def test_traffic_matrix_placement_flow_details_edges( mock_failure_manager_class, ) -> None: - # Prepare mock scenario with traffic matrix and results store mock_scenario = MagicMock() mock_td = TrafficDemand( source="A", @@ -119,72 +95,12 @@ def test_traffic_matrix_placement_flow_details_edges( mock_scenario.demand_set.get_set.return_value = [mock_td] # Mock FailureManager return value with edges used (baseline separate) - mock_raw = { - "baseline": { - "failure_id": "", - "failure_state": None, - "flows": [], - "summary": { - "total_demand": 10.0, - "total_placed": 10.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 0, - }, - "data": {}, - }, - "results": [ - { - "failure_id": "", - "failure_state": None, - "flows": [ - { - "source": "A", - "destination": "B", - "priority": 0, - "volume": 10.0, - "placed": 8.0, - "dropped": 2.0, - "cost_distribution": {}, - "data": {"edges": ["(u,v,k1)", "(x,y,k2)"]}, - } - ], - "summary": { - "total_demand": 10.0, - "total_placed": 8.0, - "overall_ratio": 0.8, - "dropped_flows": 1, - "num_flows": 1, - }, - "data": {}, - }, - { - "failure_id": "", - "failure_state": None, - "flows": [ - { - "source": "A", - "destination": "B", - "priority": 0, - "volume": 10.0, - "placed": 10.0, - "dropped": 0.0, - "cost_distribution": {}, - "data": {"edges": ["(u,v,k1)"]}, - } - ], - "summary": { - "total_demand": 10.0, - "total_placed": 10.0, - "overall_ratio": 1.0, - "dropped_flows": 0, - "num_flows": 1, - }, - "data": {}, - }, - ], - "metadata": {"iterations": 2, "unique_patterns": 1}, - } + mock_raw = _raw( + [ + _iteration(placed=8.0, data={"edges": ["(u,v,k1)", "(x,y,k2)"]}), + _iteration(data={"edges": ["(u,v,k1)"]}), + ] + ) mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager mock_failure_manager.run_demand_placement_monte_carlo.return_value = mock_raw @@ -199,7 +115,6 @@ def test_traffic_matrix_placement_flow_details_edges( mock_scenario.results = Results() step.execute(mock_scenario) - # Verify edges presence can be found in flow_results entries exported = mock_scenario.results.to_dict() data = exported["steps"]["tm_step"]["data"] flow_results = data["flow_results"] @@ -211,7 +126,6 @@ def test_traffic_matrix_placement_flow_details_edges( def test_traffic_matrix_placement_alpha_scales_demands( mock_failure_manager_class, ) -> None: - # Prepare mock scenario with a single traffic demand mock_scenario = MagicMock() mock_td = TrafficDemand( source="S", @@ -222,24 +136,11 @@ def test_traffic_matrix_placement_alpha_scales_demands( mock_scenario.demand_set.get_set.return_value = [mock_td] # Mock FailureManager return value (minimal valid structure) - mock_raw = { - "results": [ - { - "demands": [], - "summary": { - "total_offered_gbps": 0.0, - "total_placed_gbps": 0.0, - "overall_ratio": 1.0, - }, - } - ], - "metadata": {"iterations": 1, "unique_patterns": 1}, - } + mock_raw = _raw([_iteration()]) mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager mock_failure_manager.run_demand_placement_monte_carlo.return_value = mock_raw - # Run with alpha scaling step = TrafficMatrixPlacement( name="tm_step_alpha", demand_set="default", @@ -272,19 +173,7 @@ def test_traffic_matrix_placement_metadata_includes_alpha( ) mock_scenario.demand_set.get_set.return_value = [mock_td] - mock_raw = { - "results": [ - { - "demands": [], - "summary": { - "total_offered_gbps": 0.0, - "total_placed_gbps": 0.0, - "overall_ratio": 1.0, - }, - } - ], - "metadata": {"iterations": 1, "baseline": False, "unique_patterns": 1}, - } + mock_raw = _raw([_iteration()]) mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager mock_failure_manager.run_demand_placement_monte_carlo.return_value = mock_raw @@ -298,7 +187,6 @@ def test_traffic_matrix_placement_metadata_includes_alpha( mock_scenario.results = Results() step.execute(mock_scenario) - # Find data.context and assert it contains alpha exported = mock_scenario.results.to_dict() ctx = exported["steps"]["tm_step_meta"]["data"]["context"] assert ctx.get("alpha") == 3.0 @@ -308,7 +196,6 @@ def test_traffic_matrix_placement_metadata_includes_alpha( def test_traffic_matrix_placement_alpha_auto_uses_msd( mock_failure_manager_class, ) -> None: - # Scenario with one TD mock_scenario = MagicMock() td = TrafficDemand( source="S", @@ -318,8 +205,7 @@ def test_traffic_matrix_placement_alpha_auto_uses_msd( ) mock_scenario.demand_set.get_set.return_value = [td] - # Populate results metadata: prior MSD step - # Provide MSD step data in Results store + # Data from a prior MSD step in the results store mock_scenario.results = Results() mock_scenario.results.enter_step("msd1") mock_scenario.results.put("metadata", {}) @@ -327,7 +213,7 @@ def test_traffic_matrix_placement_alpha_auto_uses_msd( "data", { "alpha_star": 2.0, - "context": {"matrix_name": "default", "placement_rounds": "auto"}, + "context": {"demand_set": "default"}, "base_demands": [ { "source": "S", @@ -343,19 +229,7 @@ def test_traffic_matrix_placement_alpha_auto_uses_msd( mock_scenario.results.exit_step() # Minimal MC results - mock_raw = { - "results": [ - { - "demands": [], - "summary": { - "total_offered_gbps": 0.0, - "total_placed_gbps": 0.0, - "overall_ratio": 1.0, - }, - } - ], - "metadata": {"iterations": 1, "unique_patterns": 1}, - } + mock_raw = _raw([_iteration()]) mock_failure_manager = MagicMock() mock_failure_manager_class.return_value = mock_failure_manager mock_failure_manager.run_demand_placement_monte_carlo.return_value = mock_raw @@ -418,7 +292,6 @@ def test_traffic_matrix_placement_failure_trace_on_results( ) mock_scenario.demand_set.get_set.return_value = [mock_td] - # Create mock result with failure_trace and occurrence_count mock_result = MagicMock() mock_result.failure_id = "abc123" mock_result.failure_state = {"excluded_nodes": [], "excluded_links": ["L1"]} @@ -434,7 +307,7 @@ def test_traffic_matrix_placement_failure_trace_on_results( "selected_ids": ["L1"], } ], - "expansion": {"nodes": [], "links": [], "risk_groups": []}, + "expansion": {"nodes": [], "links": []}, } mock_result.occurrence_count = 2 mock_result.summary = MagicMock() @@ -454,7 +327,6 @@ def test_traffic_matrix_placement_failure_trace_on_results( }, } - # Mock baseline mock_baseline = MagicMock() mock_baseline.to_dict.return_value = { "failure_id": "", @@ -489,7 +361,6 @@ def test_traffic_matrix_placement_failure_trace_on_results( mock_scenario.results = Results() step.execute(mock_scenario) - # Verify flow_results contains failure_trace exported = mock_scenario.results.to_dict() data = exported["steps"]["tm_patterns"]["data"] @@ -499,7 +370,7 @@ def test_traffic_matrix_placement_failure_trace_on_results( assert result["failure_trace"]["mode_index"] == 0 assert result["occurrence_count"] == 2 - # Verify baseline is stored separately in data + # Baseline is stored separately from flow_results assert "baseline" in data assert data["baseline"]["failure_id"] == "" @@ -539,6 +410,7 @@ def test_traffic_matrix_placement_no_trace_when_disabled( } mock_raw = { + "baseline": _iteration(), "results": [mock_result], "metadata": {"iterations": 1, "parallelism": 1, "unique_patterns": 1}, } @@ -555,7 +427,6 @@ def test_traffic_matrix_placement_no_trace_when_disabled( mock_scenario.results = Results() step.execute(mock_scenario) - # Verify flow_results exist but have no trace exported = mock_scenario.results.to_dict() data = exported["steps"]["tm_no_patterns"]["data"] assert len(data["flow_results"]) == 1