From 86814c833570bf60a273651daa143c8af4bfbb93 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Wed, 28 Oct 2026 03:40:56 +0000 Subject: [PATCH 1/3] Export tests compare with hand-derived values only Remove the loops that damaged exported fields to see whether other tests notice, and six controller figures another test already checks. Every direct expected-answer test stays. Co-Authored-By: Claude Opus 5.5 --- .../controller/test_owner_figures_final_q.py | 16 ---- tests/controller/test_tables.py | 35 +-------- tests/results/test_failure_field_plants.py | 63 +-------------- tests/results/test_msd_field_plants.py | 52 +------------ tests/results/test_native_field_plants.py | 76 +------------------ tests/results/test_path_field_plants.py | 49 +----------- tests/results/test_question_field_plants.py | 39 +--------- tests/results/test_report_field_plants.py | 48 +----------- .../results/test_sensitivity_field_plants.py | 51 +------------ tests/results/test_tunnel_field_plants.py | 48 +----------- tests/results/test_units_miss_field_plants.py | 50 +----------- 11 files changed, 15 insertions(+), 512 deletions(-) diff --git a/tests/controller/test_owner_figures_final_q.py b/tests/controller/test_owner_figures_final_q.py index 59781bd..38fd2cd 100644 --- a/tests/controller/test_owner_figures_final_q.py +++ b/tests/controller/test_owner_figures_final_q.py @@ -290,22 +290,6 @@ def test_t2_rows_of_every_ebb_state(state, final, at_q1): # --- controller and native figures ------------------------------------------------------- -@pytest.mark.parametrize( - ("name", "value", "extra"), - [ - ("S1", 3200, 0), - ("S2", 1600, 16), - ("S3", 800, 0), - ("S4", 2400, 0), - ("S5", 1600, 8), - ("S6", 800, 0), - ], -) -def test_ebb_groups_physical_at_the_final_q(name, value, extra): - units, q = bound(ebb(name, scope="group", trees={"planes": planes()})) - assert physical(units, q) == value + extra * Fraction(q) - - def native_ebb(state, policy, volume): prep = prepare( Scenario( diff --git a/tests/controller/test_tables.py b/tests/controller/test_tables.py index 25fb0a7..6e255d9 100644 --- a/tests/controller/test_tables.py +++ b/tests/controller/test_tables.py @@ -1,14 +1,12 @@ """Destination tables: who is programmed, in what order, by which rule, and how they forward.""" import json -from copy import deepcopy from fractions import Fraction from io import StringIO import netgraph_core as ngc import pytest -import ngraph.export as exporting from ngraph import ( FlowPolicyPreset, MSDQuery, @@ -29,7 +27,7 @@ table, tree, ) -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact # Stacked sources: S (BGP) with uplinks S-U 30 and S-C 10; U, a programmed source inside the domain, # splits U-A 10 and U-B 20; A, B and C are exits to T. @@ -511,38 +509,12 @@ def expected_key(first, second): ("scope", "weights"), [("next_hop", (2, 1)), ("group", (1, 1))] ) def test_the_controller_key_reports_the_table_and_every_field_is_checked( - scope, weights, monkeypatch + scope, weights ): prep = fixture(scope) expected = expected_key(*weights) exact(controller_json(prep)["controller"], expected) exact(msd_json(prep)["controller"], expected) - assert plant_every_field(prep, expected, monkeypatch) > 30 - - -def plant_every_field(prep, expected, monkeypatch): - """Plant a missing, null and wrong value at every field of the key; count them.""" - original = exporting._controller - caught = 0 - for path in field_paths(expected): - for mode in ("missing", "null", "wrong"): - - def planted(demands, path=path, mode=mode): - result = deepcopy(original(demands)) - change(result["controller"], path, mode) - return result - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_controller", planted) - current = expected - for key in path: - current = current[key] - if mode == "null" and current is None: - continue - with pytest.raises(AssertionError): - exact(controller_json(prep)["controller"], expected) - caught += 1 - return caught def test_the_fixture_at_its_lossless_bound_tells_the_scopes_apart(): @@ -779,13 +751,12 @@ def _member(link, weight): ] -def test_the_all_routers_key_in_placement_and_msd(monkeypatch): +def test_the_all_routers_key_in_placement_and_msd(): from tests.controller.test_all_routers import A2, B1, merge_at_m prep = merge_at_m(A2, B1) exact(controller_json(prep)["controller"], ALL_ROUTERS_KEY) exact(msd_json(prep)["controller"], ALL_ROUTERS_KEY) - assert plant_every_field(prep, ALL_ROUTERS_KEY, monkeypatch) > 50 @pytest.mark.parametrize("scope", ["next_hop", "group", "all_routers"]) diff --git a/tests/results/test_failure_field_plants.py b/tests/results/test_failure_field_plants.py index 4371fdf..01cde51 100644 --- a/tests/results/test_failure_field_plants.py +++ b/tests/results/test_failure_field_plants.py @@ -1,12 +1,9 @@ """First-draw traces and sampling receipts have independent complete wire values.""" import sys -from copy import deepcopy -import jsonschema import pytest -import ngraph.export as exporting from ngraph import ( CapacityQuery, FailureMode, @@ -23,10 +20,8 @@ prepare, ) from ngraph.model.risk import RiskGroup -from ngraph.results import AnalysisMetadata from tests.results.test_analysis_wire import project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact def oracle(seed_source): @@ -109,59 +104,3 @@ def oracle(seed_source): @pytest.mark.parametrize("seed_source", ["query", "policy", "unseeded"]) def test_trace_and_sampling_fields_are_exact(seed_source): oracle(seed_source) - - -@pytest.mark.parametrize("seed_source", ["query", "policy", "unseeded"]) -@pytest.mark.parametrize("helper", ["_failure_case", "project"]) -def test_trace_and_sampling_helper_field_plants_are_caught( - monkeypatch, seed_source, helper -): - # The generic projection is planted only where it writes analysis metadata. - original = getattr(exporting, helper) - observed = [] - - def selected(args): - return helper != "project" or type(args[0]) is AnalysisMetadata - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - if selected(args): - observed.append(deepcopy(value)) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, helper, capture) - oracle(seed_source) - caught = equivalent = count = 0 - for index, baseline in enumerate(observed): - paths = tuple(field_paths(baseline)) - count += len(paths) - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - calls = [] - - def planted( - *args, path=path, mode=mode, index=index, calls=calls, **kwargs - ): - value = original(*args, **kwargs) - if not selected(args): - return value - if len(calls) == index: - value = deepcopy(value) - change(value, path, mode) - calls.append(None) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, helper, planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle(seed_source) - caught += 1 - oracle(seed_source) - assert caught + equivalent == count * 3 - print( - f"FAILURE FIELD PLANTS: {seed_source}/{helper}; {count} paths; {caught} caught; {equivalent} equivalent null->null" - ) diff --git a/tests/results/test_msd_field_plants.py b/tests/results/test_msd_field_plants.py index 795f087..f93c4d7 100644 --- a/tests/results/test_msd_field_plants.py +++ b/tests/results/test_msd_field_plants.py @@ -1,13 +1,10 @@ -"""Exact search histories/statuses and every emitted MSD helper field near miss.""" +"""Exact search histories, statuses and emitted MSD fields.""" import math -from copy import deepcopy from fractions import Fraction -import jsonschema import pytest -import ngraph.export as exporting from ngraph import ( DemandSet, DemandSpec, @@ -25,8 +22,7 @@ from ngraph.analysis.units import UnitRangeError from tests.analysis.test_persistent_msd import prepared from tests.results.test_analysis_wire import project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact # One physical link, capacity4, cost3, q1. These are requested probes from the # declared search controls, not a recording of production output. @@ -307,47 +303,3 @@ def scaling(demands, scale, q): @pytest.mark.parametrize("case", CASES) def test_msd_full_fields_follow_declared_search(case, monkeypatch): oracle(case, monkeypatch) - - -@pytest.mark.parametrize("case", CASES) -def test_msd_projection_field_plants_are_caught(case, monkeypatch): - original = exporting._msd - observed = [] - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - observed.append(deepcopy(value)) - return value - - # Registered built-ins keep their explicit function, so patch the registry - # entry as well as testing the helper itself. - result_type = next( - cls for cls, encoder in exporting._EXPORTERS.items() if encoder is original - ) - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, result_type, capture) - oracle(case, monkeypatch) - (baseline,) = observed - paths = tuple(field_paths(baseline)) - caught = equivalent = 0 - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - - def planted(*args, path=path, mode=mode, **kwargs): - value = original(*args, **kwargs) - change(value, path, mode) - return value - - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, result_type, planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle(case, monkeypatch) - caught += 1 - oracle(case, monkeypatch) - assert caught + equivalent == 3 * len(paths) - print( - f"MSD FIELD PLANTS: {case[2]}/{case[3]}; {len(paths)} paths; {caught} caught; {equivalent} equivalent null->null" - ) diff --git a/tests/results/test_native_field_plants.py b/tests/results/test_native_field_plants.py index 6d84f2c..14f4f6e 100644 --- a/tests/results/test_native_field_plants.py +++ b/tests/results/test_native_field_plants.py @@ -1,81 +1,7 @@ -"""Native projection fields are checked by the independent finite-flow witness.""" - -from copy import deepcopy - -import jsonschema -import pytest - -import ngraph.export as exporting -from tests.results.test_analysis_wire import ( - test_capacity_native_wire_all_amounts_arrays_cuts_and_requested_curve as capacity_oracle, -) -from tests.results.test_report_field_plants import change, field_paths +"""Read a nested value by its sequence of field names and indices.""" def at(value, path): for key in path: value = value[key] return value - - -def test_native_projection_helper_field_plants_are_all_caught(monkeypatch): - original = exporting._native - observed = [] - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - observed.append(deepcopy(value)) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_native", capture) - capacity_oracle() - (baseline,) = observed - paths = tuple(field_paths(baseline)) - caught, equivalent = 0, 0 - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - # The uncapped result intentionally emits these nulls; the - # finite-offer, plan and default-output tests distinguish their - # other applicability states. Null->null is an equivalent plant. - equivalent += 1 - continue - - def planted(*args, path=path, mode=mode, **kwargs): - value = original(*args, **kwargs) - change(value, path, mode) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_native", planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - capacity_oracle() - caught += 1 - capacity_oracle() - assert caught + equivalent == 3 * len(paths) - assert equivalent == 6 - print( - f"NATIVE FIELD PLANTS: {len(paths)} field paths; {caught} caught; {equivalent} equivalent null->null with applicability controls" - ) - - -def test_wrong_carried_distribution_with_same_total_is_caught(monkeypatch): - original = exporting._native - - def planted(*args, **kwargs): - value = original(*args, **kwargs) - flows = value["edge_flows"] - positive = [row for row in flows if row["units"] > 0] - assert len(positive) == 3 - before = sum(row["units"] for row in flows) - positive[0]["units"] -= 1 - positive[1]["units"] += 1 - assert sum(row["units"] for row in flows) == before - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_native", planted) - with pytest.raises(AssertionError): - capacity_oracle() - capacity_oracle() diff --git a/tests/results/test_path_field_plants.py b/tests/results/test_path_field_plants.py index bc6a4d8..9f1927c 100644 --- a/tests/results/test_path_field_plants.py +++ b/tests/results/test_path_field_plants.py @@ -1,16 +1,11 @@ """Named path output has exact ordered hops, costs and omission rules.""" -from copy import deepcopy - -import jsonschema import netgraph_core as ngc import pytest -import ngraph.export as exporting from ngraph import PathQuery, Selector, paths from tests.results.test_analysis_wire import prepared, project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact def oracle(variant): @@ -76,45 +71,3 @@ def oracle(variant): @pytest.mark.parametrize("variant", ["spf", "ksp", "costs", "self", "unreachable"]) def test_named_path_fields_are_exact(variant): oracle(variant) - - -@pytest.mark.parametrize("variant", ["spf", "ksp", "costs", "self", "unreachable"]) -def test_path_exporter_field_plants_are_caught(monkeypatch, variant): - original = exporting._paths - cls = next( - cls for cls, encoder in exporting._EXPORTERS.items() if encoder is original - ) - observed = [] - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - observed.append(deepcopy(value)) - return value - - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, cls, capture) - oracle(variant) - (baseline,) = observed - caught = equivalent = 0 - paths = tuple(field_paths(baseline)) - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - - def planted(*args, path=path, mode=mode, **kwargs): - value = original(*args, **kwargs) - change(value, path, mode) - return value - - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, cls, planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle(variant) - caught += 1 - oracle(variant) - assert caught + equivalent == len(paths) * 3 - print( - f"PATH FIELD PLANTS: {variant}; {len(paths)} paths; {caught} caught; {equivalent} equivalent null->null" - ) diff --git a/tests/results/test_question_field_plants.py b/tests/results/test_question_field_plants.py index 6b1e1cf..cc8c301 100644 --- a/tests/results/test_question_field_plants.py +++ b/tests/results/test_question_field_plants.py @@ -1,12 +1,9 @@ """Independent question declarations distinguish every recorded control.""" -from copy import deepcopy from fractions import Fraction import netgraph_core as ngc -import pytest -import ngraph.export as exporting from ngraph import ( CapacityQuery, DemandSet, @@ -32,7 +29,7 @@ prepare, ) from tests.question import asked -from tests.results.test_report_field_plants import change, exact, field_paths, project +from tests.results.test_report_field_plants import exact, project SOURCE = {"path": "^s$", "group_by": None, "match": None, "active_only": False} TARGET = {"path": "^t$", "group_by": "name", "match": None, "active_only": True} @@ -401,40 +398,6 @@ def test_every_recorded_question_field_has_hand_expectation(): exact(project(value)["analysis"]["query"], expected) -def test_question_projection_field_plants_have_success_and_applicability_controls( - monkeypatch, -): - original = exporting.question - caught = equivalent = count = 0 - for value, expected in samples(): - exact(project(value)["analysis"]["query"], expected) - for path in field_paths(expected): - for mode in ("missing", "null", "wrong"): - current = expected - for key in path: - current = current[key] - if mode == "null" and current is None: - equivalent += 1 - continue - - def planted(question, topology, path=path, mode=mode): - result = deepcopy(original(question, topology)) - change(result, path, mode) - return result - - with monkeypatch.context() as patch: - patch.setattr(exporting, "question", planted) - with pytest.raises(AssertionError): - exact(project(value)["analysis"]["query"], expected) - caught += 1 - count += 1 - exact(project(value)["analysis"]["query"], expected) - assert caught + equivalent == count * 3 - print( - f"QUESTION PLANTS: {count} fields; {caught} caught; {equivalent} equivalent null->null; model and SPF/KSP applicability controls pass" - ) - - def test_sensitivity_question_records_the_excluded_nodes_and_edges(): network = Network(nodes={name: Node(name) for name in ("s", "m", "t")}) network.add_link(Link("s", "t", 4, 3)) diff --git a/tests/results/test_report_field_plants.py b/tests/results/test_report_field_plants.py index fe961f5..6659390 100644 --- a/tests/results/test_report_field_plants.py +++ b/tests/results/test_report_field_plants.py @@ -1,9 +1,8 @@ -"""Hand-derived report/domain values reject every emitted field near miss.""" +"""Hand-derived report and domain values, with shared field-checking helpers.""" import json import math import sys -from copy import deepcopy from fractions import Fraction from hashlib import sha256 from importlib.metadata import version @@ -11,9 +10,7 @@ from pathlib import Path import _netgraph_core -import pytest -import ngraph.export as exporting from ngraph import ( DemandSet, DemandSpec, @@ -27,7 +24,7 @@ run, write_json, ) -from ngraph.results import PreparationScope, RunResult +from ngraph.results import PreparationScope from ngraph.workflow.build_graph import BuildGraph @@ -208,48 +205,7 @@ def near(value): return 0 -def change(value, path, mode): - parent = value - for name in path[:-1]: - parent = parent[name] - name = path[-1] - if mode == "missing": - del parent[name] - elif mode == "null": - parent[name] = None - else: - parent[name] = near(parent[name]) - - def test_units_python_and_wire_domain_metadata_have_complete_hand_expectations(): value, expected, expected_python = fixture() exact(vars(value.units_report), expected_python) exact(project(value), expected) - - -def test_report_projection_helper_field_plants_are_all_caught(monkeypatch): - value, expected, _ = fixture() - original = exporting._export_registered - exact(project(value), expected) - observed = tuple(field_paths(expected)) - caught = 0 - for path in observed: - for mode in ("missing", "null", "wrong"): - - def planted(item, context, path=path, mode=mode): - projected = original(item, context) - if type(item) is RunResult: - projected = deepcopy(projected) - change(projected, path, mode) - return projected - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_export_registered", planted) - with pytest.raises(AssertionError): - exact(project(value), expected) - caught += 1 - exact(project(value), expected) - assert caught == 3 * len(observed) - print( - f"FIELD PLANTS: {len(observed)} emitted field paths; {caught} missing/null/near-value helper plants caught" - ) diff --git a/tests/results/test_sensitivity_field_plants.py b/tests/results/test_sensitivity_field_plants.py index 6603365..7fbf112 100644 --- a/tests/results/test_sensitivity_field_plants.py +++ b/tests/results/test_sensitivity_field_plants.py @@ -1,12 +1,7 @@ -"""A signed improvement survives the exact sensitivity projection and field plants.""" +"""A signed improvement survives the exact sensitivity projection.""" -from copy import deepcopy - -import jsonschema import netgraph_core as ngc -import pytest -import ngraph.export as exporting from ngraph import ( Link, Network, @@ -19,8 +14,7 @@ prepare, ) from tests.results.test_analysis_wire import project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact def oracle(): @@ -119,44 +113,3 @@ def oracle(): def test_signed_sensitivity_fields_are_exact(): oracle() - - -def test_sensitivity_exporter_field_plants_are_caught(monkeypatch): - original = exporting._sensitivity - cls = next( - cls for cls, encoder in exporting._EXPORTERS.items() if encoder is original - ) - observed = [] - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - observed.append(deepcopy(value)) - return value - - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, cls, capture) - oracle() - (baseline,) = observed - caught = equivalent = 0 - paths = tuple(field_paths(baseline)) - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - - def planted(*args, path=path, mode=mode, **kwargs): - value = original(*args, **kwargs) - change(value, path, mode) - return value - - with monkeypatch.context() as patch: - patch.setitem(exporting._EXPORTERS, cls, planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle() - caught += 1 - oracle() - assert caught + equivalent == len(paths) * 3 - print( - f"SENSITIVITY FIELD PLANTS: {len(paths)} paths; {caught} caught; {equivalent} equivalent null->null" - ) diff --git a/tests/results/test_tunnel_field_plants.py b/tests/results/test_tunnel_field_plants.py index f7b463f..d519d2c 100644 --- a/tests/results/test_tunnel_field_plants.py +++ b/tests/results/test_tunnel_field_plants.py @@ -1,12 +1,8 @@ -"""Hand-derived physical T1 and pooled T2 result projections and helper plants.""" +"""Hand-derived physical T1 and pooled T2 result projections.""" -from copy import deepcopy - -import jsonschema import netgraph_core as ngc import pytest -import ngraph.export as exporting from ngraph import ( CapacityQuery, DemandSet, @@ -24,8 +20,6 @@ prepare, ) from tests.results.test_analysis_wire import project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, field_paths def edge(link, direction="forward"): @@ -219,43 +213,3 @@ def oracle(kind): @pytest.mark.parametrize("kind", ["physical-t1", "pooled-t2"]) def test_tunnel_fields_follow_hand_derivation(kind): oracle(kind) - - -@pytest.mark.parametrize("kind", ["physical-t1", "pooled-t2"]) -@pytest.mark.timeout(120) -def test_tunnel_projection_field_plants_are_caught(monkeypatch, kind): - original = exporting._native - observed = [] - - def capture(*args, **kwargs): - value = original(*args, **kwargs) - observed.append(deepcopy(value)) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_native", capture) - oracle(kind) - (baseline,) = observed - paths = tuple(field_paths(baseline)) - caught, equivalent = 0, 0 - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - - def planted(*args, path=path, mode=mode, **kwargs): - value = original(*args, **kwargs) - change(value, path, mode) - return value - - with monkeypatch.context() as patch: - patch.setattr(exporting, "_native", planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle(kind) - caught += 1 - oracle(kind) - assert caught + equivalent == 3 * len(paths) - print( - f"TUNNEL FIELD PLANTS: {kind}; {len(paths)} paths; {caught} caught; {equivalent} equivalent null->null" - ) diff --git a/tests/results/test_units_miss_field_plants.py b/tests/results/test_units_miss_field_plants.py index 7200c86..2134c43 100644 --- a/tests/results/test_units_miss_field_plants.py +++ b/tests/results/test_units_miss_field_plants.py @@ -1,13 +1,10 @@ """Nonempty units diagnostics retain exact errors, constraints and probe locations.""" -from copy import deepcopy from fractions import Fraction -import jsonschema import netgraph_core as ngc import pytest -import ngraph.export as exporting from ngraph import ( CapacityQuery, DemandSet, @@ -24,10 +21,8 @@ place, prepare, ) -from ngraph.results import UnitsReport from tests.results.test_analysis_wire import project -from tests.results.test_native_field_plants import at -from tests.results.test_report_field_plants import change, exact, field_paths +from tests.results.test_report_field_plants import exact def fraction(value): @@ -270,46 +265,3 @@ def opaque_oracle(): ) def test_nonempty_units_report_is_exact(variant): oracle(variant) - - -@pytest.mark.parametrize( - "variant", ["rounding", "split", "weighted", "range", "opaque"] -) -def test_units_miss_helper_field_plants_are_caught(monkeypatch, variant): - original = exporting.project - observed = [] - - def capture(value): - projected = original(value) - if type(value) is UnitsReport: - observed.append(deepcopy(projected)) - return projected - - with monkeypatch.context() as patch: - patch.setattr(exporting, "project", capture) - oracle(variant) - (baseline,) = observed - caught = equivalent = 0 - paths = tuple(field_paths(baseline)) - for path in paths: - for mode in ("missing", "null", "wrong"): - if mode == "null" and at(baseline, path) is None: - equivalent += 1 - continue - - def planted(value, path=path, mode=mode): - projected = original(value) - if type(value) is UnitsReport: - change(projected, path, mode) - return projected - - with monkeypatch.context() as patch: - patch.setattr(exporting, "project", planted) - with pytest.raises((AssertionError, jsonschema.ValidationError)): - oracle(variant) - caught += 1 - oracle(variant) - assert caught + equivalent == len(paths) * 3 - print( - f"UNITS MISS FIELD PLANTS: {variant}; {len(paths)} paths; {caught} caught; {equivalent} equivalent null->null" - ) From beeefc9387e6f2a0ef0abb3b5a1853643249313c Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Thu, 29 Oct 2026 12:47:13 +0000 Subject: [PATCH 2/3] Choose q once from a static bound; delete per-case precision checks Preparation chooses the unit q once, as the largest power of two that meets the input bound and the split-count bound, from counts it already knows; there is no candidate loop and no exact-fraction propagation per MSD probe or failure case, and the misses of later questions are gone. Preparation keeps one no-traffic validation of each capacity and count question. The units report records which bound set q; docs/concepts.md states what is promised. On NetLab's DC-BB scenarios preparation and run are 15 to 30 times faster. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + dev/explicit_results_schema.json | 4 +- dev/generate_results_schema.py | 3 +- docs/concepts.md | 77 +- docs/reference/api-full.md | 135 +- docs/reference/api.md | 4 +- docs/reference/dsl.md | 2 +- docs/reference/results.md | 100 +- ngraph/__init__.py | 30 +- ngraph/analysis/capacity.py | 28 +- ngraph/analysis/controller.py | 88 +- ngraph/analysis/counts.py | 59 +- ngraph/analysis/demands.py | 42 +- ngraph/analysis/msd.py | 19 +- ngraph/analysis/paths.py | 1 - ngraph/analysis/placement.py | 80 +- ngraph/analysis/precision.py | 949 ------------- ngraph/analysis/preparation.py | 444 +++--- ngraph/analysis/sensitivity.py | 29 - ngraph/analysis/units.py | 322 ++--- ngraph/analysis/validation.py | 6 +- ngraph/export.py | 14 +- ngraph/results.py | 226 +-- ngraph/scenario.py | 8 +- ngraph/schemas/results.json | 448 ++---- ngraph/schemas/scenario.json | 1 + ngraph/workflow/max_flow_step.py | 2 +- ngraph/workflow/tunnel_count_step.py | 2 +- tests/analysis/test_capacity_pools.py | 4 - tests/analysis/test_msd_search.py | 4 +- tests/analysis/test_msd_threshold.py | 14 +- .../test_observed_model_boundaries.py | 510 ------- tests/analysis/test_persistent_msd.py | 8 +- tests/analysis/test_preparation_contracts.py | 316 +++++ tests/analysis/test_preparation_units.py | 89 +- tests/analysis/test_preparation_work.py | 185 --- tests/analysis/test_publication_faults.py | 3 - tests/analysis/test_q_recipe_rules.py | 327 ----- .../analysis/test_query_publication_faults.py | 6 +- tests/analysis/test_record_selection.py | 15 +- tests/analysis/test_selection_invariants.py | 14 +- tests/analysis/test_simultaneous.py | 48 +- tests/analysis/test_source_boundaries.py | 370 ----- tests/analysis/test_split_rules.py | 285 ---- tests/analysis/test_tunnel_counts.py | 28 - tests/analysis/test_tunnel_resolution.py | 812 ----------- tests/analysis/test_units.py | 176 +-- tests/analysis/test_units_rule.py | 1210 +++++++++++++++++ tests/controller/helpers.py | 13 +- tests/controller/test_backbone_matrix.py | 15 +- tests/controller/test_failure_tables.py | 25 - tests/controller/test_filling.py | 19 +- .../controller/test_owner_figures_final_q.py | 65 +- tests/controller/test_reports.py | 4 +- tests/controller/test_tables.py | 71 - tests/dev/test_api_docs_generator.py | 8 +- tests/dev/test_results_schema.py | 16 +- tests/docs/test_narrated_answers.py | 53 +- tests/dsl/test_input_parity.py | 4 +- tests/integration/test_shipped_scenarios.py | 25 +- tests/results/test_analysis_wire.py | 4 +- tests/results/test_record_methods.py | 2 +- tests/results/test_report_field_plants.py | 62 +- tests/results/test_units_miss_field_plants.py | 267 ---- tests/results/test_wire_key_order.py | 20 +- tests/scenario/test_atomic_inputs.py | 43 +- tests/scenario/test_prepared_answers.py | 108 +- tests/scenario/test_publication_faults.py | 81 +- tests/test_startup_imports.py | 2 +- tests/typing/valid_client.py | 59 +- tests/workflow/test_msd_resolution.py | 12 +- .../test_name_limits_and_owned_fields.py | 16 +- tests/workflow/test_tunnel_count_step.py | 225 +-- 73 files changed, 2627 insertions(+), 6145 deletions(-) delete mode 100644 ngraph/analysis/precision.py create mode 100644 tests/analysis/test_preparation_contracts.py delete mode 100644 tests/analysis/test_preparation_work.py delete mode 100644 tests/analysis/test_q_recipe_rules.py delete mode 100644 tests/analysis/test_source_boundaries.py delete mode 100644 tests/analysis/test_split_rules.py delete mode 100644 tests/analysis/test_tunnel_resolution.py create mode 100644 tests/analysis/test_units_rule.py delete mode 100644 tests/results/test_units_miss_field_plants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8369c61..0f61287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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] + +### Changed + +- **BREAKING**: preparation chooses `q` once from a split count (nodes, links, equal-split demands placed together, tunnels) and the smallest amounts, without re-preparing candidates; analyses no longer check splits or report precision misses. `units_report` replaces `binding_constraints`, `nominal_split_count`, `nominal_splits`, `misses` and the input-rounding samples and maxima with `limited_by`, `achieved_precision`, `input_bound`, `split_bound` and `range_limit`. `q` is finer on equal-split workloads and can change on others; the documented precision promise is in Precision and the choice of q. `units.precision` must now be at most 0.5. + ## [0.25.0] - 2026-10-27 ### Added diff --git a/dev/explicit_results_schema.json b/dev/explicit_results_schema.json index f7a9ea2..06f4627 100644 --- a/dev/explicit_results_schema.json +++ b/dev/explicit_results_schema.json @@ -3099,7 +3099,7 @@ } }, "units_report": { - "description": "The units report of the preparation: q, its reasons, rounding and misses.", + "description": "The units report of the preparation: q, its input/count bounds and range sums.", "$ref": "#/$defs/UnitsReportJSON" } }, @@ -3131,7 +3131,7 @@ ] }, "units_report": { - "description": "The units report of the preparation: q, its reasons, rounding and misses.", + "description": "The units report of the preparation: q, its input/count bounds and range sums.", "$ref": "#/$defs/UnitsReportJSON" } }, diff --git a/dev/generate_results_schema.py b/dev/generate_results_schema.py index 99979d4..b853d60 100644 --- a/dev/generate_results_schema.py +++ b/dev/generate_results_schema.py @@ -44,7 +44,6 @@ PAGE = ROOT / "docs/reference/results.md" EXPLICIT = ROOT / "dev/explicit_results_schema.json" ALIASES = ( - "UnitConstraint", "CostReport", "CostUnavailableReason", "MSDStatus", @@ -554,7 +553,7 @@ def page(document: dict) -> str: " units of the run's resolution `q` (the units report holds `q`); units × `q` is the", " physical amount.", "- **Physical amounts:** inputs, question offers and designs, `NetworkStats`", - " capacities and the units report's input samples, as declared.", + " capacities and `split_bound.capacity` in the units report, as declared.", "- **Ratios:** delivery ratios, alpha, shares and caps, with no unit.", "- **Routing costs:** path costs, tunnel costs and their sums, which never take `q`.", " `total_cost` is cost × bandwidth units, so `total_cost` × `q` is cost × physical", diff --git a/docs/concepts.md b/docs/concepts.md index 21029f5..ab505e1 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -227,44 +227,43 @@ In the reference: [Traffic Modes](reference/dsl.md#traffic-modes-mode) and [Grou ## Precision and the choice of q -A smaller `q` gives finer bandwidth units but makes converted inputs and capacity sums larger. The precision target `p` (0.001 by default) bounds local rounding and splitting, not the error in a final answer. Automatic preparation chooses the coarsest range-valid `q` that meets the input bounds and every split bound preparation checks (those of the declared demands at alpha 1 without failures, and those observed in workflow capacity and tunnel-count questions at that `q`) and at which the tunnel plans of the declared demands and workflow capacity questions give every live head a whole unit per tunnel. If none does, it chooses the finest range-valid `q` and reports a miss. Later queries, scaled placements and failure cases keep `q` and can report further misses. +NetGraph accounts for bandwidth in whole units of `q`. Conversion of a nonnegative finite binary64 amount `v` uses the nearest whole unit, half up: `floor(v/q + 1/2)`. Its physical error is at most `q/2`. For positive inputs collected at preparation, relative conversion error is at most the target `p` only when the units report's `input_bound.met` is true. A scaled volume is first multiplied in binary64: `q/2` bounds conversion of that product, not its difference from the exact mathematical product. -**The rule.** +**How q is chosen.** Preparation chooses one `q` for every placement, failure case, MSD probe and later question. The automatic choice is the largest power of two from `2^-32` through `2^32` satisfying `q ≤ 2p·v_min` and `q·Γ ≤ p·c_min`. Here `v_min` is the smallest positive prepared bandwidth input (capacity, declared volume, workflow offer, design demand or minimum unreserved bandwidth), `c_min` is the smallest positive link capacity, and `0 < p ≤ 0.5` (`0.001` by default). A bound with no positive amount is omitted. -- **The input bound.** Each positive finite input `v` (a capacity, a volume, an offer, a design, or the minimum free bandwidth a traffic-engineering model asks for, [Tunnels and TE ingress pools](#tunnels-and-te-ingress-pools)) needs `q ≤ 2pv`: half-up rounding errs by at most `q/2`. At `q` = 1 and the default `p`, every input must be at least 500. -- **The split bound.** Each split of a positive amount `v` over `m ≥ 2` members needs `q ≤ pv/(m − 1)`: expansion and source splits ([Demands: how traffic enters](#demands-how-traffic-enters)), router splits ([Routing models and admission](#routing-models-and-admission)) and the splits of [Tunnels and TE ingress pools](#tunnels-and-te-ingress-pools). -- **Tunnel resolution.** A native tunnel plan with live heads but no whole unit per tunnel misses the target: automatic preparation checks declared demands and workflow capacity questions at each candidate `q`, while tunnel-count analyses, later questions, failure cases and searches check their plans when they run. -- **Range.** Each finite native bandwidth input, in units, and the graph's directed capacity sum, in units, must stay below 2^62, reverse edges and TE ingress pool attachments included; so must the directed cost sum, whatever `q`. Exact result totals can exceed 64 bits. -- **The recipe.** Preparation tries the powers of two from 2^32 down to 2^-32, skipping those above the smallest input and expansion bound (these do not depend on `q`). It skips a `q` that is out of range; otherwise it prepares at that `q`, measures the splits that depend on it, observes its tunnel plans and stops at the first `q` that meets every bound with no tunnel resolution miss. When none does, it keeps the finest in-range `q` it tried; when no `q` under the bounds is in range, the finest in-range `q` above them. When no `q` is in range at all, preparation refuses. -- **Measured and declared splits.** A capacity question's splits are measured on the native delivery at each candidate `q`, for an uncapped lossless question or a first-hop experiment (every source sends at once the free capacity of each of its first links, forwarded lossy): `basis: observed`, with the exact physical incoming amount. Finite declared offers and designs use `basis: declared`. Input rounding is checked on its own. -- **Misses.** The target is missed for one of four reasons: a manual `q` too coarse, or a prepared `q` too coarse for a later query, scaled placement or failure case; a finer `q` out of range (one amount, or a sum); the finest legal `q`, 2^-32, still too coarse; or a supplied program the analysis cannot check (a native `Pinned` program given to a query), reported as unverified. A finer legal `q` can help a coarse-resolution miss; it cannot remove a range or resolution-floor limit or verify an opaque supplied program. The bounds are checked, not the rounding that happened: an input that `q` represents exactly still misses when `q > 2pv`, so `target_met` can be false with `inexact_inputs` and `zeroed_inputs` both 0. The units report records the constraints that set `q` (`binding_constraints`), rounding counts and examples, and each miss with its location. -- **Later questions.** Queries keep the prepared `q` and report their own misses; so do failure cases ([Failures: patterns, cases and states](#failures-patterns-cases-and-states)) and the searches of [Maximum supported demand](#maximum-supported-demand). A run's units report is the preparation's with those misses added. +Let `L` be the number of network nodes plus twice the number of links, including disabled ones. For each prepared group of demands placed together, `k` counts lossless shortest-path leaves with equal router and source splits; `t` counts the heads times tunnels per head of its tunnel leaves. Each bound demand set is a group, each workflow capacity pair is a separate group, and each tunnel-count row is a group. A pool is one head; presets use 16 or 256 tunnels per head, an explicit count uses that count, an `up_to` uses its limit, and a count step uses its largest count or limit. The count is `Γ = max(L/4 + 2, max_groups(max(k, 1)·(L/4 + 2) + t))`. Other leaves add nothing. Opaque custom workflow callbacks add only the floor; their unknown tunnels are uncounted, like later questions. Pool heads and attachment edges are excluded from `L`. -**Example.** At `p` = 0.01, a volume of 10 needs `q ≤ 0.2` (so 2^-3 would do), but A splits it over two links, which needs `q ≤ 0.1`: +A demand set used only by simultaneous placement steps adds nothing to the count. A set named by any other workflow step (an ordered placement, MSD or tunnel-count step) or requested in `prepare(..., demand_sets=...)` counts its leaves by their declared models. -```python -prepared = prepare(Scenario.from_yaml(""" -units: {resolution: auto, precision: 0.01} -network: - nodes: {A: {}, B: {}} - links: - - {source: A, target: B, capacity: 100} - - {source: A, target: B, capacity: 100} -demands: - pair: [{source: ^A$, target: ^B$, volume: 10}] -workflow: - - {type: TrafficMatrixPlacement, name: small, demand_set: pair, alpha: 0.05} -""")) -report = prepared.units_report -[binding] = report.binding_constraints -assert prepared.topology.q == 2**-4 and report.target_met -assert (binding.kind, binding.members) == ("router", 2) -result = run(prepared) -[miss] = result.units_report.misses -assert miss.location.step == "small" and not result.units_report.target_met -``` +If either bound needs a unit below `2^-32`, preparation starts at `2^-32` and reports the unmet bound. If an input or augmented capacity sum reaches `2^62` units, it takes the finest coarser unit that fits; if none fits, it refuses. `units: {resolution: }` declares `q` instead and is checked for range. The report gives `limited_by`, `input_bound.met`, `split_bound.met` and the scalar `achieved_precision = max(q/(2·v_min), q·Γ/c_min)` over present terms. `target_met` means that scalar is absent or at most `p`; it does not certify every analysis. + +**Why this count is used.** It gives a delivery bound under these conditions, which must all hold for the comparison: + +- There is one demand, placed alone on original capacities or on links no earlier demand has used. +- Its finite physical request `R` is converted directly to units, without apportioning a rounded parent or treating a binary64 scaled product as the exact mathematical product. +- Router and source allocations are equal, admission is lossless, and routing is one fixed shared acyclic hop-by-hop program, such as `SHORTEST_PATHS_ECMP`. Traffic arriving from different sources merges before each router splits it once. The source roster is nonempty and every source share reaches a destination. The proportional comparison uses the same roster, destinations, routes and member order. +- Every edge carrying a positive proportional share has positive original capacity, and the report's `split_bound.met` is true. + +Let `X` be this demand's delivery with exact equal proportional splits on the original physical capacities. Then whole-unit physical delivery `A` satisfies `|A − X| ≤ p·X + q/2`. If also `q ≤ 2p·R`, the stronger bound is `|A − X| ≤ p·X`; a directly converted positive prepared request meets this condition when `input_bound.met` is true. For this same demand, each link flow differs by at most `2p` times that link's physical capacity plus `q/2`. A changed failure-case DAG can use the theorem only if all these conditions still hold, including positive carrying capacities and every source share reaching a destination. + +For example, six nodes and seven links in the routes of [Routing models and admission](#routing-models-and-admission) give `L = 20`, one lossless equal-split demand and `Γ = 7`. With smallest capacity 2 and `p = 0.001`, the split bound is `0.002/7` and the input bound `0.004`, so `q = 2^-12`. The lossless demand delivers 32769 units, or 8.000244140625, against proportional delivery 8. C-X holds 8192 units; round robin sends C 16384 of 32769 units and X 8192 of those. One more unit would send X 8193. At declared `q = 1`, delivery is 9 and the split bound is unmet. + +**Outside this domain.** No bound against proportional splitting is promised for these uses; the following runs show the distinctions. + +- Several demands in order: on capacity 10, 1000 earlier requests of `1051/131072` each round from 525.5 to 526 units at `q = 2^-16`. The last demand gets 129360 units against proportional 129860. Both residuals exceed an unrelated smallest capacity of 1; a large residual alone does not restore the theorem. +- Expanded pair leaves: a parent request of 1 over six independent pairs, each with capacity 1000, at `p = 0.1` gives `q = 1/8`. The eight parent units split as `(2, 2, 1, 1, 1, 1)`. The first pair delivers `1/4` against proportional `1/6`, outside the directly converted request's `q/2` allowance. +- Scaled placements and MSD answers: with volume and capacity `2^28 + 1`, `p = 2^-57`, `q = 2^-31` and alpha `1 − 2^-28 + 2^-53`, binary64 multiplication gives delivery `2^28`. The exact product exceeds it by `234881025/2^53`, more than `p·X + q/2`. Conversion itself is exact in this run. MSD additionally depends on its search bracket: on capacity 10 and volume 1, `alpha_max = 1` stops at 1 though the proportional threshold is 10. +- Later questions and tunnel counts: preparation of capacity 100 alone chooses `q = 1/32`; a later finite offer `1/128` converts to zero. A capacity-1 model prepared with one tunnel at `p = 0.5`, `q = 1/8`, permits eight units per tunnel; a later request for 16 tunnels has capacity-limited size zero. Neither the new offer nor the larger count was covered by preparation's inputs and counts. +- Supplied programs: 100 repeated entries of one DAG over four nodes retain separate splits and deliver `253/128` against proportional 2 at `q = 2^-14`. +- Weighted routing: 20 parallel members with weights `(1000, 1, …, 1)`, offer 24458019, first capacity twice that offer and other capacities 24000.5 deliver the full offer with lossy admission at `q = 1`; the proportional answer is smaller by `57361/2038`. The equal-split theorem does not apply. +- Lossy service, including simultaneous sharing: three offers `(5, 1, 1)` on capacity 3 at `q = 1` deliver `(3, 0, 0)`, against proportional `(15/7, 3/7, 3/7)`. Lossy presets and stale forwarding have no certificate from this theorem. +- Tunnels: two tunnels set up by their head on parallel capacities 6 and 4 for volume `8 + 2^-12` deliver 16385 units at automatic `q = 2^-11`, or 8.00048828125, while exact setup gives 6. Setup is a discrete decision before forwarding. +- The WCMP controller: exits of 30.0007 and 10 produce weights `(3, 1)` at `q = 1` and `(30721, 10240)` at `q = 2^-10`. The routes and weights are not fixed across these units. +- Zero carrying capacity: equal splitting over parallel capacities 5 and 0 delivers one unit at `q = 2^-10`, against proportional zero. + +Ratios of deliveries, optimized route choices, flow vectors, `total_cost`, and several optimized demands placed in order have no precision promise here. -The router split binds, so `q` is 2^-4 and the target is met. The step places the demand at alpha 0.05, a volume of 0.5, which needs `q ≤ 0.01`; it keeps the prepared `q` and reports the miss under its name. +**Optimized maximum flow.** Separately, one uncapped maximum-flow question with optimized source allocation, fixed allowed support and original capacities returns the exact maximum flow on the rounded capacities. If `q ≤ 2p·c_min`, its throughput is within relative `p` of the physical optimum. If that maximum is `F_C` whole units, a finite offer `V` instead returns `min(F_C, floor(V/q + 1/2))` whole units; relative `p` additionally requires `q ≤ 2p·V`. On capacity 100 with declared offer 1, `q = 2^-9` returns 512 units (1); the uncapped maximum is 51200 units (100). A later offer needs its own input condition. In the reference: `units` in [Scenario YAML](reference/dsl.md#units-and-input-validation); `UnitsReportJSON` in [Results](reference/results.md#unitsreportjson). @@ -446,12 +445,12 @@ A failure policy says which nodes and links can fail together. NetGraph draws fr - **Draws and cases.** A failure rule matches among all declared nodes, links or risk groups, disabled ones included, taken in sorted identifier order. A draw picks one mode by weight and applies its rules in declaration order; seeds follow [Seeds](reference/dsl.md#seeds). A draw's pattern is every node and link it fails, the members of failed risk groups included. Equal patterns are one case, computed once: `occurrences` counts the draws that gave it, and with `failure_trace` its trace is the first of them. The baseline, the case without failures, comes first and is not an extra draw: draws that fail nothing count in its `occurrences`, which can be 0, so the cases' `occurrences` sum to `iterations`. The other cases follow in the order of their first draw. A step without a failure policy makes its `iterations` draws with nothing failed; a library query without a `FailureRequest` has the baseline alone, with 0 occurrences. Every draw is made before any case is computed, so the cases are the same for any number of workers. - **Coverage.** Draws are random and independent, so they need not produce every pattern a policy allows. A policy that fails one of `n` links (one `choice` rule of count 1 without `weight_by`) picks each link with equal chance in each draw, and `n` draws usually miss some. Each pattern drawn is one case, so every link was drawn exactly when the step has `n` failure cases besides the baseline. `n` × ln(`n`/ε) draws, rounded up, miss some link with chance at most ε: 32 draws for 5 links at ε = 1 %. -- **What a failure changes.** A failure removes the failed nodes and links, both directions of each failed link, from what the case can use, so when the demands are computed again (the converged state below), a source that failed or lost every route is out of the source roster. It never changes a declared volume, not even one whose endpoint failed. Each case keeps the prepared `q` and reports its own precision misses. +- **What a failure changes.** A failure removes the failed nodes and links, both directions of each failed link, from what the case can use, so when the demands are computed again (the converged state below), a source that failed or lost every route is out of the source roster. It never changes a declared volume, not even one whose endpoint failed. Each case keeps the prepared `q`. - **The states.** Each case reports its states through a common states mapping: capacity its converged state, placement its requested failure states (`[converged]` by default; `stale` and `repair` only when listed), each computed separately, from a network that carries no traffic yet. Stale forwarding replays a demand's program unchanged while failed elements swallow what reaches them; repair drops the failed routes and re-splits over the survivors, with no new search. No state is guaranteed to deliver at least as much as another: for any two of them, some cases deliver more in one and some in the other, and `converged`, computed again, can deliver less than `repair`. The engine's exact rule: [Forwarding programs](https://github.com/networmix/NetGraph-Core/blob/v0.12.0/docs/semantics.md#forwarding-programs). - `converged`: the demands computed again on the failed network. Controller tables are rebuilt for the case; declared forwarding-group shares stay fixed caps. A root group with no live member keeps its fixed cap and is listed in the state's `dead_caps`. Lossless admission stops before the first unit assigned to a dead member; lossy forwarding (for example a simultaneous step, [Ordered and simultaneous placement](#ordered-and-simultaneous-placement)) drops the units assigned to that group at its router. A case whose program would forward that share differently is refused with the group, case and state. - `stale`: each demand's program from the case without failures, forwarded unchanged and lossy at that baseline's admitted total; traffic that reaches a failed node or link is lost there. - `repair`: the same programs with failed routes removed and survivors re-split (no new route search), at the declared request and admission; group shares stay caps. -- `reference_request_units` stays the scaled request in every state; the native `requested` and `offered` describe each call. Every case and state of a step therefore has the same request, and NetGraph does not rank them: the worst case of a state is the one that delivers least, with the largest shortfall and the lowest `delivery_ratio`. A state's shortfall is its unadmitted and dropped units; in `stale` it also counts those the baseline did not admit. Cost reports and precision misses come from converged rows only. For an explicit-route demand alone, converged and repair forward the same program. +- `reference_request_units` stays the scaled request in every state; the native `requested` and `offered` describe each call. Every case and state of a step therefore has the same request, and NetGraph does not rank them: the worst case of a state is the one that delivers least, with the largest shortfall and the lowest `delivery_ratio`. A state's shortfall is its unadmitted and dropped units; in `stale` it also counts those the baseline did not admit. Cost reports come from converged rows only. For an explicit-route demand alone, converged and repair forward the same program. - **Forwarding failure.** The bounded forwarding calculation can cycle or reach its round limit. That case and state then reports a forwarding failure, with no rows; this does not prove that no stable answer exists. In the baseline it stops the step when stale or repair needs its programs. - **Groups inside the domain.** Group scope refuses converged and repair under a failure policy when it programs a router inside the IGP domain, where other demands can transit the router after a failure and a dead group's dropped share would cut them too; `stale` works there. @@ -541,7 +540,7 @@ By default a placement places its demands one after another, by priority: each d - `interaction: ordered` (the default) places demands in priority order, then declaration and canonical expansion order. A row's residual is the capacity left after that row. - `interaction: simultaneous` forwards every demand at once in one native list, sharing losses on common links, as a lossy question on fixed programs: each model that can be lossy forwards a lossy copy of itself (tunnel models are set up first and keep their holds), and a live controller (`TE_MAXFLOW_UNLIM`, `SHORTEST_PATHS_MAXFLOW`) is computed, then its program forwarded lossy. Its lossy copy is created after the other participants, in every state, so it loses one-unit ties to them. Every row reports the one final residual. Rows are not comparable with the default `ordered` rows. The engine's exact rule: [Several demands at once](https://github.com/networmix/NetGraph-Core/blob/v0.12.0/docs/semantics.md#several-demands-at-once). -- A TE ingress pool in a simultaneous step gets a lossy offer budget, its largest known request, reported as the row's `pool.offer_limit_units`. Budgets are prepared once for the scenario: every placement step on that preparation reports them, and they count in the choice of `q`. +- A TE ingress pool in a simultaneous step gets a lossy offer budget, its largest known request, reported as the row's `pool.offer_limit_units`. Budgets are prepared once for the scenario: every placement step on that preparation reports them, and they count in the range check of `q`. **Example.** Two demands on one link of 10, first of 10 each, then of 3 and 5: @@ -585,7 +584,7 @@ Maximum supported demand (MSD) asks how far the whole demand set can grow: the l **The rule.** -- **Probes.** A probe places the set at one alpha and checks exact native-unit feasibility: every demand delivers its whole request. Its outcome is `feasible`, `infeasible`, `below_resolution` (a resolved leaf of a positive declaration requests zero units after scaling and expansion, so the probe proves nothing; it is never feasible or a certificate endpoint, and search moves up from it) or `range` (the scaled volumes leave the native range; not an infeasible congestion sample). Probes keep the prepared `q` and report their own misses. MSD takes no failure input. +- **Probes.** A probe places the set at one alpha and checks exact native-unit feasibility: every demand delivers its whole request. Its outcome is `feasible`, `infeasible`, `below_resolution` (a resolved leaf of a positive declaration requests zero units after scaling and expansion, so the probe proves nothing; it is never feasible or a certificate endpoint, and search moves up from it) or `range` (the scaled volumes leave the native range; not an infeasible congestion sample). Probes keep the prepared `q`. MSD takes no failure input. - **The answer.** `alpha_feasible` is the largest tested feasible alpha, null if no probe was feasible; the best rows are its rows. The result does not claim an untested global maximum. - **Statuses** (`search_status`, with `stop_reason`): @@ -634,7 +633,7 @@ A result holds numbers of different kinds, and the field names say which. Bandwi **The rule.** -- **Five kinds of numbers.** Bandwidth units (every `*_units` field and the native amounts; units × `q` is physical), physical amounts (inputs, offers and designs, `NetworkStats` capacities and the input samples, as declared), ratios (no unit), routing costs (never multiplied by `q`; `total_cost` is cost × bandwidth units, so `total_cost` × `q` is cost × physical bandwidth) and hardware money and power. Counts are plain integers. Native integer-valued configuration fields already contain units; YAML bandwidth minima are physical and are converted at preparation. +- **Five kinds of numbers.** Bandwidth units (every `*_units` field and the native amounts; units × `q` is physical), physical amounts (inputs, offers and designs, `NetworkStats` capacities and `split_bound.capacity`, as declared), ratios (no unit), routing costs (never multiplied by `q`; `total_cost` is cost × bandwidth units, so `total_cost` × `q` is cost × physical bandwidth) and hardware money and power. Counts are plain integers. Native integer-valued configuration fields already contain units; YAML bandwidth minima are physical and are converted at preparation. - Each row's native result says what this call did (`call_` fields) and the demand's state after it (totals), and the two never mix. The engine's exact rule: [The result](https://github.com/networmix/NetGraph-Core/blob/v0.12.0/docs/semantics.md#the-result). - **Export.** `write_json` writes each record's declared fields, and the results schema is generated from the same declarations. Native integer quantities stay integer, optional outputs stay absent when unrequested, and unlimited and null values remain distinct. JSON rows default to compact exact summaries, and each optional output has its own selector; Python results always hold the detail fields, while the other outputs exist only when requested ([Output selection](reference/workflow.md#output-selection)). - **Cost reports** are computed only when `cost_distribution` is requested and only for `converged` rows. Unrestricted optimized traffic engineering (`TE_MAXFLOW_UNLIM`) reports units at each marginal cost; shortest-path routing, tunnels and named routes report units at each path cost when attribution is available. Otherwise a converged row gives `controller_table`, `mixed_cost_dag` or `unsupported_model` under `unavailable_outputs`. Stale and repair rows carry neither a report nor an unavailable reason. A report's `cost_domain` is `analysis_graph` when costs include a TE ingress pool's attachment, and `physical_network` otherwise. @@ -688,7 +687,7 @@ The documentation uses one term for each idea. The terms the sections define, ea - physical link, direction, enabled: [Links, directions and what is enabled](#links-directions-and-what-is-enabled) - next hop, ECMP, admission, program: [Routing models and admission](#routing-models-and-admission) - alpha (the demand multiplier), source roster, canonical order: [Demands: how traffic enters](#demands-how-traffic-enters) -- precision target, miss: [Precision and the choice of q](#precision-and-the-choice-of-q) +- precision target, split count, achieved precision: [Precision and the choice of q](#precision-and-the-choice-of-q) - tunnel, head, hold (a reservation), carried traffic, TE ingress pool, offer budget: [Tunnels and TE ingress pools](#tunnels-and-te-ingress-pools) - routing domain, table: [Routing domains and the WCMP controller](#routing-domains-and-the-wcmp-controller) - cap, dead cap: [Forwarding groups and their caps](#forwarding-groups-and-their-caps), [Failures: patterns, cases and states](#failures-patterns-cases-and-states) diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index d604e26..fbeec05 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -49,7 +49,6 @@ The answer of ``capacity``: the question asked and one result per failure case. - `kind` (ClassVar[Literal['capacity']]) = capacity - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `outputs` (OutputSelection) - `question` (Literal['lossless_bound', 'fixed_offer_delivery', 'first_hop_experiment']) @@ -584,20 +583,23 @@ whether q meets it. - `q_upper_bound` (Fraction) - `met` (bool) -### ngraph.InputQuantization +### ngraph.SplitCountConstraint -One physical input converted to units: its binary64 value, its units and the -rounding error. +The prepared split-count bound, not a certificate for arbitrary answers. **Attributes:** -- `label` (str) -- `kind` (Literal['capacity', 'demand', 'offer', 'design', 'min_unreserved']) -- `binary64_value` (float) -- `binary64_hex` (str) -- `units` (int) -- `error` (Fraction) -- `relative_error` (Fraction | None): None when the input is zero +- `kind` (ClassVar[Literal['split_count']]) = split_count +- `capacity_label` (str) +- `capacity` (float) +- `nodes` (int) +- `directed_links` (int) +- `group` (str | None): None when the floor sets the count +- `demands` (int) +- `tunnels` (int) +- `count` (Fraction) +- `q_upper_bound` (Fraction) +- `met` (bool) ### ngraph.JSONValue @@ -656,7 +658,6 @@ under ``MSDAnalysisJSON`` in the results reference. - `kind` (ClassVar[Literal['msd']]) = msd - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `outputs` (OutputSelection) - `alpha_feasible` (ExactAlpha | None) @@ -895,7 +896,6 @@ The answer of ``paths``: one row per endpoint pair. - `kind` (ClassVar[Literal['paths']]) = paths - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `algorithm` (Literal['spf', 'ksp']) - `mode` (Literal['combine', 'pairwise']) @@ -970,7 +970,6 @@ accounting. - `kind` (ClassVar[Literal['placement']]) = placement - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `outputs` (OutputSelection) - `alpha` (ExactAlpha) @@ -1047,19 +1046,6 @@ A TE ingress pool; ``offer_limit_units`` is its prepared lossy offer budget. - `offer_limit_units` (int) = 0 - `synthetic_ingress_cost` (ClassVar[Literal[1]]) = 1 -### ngraph.PrecisionMiss - -One place an analysis missed the precision target: its location, the constraints -it missed and its input rounding. - -**Attributes:** - -- `location` (ReportLocation) -- `constraints` (tuple[InputConstraint | SplitConstraint | TunnelResolutionConstraint | UnverifiedConstraint, ...]) -- `inexact_inputs` (int) -- `zeroed_inputs` (int) -- `examples` (tuple[InputQuantization, ...]) - ### ngraph.PreparationScope What a preparation covered: its workflow steps, and the demand sets bound for the @@ -1075,8 +1061,7 @@ workflow and for library callers. One graph, unit and demand binding over a read-only view of a scenario. -``units_report`` is the preparation's report; each analysis returns its -own precision misses beside it. +``units_report`` records the fixed input/count bounds for every analysis. **Attributes:** @@ -1128,17 +1113,6 @@ Why an MSD probe was out of range. - `message` (str) - `binary64_hex` (str | None): None when the refused amount is not a binary64 value -### ngraph.ReportLocation - -Where a precision miss happened: the step, the failure case and the MSD probe, -where they apply. - -**Attributes:** - -- `step` (str | None): None when the report is outside a workflow -- `case_id` (str | None): None when the report is outside failure cases -- `probe_index` (int | None): None when the report is outside MSD - ### ngraph.ResolvedDemand One demand bound to the prepared graph: its identity, endpoints, priority, units @@ -1229,7 +1203,7 @@ version and the seed. ### ngraph.RunResult -One run; ``units_report`` adds every step's precision misses, by step. +One run with the preparation's units report. **Attributes:** @@ -1329,7 +1303,6 @@ directed edge's removal causes. - `kind` (ClassVar[Literal['sensitivity']]) = sensitivity - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `outputs` (OutputSelection) - `endpoints` (Endpoints) @@ -1355,21 +1328,6 @@ delivery. - `outputs` (ngc.OutputOptions) - `export_details` (bool) = False -### ngraph.SplitConstraint - -The split bound of a positive amount v over m members at precision p (q at most -pv/(m - 1)), and whether q meets it. - -**Attributes:** - -- `kind` (Literal['expansion', 'source', 'router', 'tunnel']) -- `label` (str) -- `incoming_physical` (Fraction) -- `basis` (Literal['declared', 'observed']) -- `members` (int) -- `q_upper_bound` (Fraction) -- `met` (bool) - ### ngraph.StepContext What a custom step's ``run`` receives: the prepared scenario, earlier steps' @@ -1454,7 +1412,6 @@ The answer of ``tunnel_counts``: one row per demand and count. - `kind` (ClassVar[Literal['tunnel_counts']]) = tunnel_counts - `prepared` (PreparedScenario) -- `misses` (tuple[PrecisionMiss, ...]) - `metadata` (AnalysisMetadata) - `outputs` (OutputSelection) - `rows` (tuple[TunnelCountRow, ...]) @@ -1500,34 +1457,9 @@ offer). - `lossless_at_design_units` (int) - `physical_tunnel_cost` (int) -### ngraph.TunnelResolutionConstraint - -A native tunnel plan whose live heads cannot receive a whole unit per tunnel. - -**Attributes:** - -- `kind` (ClassVar[Literal['tunnel_resolution']]) = tunnel_resolution -- `label` (str) -- `q` (float) -- `heads` (tuple[str | PoolHead, ...]) -- `met` (ClassVar[Literal[False]]) = False - -### ngraph.UnitConstraint - -```python -UnitConstraint = (InputConstraint - | SplitConstraint - | TunnelResolutionConstraint - | RangeConstraint - | UnverifiedConstraint) -``` - -One precision or range constraint of a units report. - ### ngraph.UnitsReport -How preparation chose q: the mode and target, the bounds that set q, input -rounding, the range sums and every precision miss. +The prepared unit, its input/count bounds and augmented graph range sums. **Attributes:** @@ -1539,25 +1471,18 @@ rounding, the range sums and every precision miss. - `input_convention` (ClassVar[Literal['promoted_binary64_half_up']]) = promoted_binary64_half_up - `display_convention` (ClassVar[Literal['units_authoritative_binary64_display']]) = units_authoritative_binary64_display - `scope` (PreparationScope) -- `binding_constraints` (tuple[UnitConstraint, ...]) -- `input_count` (int) -- `inexact_inputs` (int) -- `zeroed_inputs` (int) -- `inexact_examples` (tuple[InputQuantization, ...]) -- `zeroed_examples` (tuple[InputQuantization, ...]) -- `max_absolute_input_error` (Fraction) -- `max_relative_input_error` (Fraction) +- `limited_by` (Literal['input', 'split_count', 'range', 'resolution_floor', 'declared', 'unbounded']) +- `achieved_precision` (Fraction | None): None when there is no positive input or capacity +- `input_bound` (InputConstraint | None): None when there is no positive prepared bandwidth input +- `split_bound` (SplitCountConstraint | None): None when there is no positive link capacity +- `range_limit` (RangeConstraint | None): None when limited_by is not range - `augmented_capacity_sum_units` (int) - `augmented_cost_sum` (int) -- `nominal_split_count` (int) -- `nominal_splits` (tuple[SplitConstraint, ...]) -- `misses` (tuple[PrecisionMiss, ...]) - `weight_approximations` (tuple[WeightApproximation, ...]) = () **Methods:** - `with_approximations(self, entries: 'tuple[WeightApproximation, ...]') -> 'UnitsReport'` - This report with the controller's weight approximations, each once. -- `with_misses(self, misses: 'tuple[PrecisionMiss, ...]') -> 'UnitsReport'` - This report with further precision misses appended. ### ngraph.UnitsSettings @@ -1569,16 +1494,6 @@ relative precision target. - `resolution` (float | Literal['auto']) = auto - `precision` (float) = 0.001 -### ngraph.UnverifiedConstraint - -A supplied program whose splits cannot be checked against the precision target. - -**Attributes:** - -- `kind` (ClassVar[Literal['unverified']]) = unverified -- `label` (str) -- `reason` (ClassVar[Literal['opaque_program']]) = opaque_program - ### ngraph.UpperCertificate A proved bracket around the largest feasible alpha: a feasible probe and the @@ -1747,13 +1662,13 @@ What preparation must know of one workflow step's question. ``numerical`` steps are evaluated at the prepared resolution. A step may name a demand set to bind, a failure policy, and an earlier step whose feasible alpha it uses (one that ``provides_alpha``). ``capacity`` builds -the step's capacity question, which preparation evaluates once per -candidate resolution; ``inputs`` are that question's physical amounts, by +the step's capacity question, which preparation validates once after +final compilation; ``inputs`` are that question's physical amounts, by field name. ``pool_endpoints`` are the source and target of a question whose combined sources are a pool: preparation gives them a private head. ``offer_budget`` is a fixed lossy offer every pool leaf of ``demand_set`` -receives; ``counts`` builds a tunnel-count question, whose split targets -preparation observes once per candidate resolution. ``nodes`` and ``links`` +receives; ``counts`` builds a tunnel-count question, which preparation +validates once after final binding. ``nodes`` and ``links`` are names the step reads that must exist in the network. **Attributes:** diff --git a/docs/reference/api.md b/docs/reference/api.md index b8c8a7c..1c0fccf 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -24,7 +24,7 @@ assert prepared.topology.q == 1 [`Network`](api-full.md#ngraphnetwork) keeps editable dicts of immutable records. [`prepare`](api-full.md#ngraphprepare) resolves the selected demand sets, builds one native graph and returns [`PreparedScenario`](api-full.md#ngraphpreparedscenario); it does not change its input. Library callers name needed demand sets with `prepare(scenario, demand_sets=("traffic",))`; workflow-referenced sets are included automatically. -`PreparedScenario` holds the read-only scenario view, its `topology`, the bound `demand_sets` and the preparation's `units_report`. [`PreparedTopology`](api-full.md#ngraphpreparedtopology) holds the graph and its labels: `graph`, physical node names, read-only node-name and physical-link mappings, `edge_refs` (each engine edge's directed link or pool attachment, in edge order), `disabled` (the engine's eligibility mask of what the scenario disables), `q` (the bandwidth unit; [Units and the resolution q](../concepts.md#units-and-the-resolution-q)) and `pool_heads` (each finite TE ingress pool's synthetic head node; [Tunnels and TE ingress pools](../concepts.md#tunnels-and-te-ingress-pools)). Every analysis takes the prepared scenario, holds it as `prepared` and returns its own precision `misses`; its `units_report` is the preparation's report with those misses appended. A physical link has two native directions; [`DirectedLink(link, direction)`](api-full.md#ngraphdirectedlink) identifies one. Returned results stay readable after the scenario and the prepared scenario are deleted. The prepared view is read-only, and later edits need a new preparation ([Scenario, preparation and run](../concepts.md#scenario-preparation-and-run)). The examples on this page fix `q` to 1, so that units equal physical bandwidth; for amounts this small that misses the default precision target, and their units reports say so ([Precision and the choice of q](../concepts.md#precision-and-the-choice-of-q)). +`PreparedScenario` holds the read-only scenario view, its `topology`, the bound `demand_sets` and the preparation's `units_report`. [`PreparedTopology`](api-full.md#ngraphpreparedtopology) holds the graph and its labels: `graph`, physical node names, read-only node-name and physical-link mappings, `edge_refs` (each engine edge's directed link or pool attachment, in edge order), `disabled` (the engine's eligibility mask of what the scenario disables), `q` (the bandwidth unit; [Units and the resolution q](../concepts.md#units-and-the-resolution-q)) and `pool_heads` (each finite TE ingress pool's synthetic head node; [Tunnels and TE ingress pools](../concepts.md#tunnels-and-te-ingress-pools)). Every analysis takes the prepared scenario, holds it as `prepared` and uses the preparation's `units_report`, with controller weight approximations added for a later-bound demand set. A physical link has two native directions; [`DirectedLink(link, direction)`](api-full.md#ngraphdirectedlink) identifies one. Returned results stay readable after the scenario and the prepared scenario are deleted. The prepared view is read-only, and later edits need a new preparation ([Scenario, preparation and run](../concepts.md#scenario-preparation-and-run)). The examples on this page fix `q` to 1, so that units equal physical bandwidth; for amounts this small that misses the default precision target, and their units reports say so ([Precision and the choice of q](../concepts.md#precision-and-the-choice-of-q)). ## Native models and capacity @@ -130,7 +130,7 @@ For K shortest paths, pass `options=ngc.KspOptions(k, cost_ceiling=...)`. In `co ## Results and export -[`run(prepare(scenario))`](api-full.md#ngraphrun) returns [`RunResult`](api-full.md#ngraphrunresult) with an ordered tuple of steps, one units report (the preparation's report with each step's misses, labelled by step) and producer metadata. Every analysis records its own typed query ([`CapacityQuery`](api-full.md#ngraphcapacityquery), [`PlacementQuery`](api-full.md#ngraphplacementquery), [`SensitivityQuery`](api-full.md#ngraphsensitivityquery), [`PathQuery`](api-full.md#ngraphpathquery), [`MSDQuery`](api-full.md#ngraphmsdquery)) as `query`, and a step records it as its `question`; a built-in domain step records itself. The JSON export writes each question in normalized form (selectors, the effective native model, failures, alpha and its source). A capacity case has `states={"converged": ...}`; a placement case has one entry per requested failure state (`PlacementQuery.states`, default `("converged",)`, `"stale"` and `"repair"` on request), and `PlacementQuery.interaction` selects `"ordered"` or `"simultaneous"` forwarding (workflow reference: TrafficMatrixPlacement). Each row holds the engine's result, with its endpoints, its reference request and any requested reports. +[`run(prepare(scenario))`](api-full.md#ngraphrun) returns [`RunResult`](api-full.md#ngraphrunresult) with an ordered tuple of steps, the preparation's units report and producer metadata. Every analysis records its own typed query ([`CapacityQuery`](api-full.md#ngraphcapacityquery), [`PlacementQuery`](api-full.md#ngraphplacementquery), [`SensitivityQuery`](api-full.md#ngraphsensitivityquery), [`PathQuery`](api-full.md#ngraphpathquery), [`MSDQuery`](api-full.md#ngraphmsdquery)) as `query`, and a step records it as its `question`; a built-in domain step records itself. The JSON export writes each question in normalized form (selectors, the effective native model, failures, alpha and its source). A capacity case has `states={"converged": ...}`; a placement case has one entry per requested failure state (`PlacementQuery.states`, default `("converged",)`, `"stale"` and `"repair"` on request), and `PlacementQuery.interaction` selects `"ordered"` or `"simultaneous"` forwarding (workflow reference: TrafficMatrixPlacement). Each row holds the engine's result, with its endpoints, its reference request and any requested reports. [`write_json(result, file)`](api-full.md#ngraphwrite_json) is the export path. It writes each plain record as its declared constants and fields, in declaration order; `ngraph/schemas/results.json` is generated from the same declarations. The file's format is in [Output Format](cli.md#output-format), and what its numbers mean in [Results and their units](../concepts.md#results-and-their-units). diff --git a/docs/reference/dsl.md b/docs/reference/dsl.md index b663f06..db3a46e 100644 --- a/docs/reference/dsl.md +++ b/docs/reference/dsl.md @@ -13,7 +13,7 @@ units: | Key | Default | Meaning | |-----|---------|---------| | `resolution` | `auto` | The bandwidth unit `q`: `auto`, or one power of two from 2^-32 through 2^32 | -| `precision` | `0.001` | The relative precision target; the units report states whether it is met | +| `precision` | `0.001` | The relative precision target, greater than 0 and at most 0.5, from which preparation chooses `q`; the units report states the input/count budget and whether its bounds hold | Capacities, demand volumes, finite offers and designs and minimum unreserved bandwidth are physical amounts, converted to whole units of `q` at preparation ([Units and the resolution q](../concepts.md#units-and-the-resolution-q)); [Precision and the choice of q](../concepts.md#precision-and-the-choice-of-q) says how `q` is chosen and what the units report records. Costs are positive integers ([link keys](#direct-node-and-link-definitions)); counts and weights are integers. diff --git a/docs/reference/results.md b/docs/reference/results.md index ee859d3..51e92bf 100644 --- a/docs/reference/results.md +++ b/docs/reference/results.md @@ -9,7 +9,7 @@ in five kinds: units of the run's resolution `q` (the units report holds `q`); units × `q` is the physical amount. - **Physical amounts:** inputs, question offers and designs, `NetworkStats` - capacities and the units report's input samples, as declared. + capacities and `split_bound.capacity` in the units report, as declared. - **Ratios:** delivery ratios, alpha, shares and caps, with no unit. - **Routing costs:** path costs, tunnel costs and their sums, which never take `q`. `total_cost` is cost × bandwidth units, so `total_cost` × `q` is cost × physical @@ -381,18 +381,6 @@ One of: [BuildGraphResultJSON](#buildgraphresultjson) or [NetworkStatsResultJSON | `q_upper_bound` | [FractionJSON](#fractionjson) | The largest q at which rounding this input is guaranteed within the precision: 2 times the precision times the input (rounding moves it half a unit at most). | | `met` | boolean | Whether the chosen q meets it. | -## InputQuantizationJSON - -| Field | Type | Description | -| --- | --- | --- | -| `label` | string | The input or constraint label: where the value is declared. | -| `kind` | `"capacity"` \| `"demand"` \| `"offer"` \| `"design"` \| `"min_unreserved"` | What the input is: a capacity, demand, offer, design or minimum unreserved bandwidth. | -| `binary64_value` | number | The physical input as given. | -| `binary64_hex` | string | The physical input in hexadecimal. | -| `units` | integer | Its value in units (integer units of q). | -| `error` | [FractionJSON](#fractionjson) | The signed rounding error, units times q minus the input (physical, a fraction): positive when rounded up. | -| `relative_error` | [FractionJSON](#fractionjson) or null | The signed relative error, the error divided by the input. Null when the input is zero. | - ## JSONValue One of: null or boolean or integer or number or string or list of [JSONValue](#jsonvalue) or object. @@ -745,16 +733,6 @@ One of: `"tolerance"` \| `"alpha_bound"` \| `"bracket_limit"` \| `"bisect_limit" | --- | --- | --- | | `pool_id` | string | The pool's identifier (as PoolHeadJSON names it). | -## PrecisionMissJSON - -| Field | Type | Description | -| --- | --- | --- | -| `location` | [ReportLocationJSON](#reportlocationjson) | Where the miss was observed: step, case and probe. | -| `constraints` | list of [InputConstraintJSON](#inputconstraintjson) or [SplitConstraintJSON](#splitconstraintjson) or [TunnelResolutionConstraintJSON](#tunnelresolutionconstraintjson) or [UnverifiedConstraintJSON](#unverifiedconstraintjson) | The constraints the chosen q does not meet there. | -| `inexact_inputs` | integer | Inputs not exact at q there. | -| `zeroed_inputs` | integer | Positive inputs that round to zero units there. | -| `examples` | list of [InputQuantizationJSON](#inputquantizationjson) | Example inputs that miss. | - ## PreparationScopeJSON | Field | Type | Description | @@ -797,21 +775,13 @@ One of: `"tolerance"` \| `"alpha_bound"` \| `"bracket_limit"` \| `"bisect_limit" | `message` | string | The refusal text. | | `binary64_hex` | string or null | The refused binary64 value in hexadecimal. Null when the refused amount is not a binary64 value. | -## ReportLocationJSON - -| Field | Type | Description | -| --- | --- | --- | -| `step` | string or null | The step. Null when the report is outside a workflow. | -| `case_id` | string or null | The case. Null when the report is outside failure cases. | -| `probe_index` | integer or null | The MSD probe. Null when the report is outside MSD. | - ## RunJSON | Field | Type | Description | | --- | --- | --- | | `metadata` | [RunMetadataJSON](#runmetadatajson) | The run's metadata: versions and the seed. | | `steps` | list of [StepResultJSON](#stepresultjson) | The workflow steps in declaration order. | -| `units_report` | [UnitsReportJSON](#unitsreportjson) | The units report of the preparation: q, its reasons, rounding and misses. | +| `units_report` | [UnitsReportJSON](#unitsreportjson) | The units report of the preparation: q, its input/count bounds and range sums. | ## RunMetadataJSON @@ -913,17 +883,21 @@ One of: string or [PoolHeadJSON](#poolheadjson). | `nominal_physical` | [FractionJSON](#fractionjson) | Its share of declared physical volumes toward the destination (a fraction, before alpha). | | `weight` | integer | Its integer source weight. | -## SplitConstraintJSON +## SplitCountConstraintJSON | Field | Type | Description | | --- | --- | --- | -| `kind` | `"expansion"` \| `"source"` \| `"router"` \| `"tunnel"` | Where the split is: expansion, sources, a router or tunnels. | -| `label` | string | The input or constraint label: where the value is declared. | -| `incoming_physical` | [FractionJSON](#fractionjson) | The physical amount split (a fraction). | -| `basis` | `"declared"` \| `"observed"` | declared (from the demands) or observed (from an analysis). | -| `members` | integer | The members the amount is split over. | -| `q_upper_bound` | [FractionJSON](#fractionjson) | The q bound this split records: the precision times the incoming amount divided by (members - 1). It bounds q, not the split's own error. | -| `met` | boolean | Whether the chosen q meets it. | +| `kind` | `"split_count"` | The record kind, a fixed tag. | +| `capacity_label` | string | The first smallest positive link capacity in link insertion order. | +| `capacity` | number | The smallest positive physical link capacity, including disabled links. | +| `nodes` | integer | The physical network's node count, including disabled nodes. | +| `directed_links` | integer | Twice the physical link count, including disabled links and excluding pool attachments. | +| `group` | string or null | The demand set or workflow. that sets the count; the floor wins ties. Null when the floor sets the count. | +| `demands` | integer | Lossless shared equal-split leaves in the winning group, or zero for the floor. | +| `tunnels` | integer | Heads times declared tunnels per head in the winning group, or zero for the floor. | +| `count` | [FractionJSON](#fractionjson) | The maximum of the floor L/4 + 2 and max(k, 1) times that floor plus t over prepared groups. | +| `q_upper_bound` | [FractionJSON](#fractionjson) | The split-count upper bound on q: precision times the smallest positive capacity divided by the count. | +| `met` | boolean | Whether the prepared q meets this bound; the delivery theorem has additional premises. | ## StandaloneAnalysisJSON @@ -931,7 +905,7 @@ One of: string or [PoolHeadJSON](#poolheadjson). | --- | --- | --- | | `metadata` | [RunMetadataJSON](#runmetadatajson) | The export's metadata: versions. | | `analysis` | [AnalysisJSON](#analysisjson) (with `query`) | The exported analysis. | -| `units_report` | [UnitsReportJSON](#unitsreportjson) | The units report of the preparation: q, its reasons, rounding and misses. | +| `units_report` | [UnitsReportJSON](#unitsreportjson) | The units report of the preparation: q, its input/count bounds and range sums. | ## StepQuestionJSON @@ -1060,61 +1034,33 @@ One of: [CapacityQueryJSON](#capacityqueryjson) or [PlacementQueryJSON](#placeme | `headroom_penalty` | integer or null | T2 with prefer_headroom: the headroom penalty of the plan. Null when the plan is T1's or prefer_headroom is off. | | `max_lossless_offer_units` | integer or null | T2: the program's lossless maximum offer F. Null when the plan is T1's. | -## TunnelResolutionConstraintJSON - -| Field | Type | Description | -| --- | --- | --- | -| `kind` | `"tunnel_resolution"` | The record kind, a fixed tag. | -| `label` | string | The demand or question whose native plan was observed. | -| `q` | number | The bandwidth unit at which the plan was observed. | -| `heads` | list of string or [PoolHeadJSON](#poolheadjson) | Every live head, in native node order: a physical node name or a structured TE ingress pool head. | -| `met` | `false` | Always false: the observed plan cannot give its live heads whole-unit tunnels. | - ## UnavailableOutputsJSON | Field | Type | Description | | --- | --- | --- | | `cost_distribution` | [CostUnavailableReasonJSON](#costunavailablereasonjson) | Why the cost report is not derivable for this row. Absent when it is derivable or was not requested. | -## UnitConstraintJSON - -One of: [InputConstraintJSON](#inputconstraintjson) or [SplitConstraintJSON](#splitconstraintjson) or [TunnelResolutionConstraintJSON](#tunnelresolutionconstraintjson) or [RangeConstraintJSON](#rangeconstraintjson) or [UnverifiedConstraintJSON](#unverifiedconstraintjson). - ## UnitsReportJSON | Field | Type | Description | | --- | --- | --- | | `q` | number | The bandwidth unit: one unit is q in physical terms. | | `exponent` | integer | q as a power of two: q = 2^exponent. | -| `mode` | `"auto"` \| `"manual"` | auto (chosen by the recipe) or manual (declared). | -| `precision` | number | The relative precision target. | -| `target_met` | boolean | Whether every input and split meets its precision bound at q and every native tunnel plan observed at q has sufficient resolution for its live heads. Bounds are checked, not the rounding: false is possible with no input rounded. | +| `mode` | `"auto"` \| `"manual"` | auto (chosen from static bounds) or manual (declared). | +| `precision` | number | The target input/count budget, greater than zero and at most 0.5. | +| `target_met` | boolean | Whether both present input/count bounds hold at q; this does not certify arbitrary analysis answers. | | `input_convention` | `"promoted_binary64_half_up"` | Physical inputs round half-up from their binary64 value. | | `display_convention` | `"units_authoritative_binary64_display"` | Units are authoritative; physical values are binary64 displays. | | `scope` | [PreparationScopeJSON](#preparationscopejson) | What the preparation covered. | -| `binding_constraints` | list of [UnitConstraintJSON](#unitconstraintjson) | The constraints that limited q, including observed tunnel plans with insufficient resolution for their live heads: at q, or at 2q when automatic preparation rejected that coarser candidate and meets the target at q. Each tunnel record's q identifies its observation. | -| `input_count` | integer | Physical inputs converted to units. | -| `inexact_inputs` | integer | Inputs not exact at q. | -| `zeroed_inputs` | integer | Positive inputs that round to zero units. | -| `inexact_examples` | list of [InputQuantizationJSON](#inputquantizationjson) | Example inexact inputs. | -| `zeroed_examples` | list of [InputQuantizationJSON](#inputquantizationjson) | Example zeroed inputs. | -| `max_absolute_input_error` | [FractionJSON](#fractionjson) | The largest absolute input error (physical, a fraction). | -| `max_relative_input_error` | [FractionJSON](#fractionjson) | The largest absolute relative input error (a fraction). | +| `limited_by` | `"input"` \| `"split_count"` \| `"range"` \| `"resolution_floor"` \| `"declared"` \| `"unbounded"` | Which bound selected q; input wins an equal input/split bound. | +| `achieved_precision` | [FractionJSON](#fractionjson) or null | The maximum of q/(2*v_min) and q*count/c_min over present denominators; an input/count budget, not a general answer guarantee. Null when there is no positive input or capacity. | +| `input_bound` | [InputConstraintJSON](#inputconstraintjson) or null | The smallest positive input's bound, with ties in preparation insertion order. Null when there is no positive prepared bandwidth input. | +| `split_bound` | [SplitCountConstraintJSON](#splitcountconstraintjson) or null | The prepared split-count bound, subject to the documented delivery theorem's premises. Null when there is no positive link capacity. | +| `range_limit` | [RangeConstraintJSON](#rangeconstraintjson) or null | The range failure at the next finer q when range forced a coarser unit. Null when limited_by is not range. | | `augmented_capacity_sum_units` | integer | The graph's capacity sum with pool attachments (integer units of q). | | `augmented_cost_sum` | integer | The graph's cost sum with pool attachments. | -| `nominal_split_count` | integer | Splits of the demands at alpha 1 without failures. | -| `nominal_splits` | list of [SplitConstraintJSON](#splitconstraintjson) | Those splits with their bounds on q. | -| `misses` | list of [PrecisionMissJSON](#precisionmissjson) | Precision misses observed in analyses. | | `weight_approximations` | list of [WeightApproximationJSON](#weightapproximationjson) | Controller weights apportioned because they reached 2^62. | -## UnverifiedConstraintJSON - -| Field | Type | Description | -| --- | --- | --- | -| `kind` | `"unverified"` | The record kind, a fixed tag. | -| `label` | string | The input or constraint label: where the value is declared. | -| `reason` | `"opaque_program"` | The split is inside a supplied native program NetGraph cannot inspect. | - ## UpperCertificateJSON | Field | Type | Description | diff --git a/ngraph/__init__.py b/ngraph/__init__.py index ee6f541..0834cd5 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -71,7 +71,6 @@ FailureTrace, HardwareRow, InputConstraint, - InputQuantization, JSONValue, MarginalCostBin, MarginalCurve, @@ -90,11 +89,9 @@ PlacementTotals, PoolHead, PoolMetadata, - PrecisionMiss, PreparationScope, RangeConstraint, RangeRefusal, - ReportLocation, ResolvedDemand, ResolvedDemandSet, RunMetadata, @@ -102,7 +99,7 @@ SamplingMetadata, SelfPair, SensitivityAnalysis, - SplitConstraint, + SplitCountConstraint, StepContext, StepOutput, StepQuestion, @@ -110,10 +107,7 @@ SyntheticIngress, TunnelCountAnalysis, TunnelCountRow, - TunnelResolutionConstraint, - UnitConstraint, UnitsReport, - UnverifiedConstraint, UpperCertificate, ) from ngraph.scenario import PreparedScenario, Scenario, prepare, run @@ -149,19 +143,19 @@ "DomainResult Endpoints ExactAlpha ExpansionAccounting " "ExpansionLeaf ExpansionParent ExpansionSplit ExportContext FailureCase " "FailurePattern FailureSelection FailureTrace " - "HardwareRow InputConstraint InputQuantization JSONValue " + "HardwareRow InputConstraint SplitCountConstraint JSONValue " "MarginalCostBin MarginalCurve MSDAnalysis MSDProbe MSDProof " "MSDStatus MSDStopReason " "NetworkStatsResult OutputSelection PathAnalysis " "PathCostBin PathRow PlacementAnalysis " - "PlacementRow PlacementTotals PoolHead PoolMetadata PrecisionMiss " - "PreparationScope RangeConstraint RangeRefusal ReportLocation " + "PlacementRow PlacementTotals PoolHead PoolMetadata " + "PreparationScope RangeConstraint RangeRefusal " "ResolvedDemand ResolvedDemandSet RunMetadata RunResult " "SamplingMetadata SelfPair SensitivityAnalysis " - "SplitConstraint StepContext StepOutput StepQuestion " - "StepResult SyntheticIngress TunnelCountAnalysis TunnelCountRow TunnelResolutionConstraint " - "UnitConstraint UnitsReport " - "UnverifiedConstraint UpperCertificate" + "StepContext StepOutput StepQuestion " + "StepResult SyntheticIngress TunnelCountAnalysis TunnelCountRow " + "UnitsReport " + "UpperCertificate" ), "ngraph.scenario": "PreparedScenario Scenario prepare run", "ngraph.workflow.base": "WorkflowStep register_workflow_step", @@ -234,7 +228,7 @@ def __dir__(): "GroupChild", "HardwareRow", "InputConstraint", - "InputQuantization", + "SplitCountConstraint", "JSONValue", "Link", "MSDAnalysis", @@ -263,13 +257,11 @@ def __dir__(): "PlacementTotals", "PoolHead", "PoolMetadata", - "PrecisionMiss", "PreparationScope", "PreparedScenario", "PreparedTopology", "RangeConstraint", "RangeRefusal", - "ReportLocation", "ResolvedDemand", "ResolvedDemandSet", "RiskGroup", @@ -283,7 +275,6 @@ def __dir__(): "SelfPair", "SensitivityAnalysis", "SensitivityQuery", - "SplitConstraint", "StepContext", "StepOutput", "StepQuestion", @@ -293,11 +284,8 @@ def __dir__(): "TunnelCountAnalysis", "TunnelCountQuery", "TunnelCountRow", - "TunnelResolutionConstraint", - "UnitConstraint", "UnitsReport", "UnitsSettings", - "UnverifiedConstraint", "UpperCertificate", "WCMPController", "WeightSpec", diff --git a/ngraph/analysis/capacity.py b/ngraph/analysis/capacity.py index f95e75d..c13288e 100644 --- a/ngraph/analysis/capacity.py +++ b/ngraph/analysis/capacity.py @@ -18,7 +18,6 @@ schedule_cases, ) from ngraph.analysis.metadata import analysis_metadata -from ngraph.analysis.precision import query_has_splits, query_inputs, query_miss from ngraph.analysis.units import quantize from ngraph.analysis.validation import validate_model from ngraph.model.selectors import Selector @@ -30,7 +29,6 @@ OutputSelection, PoolHead, PoolMetadata, - ReportLocation, SelfPair, SyntheticIngress, ) @@ -244,9 +242,7 @@ def capacity(prepared: PreparedScenario, query: CapacityQuery) -> CapacityAnalys endpoints, jobs, native_pairs, pools = question_pairs(prepared, query, "capacity") workers = resolve_workers(query.workers) cases = failure_cases(prepared, query.failures) - completed, misses = [], [] - check_splits = query_has_splits(query) - inputs = query_inputs(prepared, query, "capacity") + completed = [] for case in cases: options = evaluation_options( topology, @@ -302,31 +298,9 @@ def capacity(prepared: PreparedScenario, query: CapacityQuery) -> CapacityAnalys case, MappingProxyType({"converged": CaseState(tuple(rows), None)}) ) ) - miss = query_miss( - prepared, - query, - tuple( - ( - pair, - indexed[index].delivered, - case.eligibility, - f"capacity.pair[{index}]", - pools.get(index), - indexed[index], - ) - for index, pair in jobs - ) - if check_splits - else (), - ReportLocation(None, case.id, None), - inputs, - ) - if miss is not None: - misses.append(miss) question, source = question_kind(query.config, query.offer, query.design_demand) return CapacityAnalysis( prepared, - tuple(misses), analysis_metadata(workers, query.failures), OutputSelection( query.outputs, query.export_cost_distribution, query.export_details diff --git a/ngraph/analysis/controller.py b/ngraph/analysis/controller.py index 76e237c..b9506df 100644 --- a/ngraph/analysis/controller.py +++ b/ngraph/analysis/controller.py @@ -11,6 +11,7 @@ from __future__ import annotations +from collections import deque from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from fractions import Fraction @@ -21,7 +22,6 @@ import netgraph_core as ngc from ngraph.analysis.demands import selected_groups -from ngraph.analysis.precision import effective_rows, propagate from ngraph.analysis.programs import dag_from_edges, domain_dag, edge_id from ngraph.analysis.topology import PreparedTopology from ngraph.model.demand import PRESETS, FlowPolicyPreset, ForwardingGroup @@ -845,3 +845,89 @@ def table_report( for source, nominal, weight in table.source_weights ), ) + + +def effective_rows(topology, edges, destinations, allocation, weights): + """Native preparation's split rows over a set of DAG edges. + + Native W prunes zero-capacity members even for positive EXPLICIT weights, + then removes branches with no effective continuation to a destination; + EQUAL keeps every member. Returns each router's members in EdgeId order, + with their weights (1 for EQUAL). + """ + graph = topology.graph + sources, targets = graph.edge_sources(), graph.edge_destinations() + capacities = graph.edge_capacity_units() + effective_edges = { + edge + for edge in edges + if int(sources[edge]) not in destinations + and ( + allocation == ngc.RouteAllocation.EQUAL + or (capacities[edge] > 0 and weights[edge] > 0) + ) + } + parents = [[] for _ in range(graph.num_nodes())] + for edge in effective_edges: + parents[int(targets[edge])].append(int(sources[edge])) + reachable = set(destinations) + pending = list(destinations) + while pending: + for source in parents[pending.pop()]: + if source not in reachable: + reachable.add(source) + pending.append(source) + rows: dict[int, list[tuple[int, int]]] = {} + for edge in sorted(effective_edges): + if int(targets[edge]) in reachable: + share = 1 if allocation == ngc.RouteAllocation.EQUAL else int(weights[edge]) + rows.setdefault(int(sources[edge]), []).append((edge, share)) + return rows + + +def propagate(graph, rows, injections, *, reached_only=False): + """Exact rational propagation of injected amounts through split rows. + + ``rows`` maps a router to its members (EdgeId, positive weight); a router + splits what it holds in proportion to the weights. Returns the nodes in + processing order (with ``reached_only``, only those the injections reach), + the amount each node holds and the amount on each edge. Amounts at a node + without a row stay there (a destination, or a loss). + """ + targets = graph.edge_destinations() + indegree = [0] * graph.num_nodes() + if reached_only: + nodes, pending = set(injections), list(injections) + while pending: + for edge, _ in rows.get(pending.pop(), ()): + target = int(targets[edge]) + indegree[target] += 1 + if target not in nodes: + nodes.add(target) + pending.append(target) + else: + nodes = range(graph.num_nodes()) + for members in rows.values(): + for edge, _ in members: + indegree[int(targets[edge])] += 1 + ready = deque(node for node in sorted(nodes) if indegree[node] == 0) + incoming: list[Fraction | int] = [0] * len(indegree) + for node, amount in injections.items(): + incoming[node] += Fraction(amount) + order, load = [], {} + while ready: + node = ready.popleft() + order.append(node) + members = rows.get(node, ()) + if incoming[node] > 0 and members: + denominator = sum(weight for _, weight in members) + for edge, weight in members: + share = incoming[node] * weight / denominator + load[edge] = load.get(edge, 0) + share + incoming[int(targets[edge])] += share + for edge, _ in members: + target = int(targets[edge]) + indegree[target] -= 1 + if indegree[target] == 0: + ready.append(target) + return order, incoming, load diff --git a/ngraph/analysis/counts.py b/ngraph/analysis/counts.py index 4231829..c104c56 100644 --- a/ngraph/analysis/counts.py +++ b/ngraph/analysis/counts.py @@ -16,11 +16,9 @@ from ngraph.analysis.failures import Workers, checked_workers, resolve_workers from ngraph.analysis.metadata import analysis_metadata from ngraph.analysis.placement import _endpoints -from ngraph.analysis.precision import _Searched, query_inputs, query_splits -from ngraph.analysis.units import precision_miss, quantize +from ngraph.analysis.units import quantize from ngraph.results import ( OutputSelection, - ReportLocation, ResolvedDemandSet, TunnelCountAnalysis, TunnelCountRow, @@ -134,15 +132,6 @@ def specs(self) -> tuple[tuple[int, bool], ...]: return tuple((count, False) for count in self.counts) + tail -@dataclass(frozen=True) -class _Question: - """One row's question in the shape the split rules read.""" - - config: ngc.FlowPolicyConfig - offer: float - design_demand: float - - def _model(demand, label: str) -> ngc.CentralizedTE: model = demand.config.routing if not isinstance(model, ngc.CentralizedTE) or model.tunnel_count is None: @@ -176,44 +165,6 @@ def _group_key(model: ngc.CentralizedTE) -> tuple: return (model.source_allocation, model.prefer_headroom, model.support) -def count_splits( - prepared: PreparedScenario, query: TunnelCountQuery, label, mask, results=None -): - """The rows' split constraints, declared from the offer, design and live heads. - - One reverse search per demand finds its live heads for all its rows; no - traffic is solved. - """ - topology = prepared.topology - precision = prepared.units_report.precision - specs = query.specs - splits, extra = [], [] - for index, demand in enumerate(query.demands.demands): - model = _model(demand, label) - facts = _Searched( - topology, prepared._named, model, demand.endpoints, mask, demand.pool - ) - for count, up_to in specs: - local, other = query_splits( - topology, - prepared._named, - _Question( - _counted(model, count, up_to), query.offer, query.design_demand - ), - demand.endpoints, - 0, - mask, - f"{label}.demand[{demand.id}].{'up_to' if up_to else 'count'}[{count}]", - precision, - demand.pool, - facts, - result=None if results is None else results[(index, count, up_to)], - ) - splits.extend(local) - extra.extend(other) - return splits, extra - - def tunnel_counts( prepared: PreparedScenario, query: TunnelCountQuery ) -> TunnelCountAnalysis: @@ -292,16 +243,8 @@ def tunnel_counts( cost, ) ) - splits, extra = count_splits(prepared, query, label, mask, results) - samples, constraints = query_inputs(prepared, query, label) - miss = precision_miss( - ReportLocation(None, "baseline", None), - samples, - (*constraints, *splits, *extra), - ) return TunnelCountAnalysis( prepared, - () if miss is None else (miss,), analysis_metadata(workers), OutputSelection(query.outputs, False, query.export_details), tuple(rows), diff --git a/ngraph/analysis/demands.py b/ngraph/analysis/demands.py index 68bec64..3aa1484 100644 --- a/ngraph/analysis/demands.py +++ b/ngraph/analysis/demands.py @@ -389,15 +389,6 @@ def declared_split( return None, None -def residual_weights(named: NamedRoutes, parents) -> bool: - """Whether any declared demand weights its split by the residual snapshot.""" - return any( - declared_split(named, parent.config, parent.policy)[1] - == ngc.WeightBasis.RESIDUAL_SNAPSHOT - for parent in parents - ) - - def expand_units( root: ExpansionSplit | ExpansionLeaf, total: int ) -> tuple[tuple[ExpansionLeaf, int], ...]: @@ -504,15 +495,40 @@ def bind_plans( # Retain references with identity keys: model subclasses need not be hashable, # and their equality must not suppress validation of a different configuration. checked_models = {} + checked_rosters = set() - def check(config, plan): + def check(config, plan, leaf=None): key = id(config) - if key not in checked_models: + model = config.routing + pairs = () + roster = None + if ( + leaf is not None + and isinstance(model, ngc.CentralizedTE) + and model.tunnel_count is not None + ): + sources = ( + [topology.pool_heads[leaf.id]] + if leaf.id in topology.pool_heads + else [topology.node_id_by_name[n] for n in leaf.endpoints.sources] + ) + destinations = [ + topology.node_id_by_name[n] for n in leaf.endpoints.destinations + ] + # Native argument checks depend on roster arity; graph/options checks + # are shared by the materialized model as for all other declarations. + roster = (key, len(sources), len(destinations)) + pairs = ((sources, destinations),) + if key not in checked_models or ( + roster is not None and roster not in checked_rosters + ): try: - validate_model(topology, config) + validate_model(topology, config, pairs=pairs) except (ValueError, TypeError) as exc: raise type(exc)(f"{_plan_label(plan, name)}: {exc}") from exc checked_models[key] = config + if roster is not None: + checked_rosters.add(roster) for plan in plans: error_label = plan.spec.yaml_label @@ -542,7 +558,7 @@ def check(config, plan): raise raise type(exc)(f"{error_label}: {exc}") from exc first_config = first_config or config - check(config, plan) + check(config, plan, leaf) pool = None if leaf.id in topology.pool_heads: pool = PoolMetadata( diff --git a/ngraph/analysis/msd.py b/ngraph/analysis/msd.py index 00fb80a..5037514 100644 --- a/ngraph/analysis/msd.py +++ b/ngraph/analysis/msd.py @@ -13,11 +13,9 @@ from ngraph.analysis.metadata import analysis_metadata from ngraph.analysis.placement import ( _apply_matrix, - _observation_records, policy_roster, remove_policies, ) -from ngraph.analysis.precision import placement_inputs, placement_miss from ngraph.analysis.totals import placement_totals from ngraph.analysis.units import UnitRangeError from ngraph.results import ( @@ -26,7 +24,6 @@ OutputSelection, PlacementRow, RangeRefusal, - ReportLocation, ResolvedDemandSet, UpperCertificate, exact_alpha, @@ -150,7 +147,7 @@ def maximum_supported_demand( demands = query.demands proof = _proof(demands) ledger, owners = policy_roster(topology, demands.demands, topology.disabled) - history, misses = [], [] + history = [] best = None best_rows = best_accounting = None retained = {} if query.export_probe_rows else None @@ -189,7 +186,6 @@ def probe(alpha): return "range" requests = tuple(amounts[demand.id] for demand in demands.demands) results = _apply_matrix(ledger, owners, requests) - records = _observation_records(prepared._named, demands, owners) totals = placement_totals( zip( (demand.id for demand in demands.demands), @@ -207,18 +203,6 @@ def probe(alpha): if below_resolution else ("feasible" if totals.feasible else "infeasible") ) - miss = placement_miss( - prepared, - demands, - results, - records, - topology.disabled, - exact.value, - ReportLocation(None, "baseline", index), - placement_inputs(prepared, demands, exact.value), - ) - if miss is not None: - misses.append(miss) history.append(MSDProbe(index, exact, outcome, totals, None)) better = outcome == "feasible" and ( best is None or alpha > history[best].alpha.value @@ -361,7 +345,6 @@ def unit_threshold(lower, upper): ) return MSDAnalysis( prepared, - tuple(misses), analysis_metadata(1), OutputSelection(ngc.OutputOptions(), False, query.export_details), history[best].alpha if best is not None else None, diff --git a/ngraph/analysis/paths.py b/ngraph/analysis/paths.py index aefbcf9..e702012 100644 --- a/ngraph/analysis/paths.py +++ b/ngraph/analysis/paths.py @@ -246,7 +246,6 @@ def paths(prepared: PreparedScenario, query: PathQuery) -> PathAnalysis: ) return PathAnalysis( prepared, - (), analysis_metadata(1), algorithm, query.mode, diff --git a/ngraph/analysis/placement.py b/ngraph/analysis/placement.py index 28f91d7..4cc1060 100644 --- a/ngraph/analysis/placement.py +++ b/ngraph/analysis/placement.py @@ -9,7 +9,7 @@ import netgraph_core as ngc -from ngraph.analysis.demands import controlled, residual_weights, scaled_expansion +from ngraph.analysis.demands import controlled, scaled_expansion from ngraph.analysis.failures import ( FailureRequest, Workers, @@ -19,14 +19,13 @@ schedule_cases, ) from ngraph.analysis.metadata import analysis_metadata -from ngraph.analysis.precision import placement_inputs, placement_miss from ngraph.analysis.states import ( Baseline, forwarding_failure, participants, simultaneous_role, ) -from ngraph.analysis.topology import NamedRoutes, PreparedTopology +from ngraph.analysis.topology import PreparedTopology from ngraph.analysis.totals import placement_totals from ngraph.results import ( STATE_ORDER, @@ -35,7 +34,6 @@ OutputSelection, PlacementAnalysis, PlacementRow, - ReportLocation, ResolvedDemand, ResolvedDemandSet, StateName, @@ -218,37 +216,6 @@ def _apply_matrix(ledger, policies, requests, outputs=None): ) -def _observation_records( - named: NamedRoutes, demands: ResolvedDemandSet, policies, *, costs=False -): - """Read records only for split descriptions, residual weights or SPF costs.""" - residual = residual_weights(named, demands.expansion_template) - - def needed(policy): - model = policy.config.routing - return ( - residual - or isinstance(model, ngc.Pinned) - or ( - isinstance(model, ngc.ShortestPaths) - and (costs or model.allocation != ngc.RouteAllocation.OPTIMIZED) - ) - or ( - isinstance(model, ngc.CentralizedTE) - and model.tunnel_count is None - and model.source_allocation - in ( - ngc.SourceAllocation.EQUAL, - ngc.SourceAllocation.WEIGHTED, - ) - ) - ) - - return tuple( - tuple(policy.records()) if needed(policy) else () for policy in policies - ) - - def _forward_simultaneous( topology, demands, ledger, owners, entries, roles, held, outputs ): @@ -339,13 +306,16 @@ def _place_state(prepared, query, case, state, entries, tables, shared): failure = forwarding_failure(exc) if failure is None: raise - return CaseState((), None, failure), None + return CaseState((), None, failure) observed = state == "converged" and state in query.states records = ( - _observation_records( - prepared._named, demands, owners, costs=query.export_cost_distribution + tuple( + tuple(policy.records()) + if isinstance(policy.config.routing, ngc.ShortestPaths) + else () + for policy in owners ) - if observed + if observed and query.export_cost_distribution else ((),) * len(owners) ) costs = shared.costs if observed else None @@ -380,24 +350,10 @@ def _place_state(prepared, query, case, state, entries, tables, shared): unavailable, ) ) - miss = None - if observed: - miss = placement_miss( - prepared, - demands, - results, - records, - case.eligibility, - query.alpha, - ReportLocation(None, case.id, None), - shared.inputs, - tables, - ordered, - ) totals = placement_totals( (row.id, row.reference_request_units, row.result) for row in rows ) - return CaseState(tuple(rows), totals), miss + return CaseState(tuple(rows), totals) finally: remove_policies(held) @@ -411,7 +367,6 @@ class _Shared: """ amounts: Mapping[str, int] - inputs: object costs: object programmed: frozenset[str] live: tuple[bool, ...] @@ -441,7 +396,6 @@ def _place_case(prepared, query, case, shared, baseline): computed = ("converged", *(s for s in computed if s != "converged")) group = demands.controller is not None and setup.scope == "group" states: dict[StateName, CaseState] = {} - miss = None for state in computed: if state != "converged" and baseline is None: baseline = Baseline.of(states["converged"]) @@ -469,17 +423,13 @@ def _place_case(prepared, query, case, shared, baseline): baseline, tables, ) - value, observed = _place_state( - prepared, query, case, state, entries, tables, shared - ) + value = _place_state(prepared, query, case, state, entries, tables, shared) if group and state != "stale": dead = tuple(c for t in (tables or {}).values() for c in t.dead_caps) value = replace(value, dead_caps=dead) states[state] = value - if observed is not None: - miss = observed published = MappingProxyType({s: states[s] for s in query.states}) - return CaseResult(case, published), miss, baseline + return CaseResult(case, published), baseline def place(prepared: PreparedScenario, query: PlacementQuery) -> PlacementAnalysis: @@ -518,7 +468,6 @@ def place(prepared: PreparedScenario, query: PlacementQuery) -> PlacementAnalysi workers = resolve_workers(query.workers) shared = _Shared( amounts, - placement_inputs(prepared, query.demands, alpha.value), cost_distribution, _programmed(query.demands), tuple( @@ -535,19 +484,18 @@ def solve(case, baseline=None): first = solve(cases[0]) observed = ( first, - *schedule_cases(cases[1:], lambda case: solve(case, first[2]), workers), + *schedule_cases(cases[1:], lambda case: solve(case, first[1]), workers), ) else: observed = schedule_cases(cases, solve, workers) return PlacementAnalysis( prepared, - tuple(miss for _, miss, _ in observed if miss is not None), analysis_metadata(workers, query.failures), OutputSelection( query.outputs, query.export_cost_distribution, query.export_details ), alpha, - tuple(case for case, _, _ in observed), + tuple(case for case, _ in observed), accounting, query, ) diff --git a/ngraph/analysis/precision.py b/ngraph/analysis/precision.py deleted file mode 100644 index 41b499b..0000000 --- a/ngraph/analysis/precision.py +++ /dev/null @@ -1,949 +0,0 @@ -"""Local split-precision constraints of native observations, under one set of rules. - -A placed demand and an evaluated capacity question split traffic by the same -rules: sources share a demand equally or by positive weights, tunnels share a head, and DAG routers -share their incoming amount by allocation and weights. The rules read their -facts from one of two observers: a placement's native records, or the -searches a capacity evaluator would make on an empty ledger. -""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass, replace -from fractions import Fraction -from functools import cached_property -from typing import TYPE_CHECKING, Literal - -import netgraph_core as ngc - -from ngraph.analysis.demands import controlled, residual_weights -from ngraph.analysis.topology import NamedRoutes, PreparedTopology -from ngraph.analysis.units import ( - input_constraint, - input_samples, - precision_miss, -) -from ngraph.model.demand import PinnedRoutes, _DistributedTEInput, _ParsedModel -from ngraph.results import ( - ExpansionLeaf, - ExpansionParent, - PrecisionMiss, - SplitConstraint, - TunnelResolutionConstraint, - UnverifiedConstraint, -) -from ngraph.utils.values import integer, physical - -if TYPE_CHECKING: - from ngraph.analysis.preparation import PreparedScenario - - -def split_constraint( - kind: Literal["expansion", "source", "router", "tunnel"], - label: str, - incoming: Fraction, - members: int, - q: float, - precision: float, -) -> SplitConstraint: - """Record a local split bound without solving admission or forwarding.""" - integer(members, "split members", minimum=2) - bound = ( - Fraction(physical(precision, "units.precision", positive=True)) - * incoming - / (members - 1) - ) - return SplitConstraint( - kind, label, incoming, "declared", members, bound, Fraction(q) <= bound - ) - - -def expansion_bounds(plans, q, precision, alpha=1.0): - """Describe continuous declared splits on the fixed ordered tree.""" - constraints, incoming = [], {} - - def visit(node, amount): - if isinstance(node, ExpansionLeaf): - incoming[node.id] = amount - return - members = len(node.children) - if amount > 0 and members > 1: - constraints.append( - split_constraint("expansion", node.label, amount, members, q, precision) - ) - for child in node.children: - visit(child, amount / members) - - for plan in plans: - volume = ( - plan.physical_volume - if isinstance(plan, ExpansionParent) - else plan.spec.volume - ) - visit(plan.root, Fraction(float(volume) * float(alpha))) - return tuple(constraints), incoming - - -def effective_rows(topology, edges, destinations, allocation, weights): - """Native preparation's split rows over a set of DAG edges. - - Native W prunes zero-capacity members even for positive EXPLICIT weights, - then removes branches with no effective continuation to a destination; - EQUAL keeps every member. Returns each router's members in EdgeId order, - with their weights (1 for EQUAL). - """ - graph = topology.graph - sources, targets = graph.edge_sources(), graph.edge_destinations() - capacities = graph.edge_capacity_units() - effective_edges = { - edge - for edge in edges - if int(sources[edge]) not in destinations - and ( - allocation == ngc.RouteAllocation.EQUAL - or (capacities[edge] > 0 and weights[edge] > 0) - ) - } - parents = [[] for _ in range(graph.num_nodes())] - for edge in effective_edges: - parents[int(targets[edge])].append(int(sources[edge])) - reachable = set(destinations) - pending = list(destinations) - while pending: - for source in parents[pending.pop()]: - if source not in reachable: - reachable.add(source) - pending.append(source) - rows: dict[int, list[tuple[int, int]]] = {} - for edge in sorted(effective_edges): - if int(targets[edge]) in reachable: - share = 1 if allocation == ngc.RouteAllocation.EQUAL else int(weights[edge]) - rows.setdefault(int(sources[edge]), []).append((edge, share)) - return rows - - -def propagate(graph, rows, injections, *, reached_only=False): - """Exact rational propagation of injected amounts through split rows. - - ``rows`` maps a router to its members (EdgeId, positive weight); a router - splits what it holds in proportion to the weights. Returns the nodes in - processing order (with ``reached_only``, only those the injections reach), - the amount each node holds and the amount on each edge. Amounts at a node - without a row stay there (a destination, or a loss). - """ - targets = graph.edge_destinations() - indegree = [0] * graph.num_nodes() - if reached_only: - nodes, pending = set(injections), list(injections) - while pending: - for edge, _ in rows.get(pending.pop(), ()): - target = int(targets[edge]) - indegree[target] += 1 - if target not in nodes: - nodes.add(target) - pending.append(target) - else: - nodes = range(graph.num_nodes()) - for members in rows.values(): - for edge, _ in members: - indegree[int(targets[edge])] += 1 - ready = deque(node for node in sorted(nodes) if indegree[node] == 0) - incoming: list[Fraction | int] = [0] * len(indegree) - for node, amount in injections.items(): - incoming[node] += Fraction(amount) - order, load = [], {} - while ready: - node = ready.popleft() - order.append(node) - members = rows.get(node, ()) - if incoming[node] > 0 and members: - denominator = sum(weight for _, weight in members) - for edge, weight in members: - share = incoming[node] * weight / denominator - load[edge] = load.get(edge, 0) + share - incoming[int(targets[edge])] += share - for edge, _ in members: - target = int(targets[edge]) - indegree[target] -= 1 - if indegree[target] == 0: - ready.append(target) - return order, incoming, load - - -def dag_bounds( - topology, - dags, - injections, - destinations, - allocation, - weights, - label, - precision, - *, - allowed_edges=None, -): - """Rational split propagation over native-selected DAGs, without capacity/admission.""" - graph = topology.graph - edges = set() - for dag in dags: - edges.update(int(edge) for edge in dag.via_edges()) - if allowed_edges is not None: - edges.intersection_update(allowed_edges) - rows = effective_rows(topology, edges, destinations, allocation, weights) - order, incoming, _ = propagate(graph, rows, injections) - constraints = [] - for node in order: - count = len(rows.get(node, ())) - if node not in destinations and incoming[node] > 0 and count > 1: - name = ( - topology.node_names[node] - if node < len(topology.node_names) - else f"private[{node}]" - ) - constraints.append( - split_constraint( - "router", - f"{label}.router.{name}", - incoming[node], - count, - topology.q, - precision, - ) - ) - return tuple(constraints) - - -@dataclass(frozen=True) -class _Entry: - """One live pinned route entry: its head, its share and its DAG, if any.""" - - index: int - tag: object # the head as the entry's tunnel label names it - head: int - weight: int - siblings: int - denominator: int - dag: ngc.PredecessorDAG | None - - -def _vector(topology, allocation, basis, explicit, residual): - """The weights a weighted DAG split reads; an empty ledger's residual is capacity.""" - if allocation == ngc.RouteAllocation.EQUAL: - return None - if basis == ngc.WeightBasis.EXPLICIT: - return explicit - if basis == ngc.WeightBasis.RESIDUAL_SNAPSHOT and residual is not None: - return residual - return topology.graph.edge_capacity_units() - - -def _equal_sources(model: ngc.ShortestPaths) -> bool: - allocation = model.source_allocation or ( - ngc.SourceAllocation.OPTIMIZED - if model.allocation == ngc.RouteAllocation.OPTIMIZED - else ngc.SourceAllocation.EQUAL - ) - return allocation == ngc.SourceAllocation.EQUAL - - -def tunnel_members(model, heads, plan=None): - """Describe actual T2 tunnels, or declared counts without an observation. - - T1 always attempts its requested tunnels, including at dead heads. - """ - assert model.tunnel_count is not None - if plan is None or isinstance(model, ngc.DistributedTE): - return tuple((head, model.tunnel_count.per_head) for head in heads) - live = set(plan.heads_with_capacity) - # Without a whole unit per tunnel, the chosen count is only a fallback. - members = ( - model.tunnel_count.per_head - if plan.max_bandwidth_per_tunnel == 0 - else plan.tunnels_per_head - ) - return tuple((head, members) for head in heads if head in live) - - -def _tunnel_resolution(topology, model, plan, label, pool): - """Observe a live T2 plan without inferring a bound for another rounded graph.""" - if ( - not isinstance(model, ngc.CentralizedTE) - or model.tunnel_count is None - or plan is None - or not plan.insufficient_resolution - or not plan.heads_with_capacity - ): - return () - heads = [] - for head in plan.heads_with_capacity: - if head < len(topology.node_names): - heads.append(topology.node_names[head]) - else: - assert pool is not None and topology.pool_heads[pool.head.pool_id] == head - heads.append(pool.head) - return (TunnelResolutionConstraint(label, topology.q, tuple(heads)),) - - -def _model_splits( - topology, - named, - model, - declaration, - label, - amount, - facts, - precision, - table=None, - plan=None, - pool=None, -): - """Apply the split rules of one native model to one observed amount. - - A demand the WCMP controller programs (``table``) splits equally over its - sources, then by its destination table. - """ - constraints = [] - extra = _tunnel_resolution(topology, model, plan, label, pool) - - def add(kind, suffix, incoming, members): - if incoming > 0 and members > 1: - constraints.append( - split_constraint( - kind, f"{label}.{suffix}", incoming, members, topology.q, precision - ) - ) - - if ( - table is None - and isinstance(model, ngc.Pinned) - and not isinstance(declaration, PinnedRoutes) - ): - return constraints, (UnverifiedConstraint(label),) - if amount <= 0: - return constraints, extra - heads = facts.tunnel_heads - if heads is not None: - assert isinstance(model, (ngc.DistributedTE, ngc.CentralizedTE)) - assert model.tunnel_count is not None - add("source", "sources", amount, len(heads)) - for head, members in tunnel_members(model, heads, plan): - add( - "tunnel", - f"head[{head}]", - amount / len(heads), - members, - ) - elif table is not None or isinstance(model, ngc.ShortestPaths): - if table is None: - assert isinstance(model, ngc.ShortestPaths) - roster, dags = facts.shortest(model) - equal, allocation = _equal_sources(model), model.allocation - basis, explicit = model.weight_basis, model.edge_weights - else: - roster, dags = facts.sources, (table.dag,) - equal, allocation = True, table.encoding - basis, explicit = ngc.WeightBasis.EXPLICIT, table.weights - if equal: - add("source", "sources", amount, len(roster)) - if allocation != ngc.RouteAllocation.OPTIMIZED and roster: - constraints.extend( - dag_bounds( - topology, - dags, - {source: amount / len(roster) for source in roster}, - facts.destinations, - allocation, - _vector(topology, allocation, basis, explicit, facts.residual), - label, - precision, - ) - ) - elif isinstance(model, ngc.Pinned): - assert isinstance(declaration, PinnedRoutes) - roster, entries = facts.pinned(declaration) - add("source", "sources", amount, len(roster)) - allocation = ( - ngc.RouteAllocation.WEIGHTED - if declaration.allocation == ngc.RouteAllocation.WEIGHTED - else ngc.RouteAllocation.EQUAL - ) - explicit = ( - None - if declaration.edge_weights is None - else named.weights[declaration.edge_weights] - ) - for entry in entries: - if not entry.denominator or not roster or not entry.weight: - continue - incoming = amount / len(roster) - add("tunnel", f"entries.{entry.tag}", incoming, entry.siblings) - if entry.dag is not None: - constraints.extend( - dag_bounds( - topology, - (entry.dag,), - {entry.head: incoming * entry.weight / entry.denominator}, - facts.destinations, - allocation, - _vector( - topology, - allocation, - declaration.weight_basis, - explicit, - facts.residual, - ), - f"{label}.entry[{entry.index}]", - precision, - allowed_edges=facts.allowed, - ) - ) - elif isinstance(model, ngc.CentralizedTE) and model.source_allocation in ( - ngc.SourceAllocation.EQUAL, - ngc.SourceAllocation.WEIGHTED, - ): - roster = facts.sources - if model.source_allocation == ngc.SourceAllocation.WEIGHTED: - weights = model.source_weights - assert weights is not None - roster = [source for source in roster if weights[source] > 0] - add("source", "sources", amount, len(roster)) - return constraints, extra - - -def _unique(constraints): - return tuple( - { - (c.kind, c.label, c.incoming_physical, c.members): c for c in constraints - }.values() - ) - - -class _Placed: - """Facts of one placed demand, read from its native records.""" - - allowed = None - - def __init__(self, topology, named, demand, result, records, mask, residual): - self.topology, self.named, self.demand = topology, named, demand - self.result, self.records, self.mask = result, records, mask - self.residual = residual - self.destinations = { - topology.node_id_by_name[name] for name in demand.endpoints.destinations - } - - @property - def tunnel_heads(self): - plan = self.result.plan - return None if plan is None else [head for head, _ in plan.design_by_head] - - def shortest(self, model): - search = ngc.spf( - self.topology.graph, - sorted(self.destinations), - ngc.SpfOptions(ngc.Direction.REVERSE, eligibility=self.mask), - ) - distances = search.costs - assert distances is not None - sources = [ - self.topology.node_id_by_name[name] - for name in self.demand.endpoints.sources - ] - roster = [source for source in sources if distances[source] != ngc.UNREACHABLE] - return roster, [record.route for record in self.records] - - @property - def sources(self): - return sorted({source for record in self.records for source in record.sources}) - - def pinned(self, declaration): - specs = [self.named.route_specs[name] for name in declaration.routes] - # An entry the case's eligibility fails gets nothing; only live ones split. - failed = { - row.entry_id - for row in self.result.by_tunnel - if row.status == ngc.TunnelStatus.FAILED - } - live = [] - for record in self.records: - if record.entry_id is None: - raise ValueError( - f"demand.{self.demand.id}: named pinned entry has no entry_id" - ) - if record.entry_id not in failed: - live.append(record) - entries = [] - for record in live: - spec = specs[record.entry_id] - siblings = [ - specs[other.entry_id] - for other in live - if specs[other.entry_id].head == spec.head - and specs[other.entry_id].weight > 0 - ] - entries.append( - _Entry( - record.entry_id, - spec.head, - self.topology.node_id_by_name[spec.head], - spec.weight, - len(siblings), - sum(item.weight for item in siblings), - record.route if spec.support is not None else None, - ) - ) - return sorted({source for record in live for source in record.sources}), entries - - -def placement_splits( - topology: PreparedTopology, - named: NamedRoutes, - demands, - results, - record_sets, - mask, - alpha, - precision, - tables=None, - ordered=True, -): - """Describe actual native records; never rerun a placement to obtain a report. - - A row is described by its demand's declared model, from the participant's - records. A controlled row is described from its table: the set's nominal - one, or the case's own (``tables``, by destination). Under a SIMULTANEOUS - list (not ``ordered``) every participant was prepared on one common view. - """ - expansion, incoming = expansion_bounds( - demands.expansion_template, topology.q, precision, alpha - ) - parents = {parent.base_id: parent for parent in demands.expansion_template} - constraints = list(expansion) - extra = [] - before = ( - [int(value) for value in topology.graph.edge_capacity_units()] - if residual_weights(named, demands.expansion_template) - else None - ) - for demand, result, records in zip( - demands.demands, results, record_sets, strict=True - ): - facts = _Placed(topology, named, demand, result, records, mask, before) - local, other = _model_splits( - topology, - named, - demand.config.routing, - parents[demand.base_id].policy, - f"demand.{demand.id}", - incoming[demand.id], - facts, - precision, - ( - tables[demand.endpoints.destinations[0]] - if tables is not None - else demands.controller_table(demand.endpoints.destinations[0]) - ) - if controlled(demands.controller, parents[demand.base_id].policy) - else None, - plan=result.plan, - pool=demand.pool, - ) - constraints.extend(local) - extra.extend(other) - # Service and reservation baselines are distinct; only carried flows - # change the next ordered policy's RESIDUAL_SNAPSHOT weights. - if before is not None and ordered: - for record in records: - for edge, units in record.edge_flows: - before[edge] -= units - return _unique(constraints), tuple(extra) - - -class _Searched: - """Facts of one capacity question's pair, from the searches its evaluator makes. - - Every evaluator begins on an empty ledger, so availability equals capacity. - """ - - residual = None - - def __init__(self, topology, named, model, pair, mask, pool=None): - self.topology, self.named, self.model, self.mask = topology, named, model, mask - self.roster = ( - [topology.pool_heads[pool.head.pool_id]] - if pool is not None - else [topology.node_id_by_name[n] for n in pair.sources] - ) - self.destinations = {topology.node_id_by_name[n] for n in pair.destinations} - - def _reached(self, mask): - search = ngc.spf( - self.topology.graph, - sorted(self.destinations), - ngc.SpfOptions(ngc.Direction.REVERSE, eligibility=mask), - ) - distances = search.costs - assert distances is not None - return [ - source for source in self.roster if distances[source] != ngc.UNREACHABLE - ] - - @cached_property - def tunnel_heads(self): - model = self.model - if isinstance(model, ngc.DistributedTE) or ( - isinstance(model, ngc.CentralizedTE) and model.tunnel_count is not None - ): - return self.sources - return None - - def shortest(self, model): - # No residual is supplied, so zero-capacity members remain in native SPF. - dags, roster = [], [] - for source in self.roster: - search = ngc.spf( - self.topology.graph, - sorted(self.destinations), - ngc.SpfOptions( - ngc.Direction.REVERSE, - targets=None if model.multipath else source, - tie_break=model.tie_break, - multipath=model.multipath, - parallel_edges=model.parallel_edges, - eligibility=self.mask, - ), - ) - if model.multipath: - distances = search.costs - assert distances is not None - reached = distances[source] != ngc.UNREACHABLE - else: - reached = search.target_cost is not None - if reached: - roster.append(source) - dags.append(search.dag) - return roster, dags - - @property - def sources(self): - model = self.model - mask = self.mask - support = getattr(model, "support", None) - if support is not None: - members = {int(edge) for edge in support.via_edges()} - mask = ngc.Eligibility.intersect( - mask, - self.topology.graph.eligibility( - excluded_edges=[ - edge - for edge in range(self.topology.graph.num_edges()) - if edge not in members - ] - ), - ) - return self._reached(mask) - - @cached_property - def allowed(self): - graph = self.topology.graph - edge_allowed, node_allowed = self.mask.edge_allowed(), self.mask.node_allowed() - sources, targets = graph.edge_sources(), graph.edge_destinations() - return { - edge - for edge in range(graph.num_edges()) - if edge_allowed[edge] - and node_allowed[int(sources[edge])] - and node_allowed[int(targets[edge])] - } - - def _live(self, definition): - # Native eligibility repairs the route; only surviving members count. - route = definition.route - if not isinstance(route, ngc.PredecessorDAG): - return all( - int(edge) in self.allowed for hop in route.hops() for edge in hop - ) - graph = self.topology.graph - sources, targets = graph.edge_sources(), graph.edge_destinations() - parents = [[] for _ in range(graph.num_nodes())] - for edge in route.via_edges(): - if int(edge) in self.allowed: - parents[int(targets[edge])].append(int(sources[edge])) - reachable, pending = set(self.destinations), list(self.destinations) - while pending: - for parent in parents[pending.pop()]: - if parent not in reachable: - reachable.add(parent) - pending.append(parent) - return definition.head in reachable - - def pinned(self, declaration): - definitions = [self.named.routes[name] for name in declaration.routes] - live = [ - (index, definition) - for index, definition in enumerate(definitions) - if self._live(definition) - ] - roster = sorted({definition.head for _, definition in live}) - entries = [] - for index, definition in live: - siblings = [ - other - for _, other in live - if other.head == definition.head and other.weight > 0 - ] - route = definition.route - entries.append( - _Entry( - index, - definition.head, - definition.head, - definition.weight, - len(siblings), - sum(other.weight for other in siblings), - route if isinstance(route, ngc.PredecessorDAG) else None, - ) - ) - return roster, entries - - -def question_inputs(offer, design_demand, declaration): - """A capacity question's physical atoms, by field, known before evaluation.""" - values = {} - if offer is not None: - values["offer"] = ("offer", offer) - if design_demand is not None: - values["design_demand"] = ("design", design_demand) - if isinstance(declaration, _ParsedModel) and isinstance( - declaration.routing, _DistributedTEInput - ): - values["min_unreserved"] = ( - "min_unreserved", - declaration.routing.min_unreserved_bandwidth, - ) - return values - - -def query_has_splits(query) -> bool: - """Whether this native allocation has local split constraints at all.""" - model = query.config.routing - return not ( - ( - isinstance(model, ngc.CentralizedTE) - and model.tunnel_count is None - and model.source_allocation == ngc.SourceAllocation.OPTIMIZED - ) - or ( - isinstance(model, ngc.ShortestPaths) - and model.allocation == ngc.RouteAllocation.OPTIMIZED - and model.source_allocation in (None, ngc.SourceAllocation.OPTIMIZED) - ) - ) - - -def query_splits( - topology, - named, - query, - pair, - delivered, - mask, - label, - precision, - pool=None, - facts=None, - result=None, -): - """Describe this native evaluation's splits, without another traffic solve. - - ``facts`` reuses one pair's searches across questions that differ only in - their tunnel count. - """ - if not query_has_splits(query): - return (), () - model = query.config.routing - observed = query.offer is None - amount = query.offer - if ( - amount is None - and query.config.admission == ngc.Admission.LOSSY - and isinstance(model, ngc.CentralizedTE) - and model.tunnel_count is not None - and query.design_demand is not None - ): - amount, observed = query.design_demand, False - if observed: - amount = Fraction(delivered) * Fraction(topology.q) - else: - amount = Fraction(float(amount)) - if facts is None: - facts = _Searched(topology, named, model, pair, mask, pool) - declaration = getattr(query, "declaration", None) # sensitivity has none - plan = None if result is None else result.plan - constraints, extra = _model_splits( - topology, - named, - model, - declaration, - label, - amount, - facts, - precision, - plan=plan, - pool=pool, - ) - if observed: - constraints = [replace(c, basis="observed") for c in constraints] - heads = facts.tunnel_heads - if heads is not None and query.design_demand is not None: - assert isinstance(model, (ngc.DistributedTE, ngc.CentralizedTE)) - assert model.tunnel_count is not None - design = Fraction(float(query.design_demand)) - if design > 0 and (observed or design != amount): - if len(heads) > 1: - constraints.append( - split_constraint( - "source", - f"{label}.design.sources", - design, - len(heads), - topology.q, - precision, - ) - ) - for head, members in tunnel_members(model, heads, plan): - if members > 1: - constraints.append( - split_constraint( - "tunnel", - f"{label}.design.head[{head}]", - design / len(heads), - members, - topology.q, - precision, - ) - ) - return _unique(constraints), tuple(extra) - - -def observed_inputs(prepared: PreparedScenario, inputs): - """Reuse exact prepared atoms; scaled or query-local atoms remain local.""" - q = prepared.topology.q - samples, constraints = [], [] - for label, (kind, value) in sorted(inputs.items()): - cached = prepared._input_observations.get(label) - if ( - cached is not None - and (cached[0].kind, cached[0].binary64_value) == (kind, value) - and (value != 0 or cached[0].binary64_hex == float(value).hex()) - ): - sample, constraint = cached - else: - sample = input_samples({label: (kind, value)}, q)[0] - constraint = input_constraint( - label, value, q, prepared.units_report.precision - ) - samples.append(sample) - if constraint is not None: - constraints.append(constraint) - return tuple(samples), tuple(constraints) - - -def placement_inputs(prepared: PreparedScenario, demands, alpha): - """The input observations shared by cases of one fixed-alpha question.""" - parents = {parent.base_id for parent in demands.expansion_template} - inputs = { - label: value - for label, value in prepared._inputs.items() - if value[0] == "capacity" - or ( - value[0] == "min_unreserved" - and any(label == f"demand.{base_id}.min_unreserved" for base_id in parents) - ) - } - inputs.update( - { - f"demand.{parent.base_id}.volume": ( - "demand", - float(parent.physical_volume) * float(alpha), - ) - for parent in demands.expansion_template - } - ) - return observed_inputs(prepared, inputs) - - -def query_inputs(prepared: PreparedScenario, query, label): - """The input observations of one capacity question, shared by its cases.""" - inputs = { - name: atom for name, atom in prepared._inputs.items() if atom[0] == "capacity" - } - inputs.update( - { - f"{label}.{name}": atom - for name, atom in question_inputs( - query.offer, query.design_demand, getattr(query, "declaration", None) - ).items() - } - ) - return observed_inputs(prepared, inputs) - - -def placement_miss( - prepared: PreparedScenario, - demands, - results, - records, - mask, - alpha, - location, - observations, - tables=None, - ordered=True, -) -> PrecisionMiss | None: - """The missed local targets of one placement on the fixed prepared q. - - ``tables`` are a failure case's controller tables by destination. - """ - samples, constraints = observations - splits, extra = placement_splits( - prepared.topology, - prepared._named, - demands, - results, - records, - mask, - alpha, - prepared.units_report.precision, - tables, - ordered, - ) - return precision_miss(location, samples, (*constraints, *splits, *extra)) - - -def query_miss( - prepared: PreparedScenario, query, observations, location, inputs -) -> PrecisionMiss | None: - """The missed local targets of one capacity question's evaluations. - - ``observations`` holds (pair, delivered units, eligibility, label, pool, result) - rows. - """ - samples, constraints = inputs - constraints = list(constraints) - if query_has_splits(query): - for pair, delivered, mask, label, pool, result in observations: - splits, extra = query_splits( - prepared.topology, - prepared._named, - query, - pair, - delivered, - mask, - label, - prepared.units_report.precision, - pool, - result=result, - ) - constraints.extend((*splits, *extra)) - return precision_miss(location, samples, constraints) diff --git a/ngraph/analysis/preparation.py b/ngraph/analysis/preparation.py index 4183f45..b02508a 100644 --- a/ngraph/analysis/preparation.py +++ b/ngraph/analysis/preparation.py @@ -1,7 +1,7 @@ """Assemble one prepared graph, unit and demand binding for a scenario's questions. Preparation decides what is asked (which steps, demand sets and capacity -questions are active) and observes each candidate resolution once; the units +questions are active) and compiles one graph at the chosen unit; the units module calculates conversions, reports and the choice of resolution. """ @@ -9,7 +9,7 @@ from collections import Counter from collections.abc import Callable, Mapping -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from functools import cache from types import MappingProxyType from typing import TYPE_CHECKING, NoReturn @@ -29,20 +29,9 @@ offer_budgets, pool_budgets, pool_requirements, + pooled, selected_pairs, ) -from ngraph.analysis.placement import ( - _observation_records, - policy_roster, - remove_policies, - replace_matrix, -) -from ngraph.analysis.precision import ( - expansion_bounds, - placement_splits, - query_has_splits, - query_splits, -) from ngraph.analysis.topology import ( NO_ROUTES, NamedRoutes, @@ -55,24 +44,27 @@ UnitsSettings, _InputKind, choose_resolution, - input_observations, make_report, - with_splits, + quantize, + split_count, +) +from ngraph.model.demand import ( + PRESETS, + FlowPolicyPreset, + _DistributedTEInput, + _ParsedModel, + _ShortestPathsInput, ) -from ngraph.model.demand import _DistributedTEInput, _ParsedModel from ngraph.model.risk import MembershipIndex from ngraph.model.selectors import Selector from ngraph.results import ( - InputConstraint, - InputQuantization, PreparationScope, ResolvedDemandSet, - TunnelResolutionConstraint, UnitsReport, approximations, ) from ngraph.utils.values import _input_error, _shown, physical -from ngraph.workflow.base import step_name +from ngraph.workflow.base import WORKFLOW_STEP_REGISTRY, step_name from ngraph.workflow.diagnostics import ( _MISSING, error_context, @@ -94,13 +86,13 @@ class StepNeeds: ``numerical`` steps are evaluated at the prepared resolution. A step may name a demand set to bind, a failure policy, and an earlier step whose feasible alpha it uses (one that ``provides_alpha``). ``capacity`` builds - the step's capacity question, which preparation evaluates once per - candidate resolution; ``inputs`` are that question's physical amounts, by + the step's capacity question, which preparation validates once after + final compilation; ``inputs`` are that question's physical amounts, by field name. ``pool_endpoints`` are the source and target of a question whose combined sources are a pool: preparation gives them a private head. ``offer_budget`` is a fixed lossy offer every pool leaf of ``demand_set`` - receives; ``counts`` builds a tunnel-count question, whose split targets - preparation observes once per candidate resolution. ``nodes`` and ``links`` + receives; ``counts`` builds a tunnel-count question, which preparation + validates once after final binding. ``nodes`` and ``links`` are names the step reads that must exist in the network. """ @@ -129,8 +121,7 @@ class StepNeeds: class PreparedScenario: """One graph, unit and demand binding over a read-only view of a scenario. - ``units_report`` is the preparation's report; each analysis returns its - own precision misses beside it. + ``units_report`` records the fixed input/count bounds for every analysis. """ scenario: Scenario @@ -139,11 +130,6 @@ class PreparedScenario: units_report: UnitsReport _named: NamedRoutes = field(repr=False, compare=False) _membership: MembershipIndex = field(repr=False, compare=False) - # Each physical input's kind and value, and its exact observation. - _inputs: Mapping[str, tuple[_InputKind, float]] = field(repr=False, compare=False) - _input_observations: Mapping[ - str, tuple[InputQuantization, InputConstraint | None] - ] = field(repr=False, compare=False) # Each step's name, seed and seed source as prepared. _steps: tuple[tuple[str, int | None, SeedSource], ...] = field( repr=False, compare=False @@ -152,29 +138,6 @@ class PreparedScenario: _controller: ControllerSetup | None = field(default=None, repr=False, compare=False) -def _nominal_splits(prepared, demands, precision): - """Observe one native nominal placement on a private ledger.""" - topology = prepared.topology - ledger, owners = policy_roster(topology, demands.demands, topology.disabled) - try: - results = replace_matrix( - ledger, owners, tuple(d.nominal_units for d in demands.demands) - ) - records = _observation_records(prepared._named, demands, owners) - return placement_splits( - topology, - prepared._named, - demands, - results, - records, - topology.disabled, - 1.0, - precision, - ) - finally: - remove_policies(owners) - - def _options(topology, query, mask): return evaluation_options( topology, @@ -186,50 +149,155 @@ def _options(topology, query, mask): ) -def _capacity_splits(prepared, step, name, needs, precision): - """Observe one capacity question's nominal evaluation.""" +def _validate_capacity_questions(prepared, capacities, counting): + """Validate final materialized questions without eligible traffic or a solve.""" topology = prepared.topology - try: - query = needs.capacity(prepared) - _, jobs, native_pairs, pools = question_pairs( - prepared, query, f"workflow.{name}" - ) - if not query_has_splits(query): - # No split constraint needs this question's amounts; one call with - # no eligible node applies the native checks without solving. - nothing = topology.graph.eligibility( - excluded_nodes=range(topology.graph.num_nodes()) - ) + nothing = topology.graph.eligibility( + excluded_nodes=range(topology.graph.num_nodes()) + ) + for step, name, needs in capacities: + try: + query = needs.capacity(prepared) + _, _, native_pairs, _ = question_pairs(prepared, query, f"workflow.{name}") + if isinstance(query.config.routing, ngc.Pinned): + # A supplied program checks endpoint identities, not just counts. + checked_pairs = native_pairs + else: + arities = {} + for pair in native_pairs: + arities.setdefault((len(pair[0]), len(pair[1])), pair) + checked_pairs = tuple(arities.values()) evaluate( - topology, query, native_pairs[:1], _options(topology, query, nothing), 1 + topology, query, checked_pairs, _options(topology, query, nothing), 1 ) - return [], [] - splits, extra = [], [] - observations = evaluate( - topology, - query, - native_pairs, - _options(topology, query, topology.disabled), - 1, - ) - for (index, pair), result in zip(jobs, observations, strict=True): - local, other = query_splits( - topology, - prepared._named, - query, - pair, - result.delivered, - topology.disabled, - f"workflow.{name}.pair[{index}]", - precision, - pools.get(index), - result=result, - ) - splits.extend(local) - extra.extend(other) - return splits, extra - except (ValueError, TypeError) as exc: - raise _labelled(step, exc, prepared.scenario) from exc + except (ValueError, TypeError) as exc: + raise _labelled(step, exc, prepared.scenario) from exc + if not counting: + return + from ngraph.analysis.counts import _counted, _group_key, _model + from ngraph.analysis.placement import _endpoints + + for step, name, needs in counting: + try: + query = needs.counts(prepared) + label = f"workflow.{name}" + offer = quantize(query.offer, topology.q, f"{label}.offer") + design = quantize(query.design_demand, topology.q, f"{label}.design_demand") + checked = set() + for demand in query.demands.demands: + model = _model(demand, label) + if demand.pool is not None and offer > demand.pool.offer_limit_units: + raise ValueError( + f"{label}.offer: demand {demand.id}: offer {offer} units exceeds the " + f"prepared lossy budget {demand.pool.offer_limit_units} units of TE " + f"ingress pool {demand.pool.head.pool_id}" + ) + pair = _endpoints(topology, demand) + for count, up_to in query.specs: + key = ( + _group_key(model), + tuple(pair[0]), + tuple(pair[1]), + count, + up_to, + ) + if key in checked: + continue + config = _counted(model, count, up_to) + ngc.evaluate_pairs( + topology.graph, + [pair], + ngc.EvaluationOptions( + config, + eligibility=nothing, + outputs=query.outputs, + offer=offer, + design_demand=design, + ), + workers=1, + ) + checked.add(key) + except (ValueError, TypeError) as exc: + raise _labelled(step, exc, prepared.scenario) from exc + + +def _declared_counts(declaration, heads): + """Classify a declaration without materializing graph-bound routes or weights.""" + if isinstance(declaration, FlowPolicyPreset): + declaration = PRESETS[declaration] + if not isinstance(declaration, (ngc.FlowPolicyConfig, _ParsedModel)): + return 0, 0 + model = declaration.routing + count = getattr(model, "tunnel_count", None) + if count is not None: + return 0, heads * count.per_head + equal = ( + isinstance(model, (ngc.ShortestPaths, _ShortestPathsInput)) + and model.allocation == ngc.RouteAllocation.EQUAL + and model.source_allocation in (None, ngc.SourceAllocation.EQUAL) + and declaration.admission == ngc.Admission.LOSSLESS + ) + return int(equal), 0 + + +def _static_groups(scenario, entries, plans_by_name, requested): + """Read known workflow declarations and expansion leaves before compilation.""" + declared_sets = set(requested) + declared_sets.update( + needs.demand_set + for _, _, needs in entries + if needs.demand_set is not None and needs.interaction != "simultaneous" + ) + groups = [] + for name, plans in plans_by_name.items(): + if name not in declared_sets: + continue + k = t = 0 + for plan in plans: + for leaf in leaves(plan.root): + if leaf.endpoints is None: + continue + heads = ( + 1 + if pooled(plan.spec.mode, plan.spec.policy) + else len(leaf.endpoints.sources) + ) + local_k, local_t = _declared_counts(plan.spec.policy, heads) + k += local_k + t += local_t + groups.append((name, k, t)) + for step, name, _ in entries: + if type(step) is WORKFLOW_STEP_REGISTRY.get("MaxFlow"): + k, tunnels_per_head = _declared_counts(step.declaration, 1) + if not tunnels_per_head: + # Each pair contributes exactly the floor, which wins ties. + # Final question validation still checks every endpoint. + continue + try: + pairs = selected_pairs( + scenario.network, + step.source, + step.target, + step.mode, + f"step {name}", + ) + except (ValueError, TypeError) as exc: + raise _labelled(step, exc, scenario) from exc + for pair in pairs: + heads = 1 if pooled(step.mode, step.declaration) else len(pair.sources) + groups.append((f"workflow.{name}", k, heads * tunnels_per_head)) + elif type(step) is WORKFLOW_STEP_REGISTRY.get("TunnelCountAnalysis"): + count = max((*step.counts, step.up_to or 0)) + for plan in plans_by_name[step.demand_set]: + for leaf in leaves(plan.root): + if leaf.endpoints is not None: + heads = ( + 1 + if pooled(plan.spec.mode, plan.spec.policy) + else len(leaf.endpoints.sources) + ) + groups.append((f"workflow.{name}", 0, heads * count)) + return groups def _labelled(step, exc, scenario, value=_MISSING): @@ -311,7 +379,7 @@ def prepare_scenario( ) -> PreparedScenario: """Prepare a read-only scenario view; the steps' needs decide what is bound.""" numerical, active, capacities, providers, failing = [], [], [], set(), [] - simultaneous, counting, declared_inputs = [], [], [] + simultaneous, counting, declared_inputs, entries = [], [], [], [] for index, step in enumerate(scenario.workflow): name = step_name(step) location = workflow_location(index, step.name) @@ -321,6 +389,7 @@ def prepare_scenario( raise _labelled(step, exc, scenario) from exc if needs is None: continue + entries.append((step, name, needs)) if needs.numerical: numerical.append(name) for kind, names, known in ( @@ -391,16 +460,16 @@ def prepare_scenario( lossy_plans = [] for step, name, demand_set, alpha, source in simultaneous: values = plans_by_name[demand_set] - pooled = [ + pooled_leaves = [ leaf.id for plan in values for leaf in leaves(plan.root) if leaf.id in pools ] - if alpha is None and pooled: + if alpha is None and pooled_leaves: raise _labelled( step, _input_error( f"workflow.{name}.interaction", - f"demand {pooled[0]}: its scale comes from MSD step {source}, unknown at " - f"preparation, so TE ingress pool {pooled[0]} has no lossy offer budget", + f"demand {pooled_leaves[0]}: its scale comes from MSD step {source}, unknown at " + f"preparation, so TE ingress pool {pooled_leaves[0]} has no lossy offer budget", ), scenario, ) @@ -481,7 +550,9 @@ def input_refusal(exc) -> NoReturn: ) if cost_sum >= LIMIT: raise ValueError(f"topology augmented cost sum {cost_sum} must be below 2^62") - expansion, _ = expansion_bounds(plans, 1.0, settings.precision) + groups = _static_groups(scenario, entries, plans_by_name, requested) + nodes, directed_links = len(network.nodes), 2 * len(network.links) + group_count = split_count(nodes, directed_links, groups) steps: tuple[tuple[str, int | None, SeedSource], ...] = tuple( ( step_name(step), @@ -491,127 +562,62 @@ def input_refusal(exc) -> NoReturn: for step in scenario.workflow ) declared = (scenario.routes, scenario.supports, scenario.weights) - membership = None - unresolved: dict[float, tuple[TunnelResolutionConstraint, ...]] = {} - - def candidate(q): - nonlocal membership - if membership is None: - membership = MembershipIndex.build(network) - topology = compile_topology(network, q, pools=pools, budgets=budgets(q)) - named = NO_ROUTES - if any(declared): - # Programs load only for scenarios that declare routes or weights. - from ngraph.analysis.programs import compile_named - - named = compile_named( - topology, - routes=scenario.routes, - supports=scenario.supports, - weights=scenario.weights, - ) - setup = None - if scenario.wcmp_controller is not None: - # The controller loads only for scenarios that declare it. - from ngraph.analysis.controller import compile_setup - - setup = compile_setup(topology, scenario) - resolved = { - name: bind_plans(topology, named, name, values, setup) - for name, values in plans_by_name.items() - } - if setup is not None: - _check_controller(topology, setup, resolved, failing, scenario) - observations = input_observations(inputs, q, settings.precision) - report = make_report( - q, - settings, - scope, - inputs, - int(topology.graph.edge_capacity_units().sum()), - int(topology.graph.edge_costs().sum()), - observations=observations, - ) - # The chosen candidate's report holds exactly its own tables' approximations. - report = report.with_approximations( - tuple( - entry - for demands in resolved.values() - for entry in approximations(demands) - ) - ) - prepared = PreparedScenario( - scenario, - topology, - MappingProxyType(resolved), - report, - named, - membership, - owned_inputs, - observations, - steps, - setup, - ) - splits, extra = [], [] - for demands in resolved.values(): - local, other = _nominal_splits(prepared, demands, settings.precision) - splits.extend(local) - extra.extend(other) - for step, name, needs in capacities: - local, other = _capacity_splits( - prepared, step, name, needs, settings.precision - ) - splits.extend(local) - extra.extend(other) - for step, name, needs in counting: - # Count questions load only for scenarios that ask them. - from ngraph.analysis.counts import count_splits - - try: - local, other = count_splits( - prepared, - needs.counts(prepared), - f"workflow.{name}", - topology.disabled, - ) - except (ValueError, TypeError) as exc: - raise _labelled(step, exc, scenario) from exc - splits.extend(local) - extra.extend(other) - report = with_splits(report, tuple(splits), tuple(extra)) - if settings.resolution == "auto": - unresolved[q] = tuple( - c for c in extra if isinstance(c, TunnelResolutionConstraint) - ) - return replace(prepared, units_report=report), report.target_met - try: - prepared, finer_failure = choose_resolution( - settings, - inputs, - coefficients, - (c.q_upper_bound for c in expansion), - candidate, - budget_excess, + q, limited_by, range_limit = choose_resolution( + settings, owned_inputs, coefficients, group_count[3], budget_excess ) except (ValueError, TypeError) as exc: input_refusal(exc) - report = prepared.units_report - # A plan rejected at the next coarser unit explains the automatic choice. - coarser = unresolved.get(report.q * 2, ()) if report.target_met else () - if coarser: - prepared = replace( - prepared, - units_report=replace( - report, binding_constraints=(*report.binding_constraints, *coarser) - ), + membership = MembershipIndex.build(network) + topology = compile_topology(network, q, pools=pools, budgets=budgets(q)) + named = NO_ROUTES + if any(declared): + from ngraph.analysis.programs import compile_named + + named = compile_named( + topology, + routes=scenario.routes, + supports=scenario.supports, + weights=scenario.weights, ) - if finer_failure is not None: - report = prepared.units_report - prepared = replace( - prepared, - units_report=replace( - report, binding_constraints=(*report.binding_constraints, finer_failure) - ), + setup = None + if scenario.wcmp_controller is not None: + from ngraph.analysis.controller import compile_setup + + setup = compile_setup(topology, scenario) + resolved = { + name: bind_plans(topology, named, name, values, setup) + for name, values in plans_by_name.items() + } + if setup is not None: + _check_controller(topology, setup, resolved, failing, scenario) + report = make_report( + q, + settings, + scope, + owned_inputs, + nodes, + directed_links, + group_count, + limited_by, + range_limit, + int(topology.graph.edge_capacity_units().sum()), + int(topology.graph.edge_costs().sum()), + ) + report = report.with_approximations( + tuple( + entry for demands in resolved.values() for entry in approximations(demands) ) + ) + prepared = PreparedScenario( + scenario, + topology, + MappingProxyType(resolved), + report, + named, + membership, + steps, + setup, + ) + _validate_capacity_questions(prepared, capacities, counting) return prepared diff --git a/ngraph/analysis/sensitivity.py b/ngraph/analysis/sensitivity.py index 83bafca..eb1bd2a 100644 --- a/ngraph/analysis/sensitivity.py +++ b/ngraph/analysis/sensitivity.py @@ -15,12 +15,9 @@ from ngraph.analysis.demands import selected_pairs from ngraph.analysis.failures import Workers, checked_workers, resolve_workers from ngraph.analysis.metadata import analysis_metadata -from ngraph.analysis.precision import query_inputs, query_miss from ngraph.model.selectors import Selector from ngraph.results import ( - Endpoints, OutputSelection, - ReportLocation, SensitivityAnalysis, ) from ngraph.utils.values import flag, own, physical @@ -110,34 +107,8 @@ def edge_sensitivity( options, workers=workers, ) - baseline = result.baseline - entries = result.entries - observations: list[ - tuple[Endpoints, int, ngc.Eligibility, str, None, ngc.PlacementResult | None] - ] = [(pair, baseline.delivered, mask, "sensitivity.baseline", None, baseline)] - observations.extend( - ( - pair, - baseline.delivered - entry.delivery_reduction, - ngc.Eligibility.intersect( - mask, topology.graph.eligibility(excluded_edges=[entry.edge]) - ), - f"sensitivity.edge[{entry.edge}]", - None, - None, # The native sensitivity entry exposes a reduction, not its plan. - ) - for entry in entries - ) - miss = query_miss( - prepared, - query, - observations, - ReportLocation(None, "baseline", None), - query_inputs(prepared, query, "sensitivity"), - ) return SensitivityAnalysis( prepared, - () if miss is None else (miss,), analysis_metadata(workers), OutputSelection(query.outputs, False, query.export_details), pair, diff --git a/ngraph/analysis/units.py b/ngraph/analysis/units.py index 926fbfd..0bf7494 100644 --- a/ngraph/analysis/units.py +++ b/ngraph/analysis/units.py @@ -4,22 +4,20 @@ import math from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, replace +from dataclasses import dataclass from fractions import Fraction -from types import MappingProxyType -from typing import Literal, TypeAlias, TypeVar +from typing import Literal, TypeAlias import netgraph_core as ngc +from ngraph.model.demand import _DistributedTEInput, _ParsedModel from ngraph.results import ( InputConstraint, - InputQuantization, - PrecisionMiss, RangeConstraint, + SplitCountConstraint, UnitsReport, - UnverifiedConstraint, ) -from ngraph.utils.values import _located_error, integer, physical +from ngraph.utils.values import _located_error, _shown, integer, physical LIMIT = 1 << 62 _InputKind: TypeAlias = Literal[ @@ -53,11 +51,16 @@ class UnitsSettings: precision: float = 0.001 def __post_init__(self) -> None: - object.__setattr__( - self, - "precision", - physical(self.precision, "units.precision", positive=True), - ) + try: + precision = physical(self.precision, "units.precision", positive=True) + if precision > 0.5: + raise ValueError + except ValueError: + raise ValueError( + "units.precision: expected a number greater than 0 and at most 0.5, " + f"got {_shown(self.precision)}" + ) from None + object.__setattr__(self, "precision", precision) if isinstance(self.resolution, str) and self.resolution == "auto": object.__setattr__(self, "resolution", "auto") else: @@ -106,83 +109,67 @@ def split_units(total: int, members: int) -> tuple[int, ...]: return tuple(quotient + (i < remainder) for i in range(members)) -def input_samples( - inputs: Mapping[str, tuple[_InputKind, float]], q: float -) -> tuple[InputQuantization, ...]: - """Describe all input errors exactly, before truncating report examples.""" - samples = [] - for label, (kind, raw) in sorted(inputs.items()): - value = physical(raw, label) - units = quantize(value, q, label) - exact = Fraction(value) - error = units * Fraction(q) - exact - samples.append( - InputQuantization( - label, - kind, - value, - value.hex(), - units, - error, - error / exact if exact else None, - ) +def question_inputs(offer, design_demand, declaration): + """A capacity question's physical atoms, by field, known before evaluation.""" + values = {} + if offer is not None: + values["offer"] = ("offer", offer) + if design_demand is not None: + values["design_demand"] = ("design", design_demand) + if isinstance(declaration, _ParsedModel) and isinstance( + declaration.routing, _DistributedTEInput + ): + values["min_unreserved"] = ( + "min_unreserved", + declaration.routing.min_unreserved_bandwidth, ) - return tuple(samples) + return values -def input_constraint( - label: str, value: float, q: float, precision: float -) -> InputConstraint | None: - """Positive inputs retain their precision constraint even if rounded to zero.""" +def input_constraint(label, value, q, precision) -> InputConstraint | None: + """The input bound of one positive binary64 amount, even if rounded to zero.""" amount = physical(value, label) - target = physical(precision, "units.precision", positive=True) if not amount: return None - bound = 2 * Fraction(target) * Fraction(amount) + bound = 2 * Fraction(precision) * Fraction(amount) return InputConstraint(label, bound, Fraction(q) <= bound) -def input_observations(inputs, q, precision): - """Own one complete set of exact input samples and constraints per unit.""" - return MappingProxyType( - { - sample.label: ( - sample, - input_constraint(sample.label, sample.binary64_value, q, precision), - ) - for sample in input_samples(inputs, q) - } - ) - - -def _binding(q, constraints): - """Constraints that are unverified, missed, or would miss at twice q.""" - return tuple( - c - for c in constraints - if isinstance(c, UnverifiedConstraint) - or not c.met - or (hasattr(c, "q_upper_bound") and Fraction(q) * 2 > c.q_upper_bound) +def split_count( + nodes: int, directed_links: int, groups: Iterable[tuple[str, int, int]] +): + """Choose the largest static group count; the floor and then first group win ties.""" + floor = Fraction(nodes + directed_links, 4) + 2 + chosen = (None, 0, 0, floor) + for name, demands, tunnels in groups: + count = max(demands, 1) * floor + tunnels + if count > chosen[3]: + chosen = (name, demands, tunnels, count) + return chosen + + +def _minimum(inputs, *, capacities=False): + # min keeps the first equal value, preserving preparation's insertion order. + return min( + ( + (label, value) + for label, (kind, value) in inputs.items() + if value > 0 + and (not capacities or (kind == "capacity" and label.startswith("link."))) + ), + key=lambda atom: atom[1], + default=None, ) -def _met(constraints) -> bool: - return all(not isinstance(c, UnverifiedConstraint) and c.met for c in constraints) - - -def with_splits(report, splits, extra): - """Complete the existing input report without observing its inputs again.""" - constraints = (*splits, *extra) - return replace( - report, - binding_constraints=( - *report.binding_constraints, - *_binding(report.q, constraints), - ), - target_met=report.target_met and _met(constraints), - nominal_split_count=len(splits), - nominal_splits=tuple(splits), +def _bounds(inputs, precision, count): + smallest = _minimum(inputs) + capacity = _minimum(inputs, capacities=True) + input_bound = 2 * Fraction(precision) * Fraction(smallest[1]) if smallest else None + split_bound = ( + Fraction(precision) * Fraction(capacity[1]) / count if capacity else None ) + return smallest, capacity, input_bound, split_bound def make_report( @@ -190,101 +177,71 @@ def make_report( settings, scope, inputs, + nodes, + directed_links, + group_count, + limited_by, + range_limit, capacity_sum, cost_sum, - splits=(), - extra=(), - misses=(), - *, - observations=None, ): - """Build a report from complete observations; examples are only a sample.""" - if observations is None: - observations = input_observations(inputs, q, settings.precision) - samples = tuple(sample for sample, _ in observations.values()) - constraints = tuple(c for _, c in observations.values() if c is not None) - all_constraints = (*constraints, *splits, *extra) - inexact = tuple(s for s in samples if s.error) - zeroed = tuple(s for s in samples if s.binary64_value > 0 and s.units == 0) - positive = tuple(s for s in samples if s.binary64_value > 0) + """Describe the selected unit's input/count budget and range; never observe traffic.""" + group, demands, tunnels, count = group_count + smallest, capacity, _, upper = _bounds(inputs, settings.precision, count) + split_bound = None + if capacity is not None: + assert upper is not None + split_bound = SplitCountConstraint( + capacity[0], + capacity[1], + nodes, + directed_links, + group, + demands, + tunnels, + count, + upper, + Fraction(q) <= upper, + ) + terms = [] + if smallest: + terms.append(Fraction(q) / (2 * Fraction(smallest[1]))) + if capacity: + terms.append(Fraction(q) * count / Fraction(capacity[1])) + achieved = max(terms) if terms else None return UnitsReport( q=q, exponent=math.frexp(q)[1] - 1, mode="auto" if settings.resolution == "auto" else "manual", - precision=float(settings.precision), - target_met=not misses and _met(all_constraints), + precision=settings.precision, + target_met=achieved is None or achieved <= Fraction(settings.precision), scope=scope, - binding_constraints=_binding(q, all_constraints), - input_count=len(samples), - inexact_inputs=len(inexact), - zeroed_inputs=len(zeroed), - inexact_examples=inexact[:16], - zeroed_examples=zeroed[:16], - max_absolute_input_error=max( - (abs(s.error) for s in positive), default=Fraction(0) - ), - max_relative_input_error=max( - (abs(s.relative_error) for s in positive if s.relative_error is not None), - default=Fraction(0), - ), + limited_by=limited_by, + achieved_precision=achieved, + input_bound=input_constraint(*smallest, q, settings.precision) + if smallest + else None, + split_bound=split_bound, + range_limit=range_limit, augmented_capacity_sum_units=capacity_sum, augmented_cost_sum=cost_sum, - nominal_split_count=len(splits), - nominal_splits=tuple(splits), - misses=tuple(misses), - ) - - -def precision_miss(location, samples, constraints) -> PrecisionMiss | None: - """One observation's missed local targets, or None when all are met.""" - missed = tuple( - c for c in constraints if isinstance(c, UnverifiedConstraint) or not c.met ) - if not missed: - return None - inexact = tuple(s for s in samples if s.error) - zeroed = tuple(s for s in samples if s.binary64_value > 0 and s.units == 0) - return PrecisionMiss(location, missed, len(inexact), len(zeroed), inexact[:16]) - - -Candidate = TypeVar("Candidate") def choose_resolution( settings: UnitsSettings, inputs: Mapping[str, tuple[_InputKind, float]], coefficients: Mapping[str, int], - split_bounds: Iterable[Fraction], - candidate: Callable[[float], tuple[Candidate, bool]], + count: Fraction, extra: Callable[[float], int] | None = None, -) -> tuple[Candidate, RangeConstraint | None]: - """Choose q by the fixed recipe and return the chosen candidate. - - ``coefficients`` counts each link capacity's directed copies in the - augmented graph; ``split_bounds`` are the declared expansion bounds, which - like the input bounds do not depend on q. ``candidate(q)`` prepares one - resolution and says whether it meets the target; ``extra(q)`` adds the - capacity that pool offer budgets add above their sources' outgoing - capacity. Automatic choice takes the - coarsest range-valid q within the declared bounds that meets the target, - else the finest range-valid one. The second value is the range failure of - the next finer q, which the caller reports as binding. +): + """Select the largest power below both static bounds, then coarsen only for range. + + ``coefficients`` counts each capacity's copies in the augmented graph; + ``extra`` adds pool offer budgets above the sources' outgoing capacity. + No graph or traffic candidate is constructed. """ failures = {} - candidates = ( - RESOLUTIONS - if settings.resolution == "auto" - else (validate_resolution(settings.resolution),) - ) - declared_limit = None - if settings.resolution == "auto": - input_bounds = ( - c.q_upper_bound - for label, (_, value) in inputs.items() - if (c := input_constraint(label, value, 1.0, settings.precision)) - is not None - ) - declared_limit = min((*input_bounds, *split_bounds), default=None) def range_valid(q): try: @@ -320,35 +277,34 @@ def range_valid(q): return False return True - valid, skipped = [], [] - last = None - for q in candidates: - if declared_limit is not None and Fraction(q) > declared_limit: - skipped.append(q) - continue - if not range_valid(q): - continue - valid.append(q) - value, met = candidate(q) - last = (q, value) - if settings.resolution != "auto" or met: - return value, None - if not valid: - # Precision may conflict with range. Find the finest range-valid excluded - # candidate, without doing its input conversions on the normal path. - for q in reversed(skipped): - if range_valid(q): - valid.append(q) - break - if not valid: - detail = failures[max(failures)] - reason = ( - "no legal resolution represents the complete prepared input/augmented graph" - ) - error = ValueError(f"{detail.label}: {reason}") - if detail.kind == "amount": - _located_error(error, detail.label, inputs[detail.label][1]) - raise error - q = valid[-1] - value = last[1] if last is not None and last[0] == q else candidate(q)[0] - return value, failures.get(q / 2) + if settings.resolution != "auto": + q = validate_resolution(settings.resolution) + limited_by = "declared" + else: + _, _, input_bound, split_bound = _bounds(inputs, settings.precision, count) + bounds = [bound for bound in (input_bound, split_bound) if bound is not None] + if not bounds: + q, limited_by = RESOLUTIONS[0], "unbounded" + else: + bound = min(bounds) + q = next((q for q in RESOLUTIONS if Fraction(q) <= bound), RESOLUTIONS[-1]) + if Fraction(q) > bound: + limited_by = "resolution_floor" + else: + limited_by = ( + "input" + if input_bound is not None and input_bound == bound + else "split_count" + ) + while not range_valid(q): + if settings.resolution != "auto" or q == RESOLUTIONS[0]: + detail = failures[q] + error = ValueError( + f"{detail.label}: no legal resolution represents the complete prepared input/augmented graph" + ) + if detail.kind == "amount": + _located_error(error, detail.label, inputs[detail.label][1]) + raise error + q *= 2 + limited_by = "range" + return q, limited_by, failures.get(q / 2) if limited_by == "range" else None diff --git a/ngraph/analysis/validation.py b/ngraph/analysis/validation.py index db01de2..6a032ea 100644 --- a/ngraph/analysis/validation.py +++ b/ngraph/analysis/validation.py @@ -1,9 +1,9 @@ -"""Graph-dependent model checks independent of selected traffic pairs.""" +"""Model checks on the prepared graph, with no eligible traffic and no solve.""" import netgraph_core as ngc -def validate_model(topology, config, options=None): +def validate_model(topology, config, options=None, *, pairs=()): """Check one materialized model with no eligible traffic and no solve.""" graph = topology.graph nothing = graph.eligibility(excluded_nodes=range(graph.num_nodes())) @@ -24,4 +24,4 @@ def validate_model(topology, config, options=None): program = config.routing.program ngc.evaluate_demand(graph, program.sources, program.destinations, checked) else: - ngc.evaluate_pairs(graph, (), checked, workers=1) + ngc.evaluate_pairs(graph, pairs, checked, workers=1) diff --git a/ngraph/export.py b/ngraph/export.py index f2c607a..47e8598 100644 --- a/ngraph/export.py +++ b/ngraph/export.py @@ -44,7 +44,6 @@ ForwardingFailure, HardwareRow, InputConstraint, - InputQuantization, MarginalCostBin, MarginalCurve, MemberWeight, @@ -59,25 +58,21 @@ PlacementTotals, PoolHead, PoolMetadata, - PrecisionMiss, PreparationScope, ProgrammedRouter, RangeConstraint, RangeRefusal, - ReportLocation, RunMetadata, RunResult, SamplingMetadata, SelfPair, SensitivityAnalysis, SourceWeight, - SplitConstraint, + SplitCountConstraint, SyntheticIngress, TableReport, TunnelCountAnalysis, - TunnelResolutionConstraint, UnitsReport, - UnverifiedConstraint, UpperCertificate, WeightApproximation, exact_alpha, @@ -115,7 +110,7 @@ ForwardingFailure, HardwareRow, InputConstraint, - InputQuantization, + SplitCountConstraint, MarginalCostBin, MarginalCurve, MemberWeight, @@ -126,22 +121,17 @@ PlacementTotals, PoolHead, PoolMetadata, - PrecisionMiss, PreparationScope, ProgrammedRouter, RangeConstraint, RangeRefusal, - ReportLocation, RunMetadata, SamplingMetadata, SelfPair, SourceWeight, - SplitConstraint, - TunnelResolutionConstraint, SyntheticIngress, TableReport, UnitsReport, - UnverifiedConstraint, UpperCertificate, WeightApproximation, ) diff --git a/ngraph/results.py b/ngraph/results.py index d14c42c..0d8d55b 100644 --- a/ngraph/results.py +++ b/ngraph/results.py @@ -397,22 +397,6 @@ class FailureCase(_ResultRecord): trace: FailureTrace | None -@_record -class ReportLocation(_ResultRecord): - """Where a precision miss happened: the step, the failure case and the MSD probe, - where they apply. - """ - - step: str | None = field(metadata={"null_when": "the report is outside a workflow"}) - """The step.""" - case_id: str | None = field( - metadata={"null_when": "the report is outside failure cases"} - ) - """The case.""" - probe_index: int | None = field(metadata={"null_when": "the report is outside MSD"}) - """The MSD probe.""" - - @_record class PreparationScope(_ResultRecord): """What a preparation covered: its workflow steps, and the demand sets bound for the @@ -427,28 +411,6 @@ class PreparationScope(_ResultRecord): """The demand sets the library caller named.""" -@_record -class InputQuantization(_ResultRecord): - """One physical input converted to units: its binary64 value, its units and the - rounding error. - """ - - label: str - """The input or constraint label: where the value is declared.""" - kind: Literal["capacity", "demand", "offer", "design", "min_unreserved"] - """What the input is: a capacity, demand, offer, design or minimum unreserved bandwidth.""" - binary64_value: float - """The physical input as given.""" - binary64_hex: str - """The physical input in hexadecimal.""" - units: int - """Its value in units (integer units of q).""" - error: Fraction - """The signed rounding error, units times q minus the input (physical, a fraction): positive when rounded up.""" - relative_error: Fraction | None = field(metadata={"null_when": "the input is zero"}) - """The signed relative error, the error divided by the input.""" - - @_record class InputConstraint(_ResultRecord): """The input bound of one positive input v at precision p (q at most 2pv), and @@ -465,44 +427,6 @@ class InputConstraint(_ResultRecord): """Whether the chosen q meets it.""" -@_record -class SplitConstraint(_ResultRecord): - """The split bound of a positive amount v over m members at precision p (q at most - pv/(m - 1)), and whether q meets it. - """ - - kind: Literal["expansion", "source", "router", "tunnel"] - """Where the split is: expansion, sources, a router or tunnels.""" - label: str - """The input or constraint label: where the value is declared.""" - incoming_physical: Fraction - """The physical amount split (a fraction).""" - basis: Literal["declared", "observed"] - """declared (from the demands) or observed (from an analysis).""" - members: int = field(metadata={"minimum": 2}) - """The members the amount is split over.""" - q_upper_bound: Fraction - """The q bound this split records: the precision times the incoming amount divided by (members - 1). It bounds q, not the split's own error.""" - met: bool - """Whether the chosen q meets it.""" - - -@_record -class TunnelResolutionConstraint(_ResultRecord): - """A native tunnel plan whose live heads cannot receive a whole unit per tunnel.""" - - kind: ClassVar[Literal["tunnel_resolution"]] = "tunnel_resolution" - """The record kind, a fixed tag.""" - label: str - """The demand or question whose native plan was observed.""" - q: float - """The bandwidth unit at which the plan was observed.""" - heads: tuple[str | PoolHead, ...] = field(metadata={"minItems": 1}) - """Every live head, in native node order: a physical node name or a structured TE ingress pool head.""" - met: ClassVar[Literal[False]] = False - """Always false: the observed plan cannot give its live heads whole-unit tunnels.""" - - @_record class RangeConstraint(_ResultRecord): """A range limit: a value in units against its exclusive limit 2^62 at the tested q.""" @@ -521,52 +445,6 @@ class RangeConstraint(_ResultRecord): """Whether the value is below the limit.""" -@_record -class UnverifiedConstraint(_ResultRecord): - """A supplied program whose splits cannot be checked against the precision target.""" - - kind: ClassVar[Literal["unverified"]] = "unverified" - """The record kind, a fixed tag.""" - label: str - """The input or constraint label: where the value is declared.""" - reason: ClassVar[Literal["opaque_program"]] = "opaque_program" - """The split is inside a supplied native program NetGraph cannot inspect.""" - - -UnitConstraint: TypeAlias = ( - InputConstraint - | SplitConstraint - | TunnelResolutionConstraint - | RangeConstraint - | UnverifiedConstraint -) -"""One precision or range constraint of a units report.""" - - -@_record -class PrecisionMiss(_ResultRecord): - """One place an analysis missed the precision target: its location, the constraints - it missed and its input rounding. - """ - - location: ReportLocation - """Where the miss was observed: step, case and probe.""" - constraints: tuple[ - InputConstraint - | SplitConstraint - | TunnelResolutionConstraint - | UnverifiedConstraint, - ..., - ] - """The constraints the chosen q does not meet there.""" - inexact_inputs: int - """Inputs not exact at q there.""" - zeroed_inputs: int - """Positive inputs that round to zero units there.""" - examples: tuple[InputQuantization, ...] - """Example inputs that miss.""" - - @_record class WeightApproximation(_ResultRecord): """Controller weights whose exact values reached 2^62 and were apportioned. @@ -586,22 +464,48 @@ class WeightApproximation(_ResultRecord): """The largest relative error of the apportioned weights (a fraction).""" +@_record +class SplitCountConstraint(_ResultRecord): + """The prepared split-count bound, not a certificate for arbitrary answers.""" + + kind: ClassVar[Literal["split_count"]] = "split_count" + """The record kind, a fixed tag.""" + capacity_label: str + """The first smallest positive link capacity in link insertion order.""" + capacity: float + """The smallest positive physical link capacity, including disabled links.""" + nodes: int + """The physical network's node count, including disabled nodes.""" + directed_links: int + """Twice the physical link count, including disabled links and excluding pool attachments.""" + group: str | None = field(metadata={"null_when": "the floor sets the count"}) + """The demand set or workflow. that sets the count; the floor wins ties.""" + demands: int + """Lossless shared equal-split leaves in the winning group, or zero for the floor.""" + tunnels: int + """Heads times declared tunnels per head in the winning group, or zero for the floor.""" + count: Fraction + """The maximum of the floor L/4 + 2 and max(k, 1) times that floor plus t over prepared groups.""" + q_upper_bound: Fraction + """The split-count upper bound on q: precision times the smallest positive capacity divided by the count.""" + met: bool + """Whether the prepared q meets this bound; the delivery theorem has additional premises.""" + + @_record class UnitsReport(_ResultRecord): - """How preparation chose q: the mode and target, the bounds that set q, input - rounding, the range sums and every precision miss. - """ + """The prepared unit, its input/count bounds and augmented graph range sums.""" q: float """The bandwidth unit: one unit is q in physical terms.""" exponent: int """q as a power of two: q = 2^exponent.""" mode: Literal["auto", "manual"] - """auto (chosen by the recipe) or manual (declared).""" + """auto (chosen from static bounds) or manual (declared).""" precision: float - """The relative precision target.""" + """The target input/count budget, greater than zero and at most 0.5.""" target_met: bool - """Whether every input and split meets its precision bound at q and every native tunnel plan observed at q has sufficient resolution for its live heads. Bounds are checked, not the rounding: false is possible with no input rounded.""" + """Whether both present input/count bounds hold at q; this does not certify arbitrary analysis answers.""" input_convention: ClassVar[Literal["promoted_binary64_half_up"]] = ( "promoted_binary64_half_up" ) @@ -612,47 +516,39 @@ class UnitsReport(_ResultRecord): """Units are authoritative; physical values are binary64 displays.""" scope: PreparationScope """What the preparation covered.""" - binding_constraints: tuple[UnitConstraint, ...] - """The constraints that limited q, including observed tunnel plans with insufficient resolution for their live heads: at q, or at 2q when automatic preparation rejected that coarser candidate and meets the target at q. Each tunnel record's q identifies its observation.""" - input_count: int - """Physical inputs converted to units.""" - inexact_inputs: int - """Inputs not exact at q.""" - zeroed_inputs: int - """Positive inputs that round to zero units.""" - inexact_examples: tuple[InputQuantization, ...] - """Example inexact inputs.""" - zeroed_examples: tuple[InputQuantization, ...] - """Example zeroed inputs.""" - max_absolute_input_error: Fraction - """The largest absolute input error (physical, a fraction).""" - max_relative_input_error: Fraction - """The largest absolute relative input error (a fraction).""" + limited_by: Literal[ + "input", "split_count", "range", "resolution_floor", "declared", "unbounded" + ] + """Which bound selected q; input wins an equal input/split bound.""" + achieved_precision: Fraction | None = field( + metadata={"null_when": "there is no positive input or capacity"} + ) + """The maximum of q/(2*v_min) and q*count/c_min over present denominators; an input/count budget, not a general answer guarantee.""" + input_bound: InputConstraint | None = field( + metadata={"null_when": "there is no positive prepared bandwidth input"} + ) + """The smallest positive input's bound, with ties in preparation insertion order.""" + split_bound: SplitCountConstraint | None = field( + metadata={"null_when": "there is no positive link capacity"} + ) + """The prepared split-count bound, subject to the documented delivery theorem's premises.""" + range_limit: RangeConstraint | None = field( + metadata={"null_when": "limited_by is not range"} + ) + """The range failure at the next finer q when range forced a coarser unit.""" augmented_capacity_sum_units: int """The graph's capacity sum with pool attachments (integer units of q).""" augmented_cost_sum: int """The graph's cost sum with pool attachments.""" - nominal_split_count: int - """Splits of the demands at alpha 1 without failures.""" - nominal_splits: tuple[SplitConstraint, ...] - """Those splits with their bounds on q.""" - misses: tuple[PrecisionMiss, ...] - """Precision misses observed in analyses.""" weight_approximations: tuple[WeightApproximation, ...] = () """Controller weights apportioned because they reached 2^62.""" - def with_misses(self, misses: tuple[PrecisionMiss, ...]) -> UnitsReport: - """This report with further precision misses appended.""" - if not misses: - return self - return replace(self, target_met=False, misses=(*self.misses, *misses)) - def with_approximations( self, entries: tuple[WeightApproximation, ...] ) -> UnitsReport: """This report with the controller's weight approximations, each once. - They change neither ``target_met`` nor the binding constraints. + They change neither ``target_met`` nor the input/count bounds. """ known = {entry.label for entry in self.weight_approximations} new = tuple(entry for entry in entries if entry.label not in known) @@ -904,15 +800,13 @@ class CaseResult(_ResultRecord, Generic[RowT]): class _PreparedResult(_ResultRecord): - """An analysis of one prepared scenario, with its own precision misses. + """An analysis using the preparation's unit and report. - Each analysis record declares ``prepared`` and ``misses`` first; - ``units_report`` is the preparation's report with these misses appended. + A later-bound demand set adds its controller weight approximations. """ __slots__ = () prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] @property def topology(self) -> PreparedTopology: @@ -920,7 +814,7 @@ def topology(self) -> PreparedTopology: @property def units_report(self) -> UnitsReport: - report = self.prepared.units_report.with_misses(self.misses) + report = self.prepared.units_report # A demand set bound after preparation brings its own approximations. demands = getattr(getattr(self, "query", None), "demands", None) if demands is None: @@ -934,7 +828,6 @@ class CapacityAnalysis(_PreparedResult): kind: ClassVar[Literal["capacity"]] = "capacity" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata outputs: OutputSelection question: Literal["lossless_bound", "fixed_offer_delivery", "first_hop_experiment"] @@ -951,7 +844,6 @@ class PlacementAnalysis(_PreparedResult): kind: ClassVar[Literal["placement"]] = "placement" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata outputs: OutputSelection alpha: ExactAlpha @@ -968,7 +860,6 @@ class SensitivityAnalysis(_PreparedResult): kind: ClassVar[Literal["sensitivity"]] = "sensitivity" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata outputs: OutputSelection endpoints: Endpoints @@ -995,7 +886,6 @@ class PathAnalysis(_PreparedResult): kind: ClassVar[Literal["paths"]] = "paths" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata algorithm: Literal["spf", "ksp"] mode: Literal["combine", "pairwise"] @@ -1096,7 +986,6 @@ class MSDAnalysis(_PreparedResult): kind: ClassVar[Literal["msd"]] = "msd" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata outputs: OutputSelection alpha_feasible: ExactAlpha | None @@ -1142,7 +1031,6 @@ class TunnelCountAnalysis(_PreparedResult): kind: ClassVar[Literal["tunnel_counts"]] = "tunnel_counts" prepared: PreparedScenario - misses: tuple[PrecisionMiss, ...] metadata: AnalysisMetadata outputs: OutputSelection rows: tuple[TunnelCountRow, ...] @@ -1354,7 +1242,7 @@ class RunMetadata(_ResultRecord): @_record class RunResult(_ResultRecord): - """One run; ``units_report`` adds every step's precision misses, by step.""" + """One run with the preparation's units report.""" metadata: RunMetadata units_report: UnitsReport diff --git a/ngraph/scenario.py b/ngraph/scenario.py index af2143a..4dd5816 100644 --- a/ngraph/scenario.py +++ b/ngraph/scenario.py @@ -319,7 +319,7 @@ def run( raise TypeError("run: expected PreparedScenario") scenario = prepared.scenario completed = {} - results, misses = [], [] + results = [] for order, (step, (name, seed, seed_source)) in enumerate( zip(scenario.workflow, prepared._steps, strict=True) ): @@ -346,15 +346,11 @@ def run( raise ValueError( f"step {name}: analysis units/topology do not belong to this preparation" ) - misses.extend( - replace(miss, location=replace(miss.location, step=name)) - for miss in value.misses - ) completed[name] = result results.append(result) return RunResult( run_metadata(scenario.seed), - prepared.units_report.with_misses(tuple(misses)), + prepared.units_report, tuple(results), prepared, ) diff --git a/ngraph/schemas/results.json b/ngraph/schemas/results.json index 44049e1..fc00359 100644 --- a/ngraph/schemas/results.json +++ b/ngraph/schemas/results.json @@ -1469,62 +1469,6 @@ ], "additionalProperties": false }, - "InputQuantizationJSON": { - "type": "object", - "properties": { - "label": { - "description": "The input or constraint label: where the value is declared.", - "type": "string" - }, - "kind": { - "description": "What the input is: a capacity, demand, offer, design or minimum unreserved bandwidth.", - "enum": [ - "capacity", - "demand", - "offer", - "design", - "min_unreserved" - ] - }, - "binary64_value": { - "description": "The physical input as given.", - "type": "number" - }, - "binary64_hex": { - "description": "The physical input in hexadecimal.", - "type": "string" - }, - "units": { - "description": "Its value in units (integer units of q).", - "type": "integer" - }, - "error": { - "description": "The signed rounding error, units times q minus the input (physical, a fraction): positive when rounded up.", - "$ref": "#/$defs/FractionJSON" - }, - "relative_error": { - "description": "The signed relative error, the error divided by the input. Null when the input is zero.", - "anyOf": [ - { - "$ref": "#/$defs/FractionJSON" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "label", - "kind", - "binary64_value", - "binary64_hex", - "units", - "error", - "relative_error" - ], - "additionalProperties": false - }, "JSONValue": { "anyOf": [ { @@ -4066,58 +4010,6 @@ ], "additionalProperties": false }, - "PrecisionMissJSON": { - "type": "object", - "properties": { - "location": { - "description": "Where the miss was observed: step, case and probe.", - "$ref": "#/$defs/ReportLocationJSON" - }, - "constraints": { - "description": "The constraints the chosen q does not meet there.", - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/$defs/InputConstraintJSON" - }, - { - "$ref": "#/$defs/SplitConstraintJSON" - }, - { - "$ref": "#/$defs/TunnelResolutionConstraintJSON" - }, - { - "$ref": "#/$defs/UnverifiedConstraintJSON" - } - ] - } - }, - "inexact_inputs": { - "description": "Inputs not exact at q there.", - "type": "integer" - }, - "zeroed_inputs": { - "description": "Positive inputs that round to zero units there.", - "type": "integer" - }, - "examples": { - "description": "Example inputs that miss.", - "type": "array", - "items": { - "$ref": "#/$defs/InputQuantizationJSON" - } - } - }, - "required": [ - "location", - "constraints", - "inexact_inputs", - "zeroed_inputs", - "examples" - ], - "additionalProperties": false - }, "PreparationScopeJSON": { "type": "object", "properties": { @@ -4272,50 +4164,6 @@ ], "additionalProperties": false }, - "ReportLocationJSON": { - "type": "object", - "properties": { - "step": { - "description": "The step. Null when the report is outside a workflow.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "case_id": { - "description": "The case. Null when the report is outside failure cases.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "probe_index": { - "description": "The MSD probe. Null when the report is outside MSD.", - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "step", - "case_id", - "probe_index" - ], - "additionalProperties": false - }, "RunJSON": { "type": "object", "properties": { @@ -4331,7 +4179,7 @@ } }, "units_report": { - "description": "The units report of the preparation: q, its reasons, rounding and misses.", + "description": "The units report of the preparation: q, its input/count bounds and range sums.", "$ref": "#/$defs/UnitsReportJSON" } }, @@ -4918,53 +4766,73 @@ ], "additionalProperties": false }, - "SplitConstraintJSON": { + "SplitCountConstraintJSON": { "type": "object", "properties": { "kind": { - "description": "Where the split is: expansion, sources, a router or tunnels.", + "description": "The record kind, a fixed tag.", "enum": [ - "expansion", - "source", - "router", - "tunnel" + "split_count" ] }, - "label": { - "description": "The input or constraint label: where the value is declared.", + "capacity_label": { + "description": "The first smallest positive link capacity in link insertion order.", "type": "string" }, - "incoming_physical": { - "description": "The physical amount split (a fraction).", - "$ref": "#/$defs/FractionJSON" + "capacity": { + "description": "The smallest positive physical link capacity, including disabled links.", + "type": "number" }, - "basis": { - "description": "declared (from the demands) or observed (from an analysis).", - "enum": [ - "declared", - "observed" + "nodes": { + "description": "The physical network's node count, including disabled nodes.", + "type": "integer" + }, + "directed_links": { + "description": "Twice the physical link count, including disabled links and excluding pool attachments.", + "type": "integer" + }, + "group": { + "description": "The demand set or workflow. that sets the count; the floor wins ties. Null when the floor sets the count.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ] }, - "members": { - "description": "The members the amount is split over.", - "type": "integer", - "minimum": 2 + "demands": { + "description": "Lossless shared equal-split leaves in the winning group, or zero for the floor.", + "type": "integer" + }, + "tunnels": { + "description": "Heads times declared tunnels per head in the winning group, or zero for the floor.", + "type": "integer" + }, + "count": { + "description": "The maximum of the floor L/4 + 2 and max(k, 1) times that floor plus t over prepared groups.", + "$ref": "#/$defs/FractionJSON" }, "q_upper_bound": { - "description": "The q bound this split records: the precision times the incoming amount divided by (members - 1). It bounds q, not the split's own error.", + "description": "The split-count upper bound on q: precision times the smallest positive capacity divided by the count.", "$ref": "#/$defs/FractionJSON" }, "met": { - "description": "Whether the chosen q meets it.", + "description": "Whether the prepared q meets this bound; the delivery theorem has additional premises.", "type": "boolean" } }, "required": [ "kind", - "label", - "incoming_physical", - "basis", - "members", + "capacity_label", + "capacity", + "nodes", + "directed_links", + "group", + "demands", + "tunnels", + "count", "q_upper_bound", "met" ], @@ -4991,7 +4859,7 @@ ] }, "units_report": { - "description": "The units report of the preparation: q, its reasons, rounding and misses.", + "description": "The units report of the preparation: q, its input/count bounds and range sums.", "$ref": "#/$defs/UnitsReportJSON" } }, @@ -6158,54 +6026,6 @@ ], "additionalProperties": false }, - "TunnelResolutionConstraintJSON": { - "type": "object", - "properties": { - "kind": { - "description": "The record kind, a fixed tag.", - "enum": [ - "tunnel_resolution" - ] - }, - "label": { - "description": "The demand or question whose native plan was observed.", - "type": "string" - }, - "q": { - "description": "The bandwidth unit at which the plan was observed.", - "type": "number" - }, - "heads": { - "description": "Every live head, in native node order: a physical node name or a structured TE ingress pool head.", - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/$defs/PoolHeadJSON" - } - ] - }, - "minItems": 1 - }, - "met": { - "description": "Always false: the observed plan cannot give its live heads whole-unit tunnels.", - "enum": [ - false - ] - } - }, - "required": [ - "kind", - "label", - "q", - "heads", - "met" - ], - "additionalProperties": false - }, "UnavailableOutputsJSON": { "type": "object", "properties": { @@ -6217,25 +6037,6 @@ "required": [], "additionalProperties": false }, - "UnitConstraintJSON": { - "anyOf": [ - { - "$ref": "#/$defs/InputConstraintJSON" - }, - { - "$ref": "#/$defs/SplitConstraintJSON" - }, - { - "$ref": "#/$defs/TunnelResolutionConstraintJSON" - }, - { - "$ref": "#/$defs/RangeConstraintJSON" - }, - { - "$ref": "#/$defs/UnverifiedConstraintJSON" - } - ] - }, "UnitsReportJSON": { "type": "object", "properties": { @@ -6248,18 +6049,18 @@ "type": "integer" }, "mode": { - "description": "auto (chosen by the recipe) or manual (declared).", + "description": "auto (chosen from static bounds) or manual (declared).", "enum": [ "auto", "manual" ] }, "precision": { - "description": "The relative precision target.", + "description": "The target input/count budget, greater than zero and at most 0.5.", "type": "number" }, "target_met": { - "description": "Whether every input and split meets its precision bound at q and every native tunnel plan observed at q has sufficient resolution for its live heads. Bounds are checked, not the rounding: false is possible with no input rounded.", + "description": "Whether both present input/count bounds hold at q; this does not certify arbitrary analysis answers.", "type": "boolean" }, "input_convention": { @@ -6278,46 +6079,60 @@ "description": "What the preparation covered.", "$ref": "#/$defs/PreparationScopeJSON" }, - "binding_constraints": { - "description": "The constraints that limited q, including observed tunnel plans with insufficient resolution for their live heads: at q, or at 2q when automatic preparation rejected that coarser candidate and meets the target at q. Each tunnel record's q identifies its observation.", - "type": "array", - "items": { - "$ref": "#/$defs/UnitConstraintJSON" - } - }, - "input_count": { - "description": "Physical inputs converted to units.", - "type": "integer" - }, - "inexact_inputs": { - "description": "Inputs not exact at q.", - "type": "integer" - }, - "zeroed_inputs": { - "description": "Positive inputs that round to zero units.", - "type": "integer" + "limited_by": { + "description": "Which bound selected q; input wins an equal input/split bound.", + "enum": [ + "input", + "split_count", + "range", + "resolution_floor", + "declared", + "unbounded" + ] }, - "inexact_examples": { - "description": "Example inexact inputs.", - "type": "array", - "items": { - "$ref": "#/$defs/InputQuantizationJSON" - } + "achieved_precision": { + "description": "The maximum of q/(2*v_min) and q*count/c_min over present denominators; an input/count budget, not a general answer guarantee. Null when there is no positive input or capacity.", + "anyOf": [ + { + "$ref": "#/$defs/FractionJSON" + }, + { + "type": "null" + } + ] }, - "zeroed_examples": { - "description": "Example zeroed inputs.", - "type": "array", - "items": { - "$ref": "#/$defs/InputQuantizationJSON" - } + "input_bound": { + "description": "The smallest positive input's bound, with ties in preparation insertion order. Null when there is no positive prepared bandwidth input.", + "anyOf": [ + { + "$ref": "#/$defs/InputConstraintJSON" + }, + { + "type": "null" + } + ] }, - "max_absolute_input_error": { - "description": "The largest absolute input error (physical, a fraction).", - "$ref": "#/$defs/FractionJSON" + "split_bound": { + "description": "The prepared split-count bound, subject to the documented delivery theorem's premises. Null when there is no positive link capacity.", + "anyOf": [ + { + "$ref": "#/$defs/SplitCountConstraintJSON" + }, + { + "type": "null" + } + ] }, - "max_relative_input_error": { - "description": "The largest absolute relative input error (a fraction).", - "$ref": "#/$defs/FractionJSON" + "range_limit": { + "description": "The range failure at the next finer q when range forced a coarser unit. Null when limited_by is not range.", + "anyOf": [ + { + "$ref": "#/$defs/RangeConstraintJSON" + }, + { + "type": "null" + } + ] }, "augmented_capacity_sum_units": { "description": "The graph's capacity sum with pool attachments (integer units of q).", @@ -6327,24 +6142,6 @@ "description": "The graph's cost sum with pool attachments.", "type": "integer" }, - "nominal_split_count": { - "description": "Splits of the demands at alpha 1 without failures.", - "type": "integer" - }, - "nominal_splits": { - "description": "Those splits with their bounds on q.", - "type": "array", - "items": { - "$ref": "#/$defs/SplitConstraintJSON" - } - }, - "misses": { - "description": "Precision misses observed in analyses.", - "type": "array", - "items": { - "$ref": "#/$defs/PrecisionMissJSON" - } - }, "weight_approximations": { "description": "Controller weights apportioned because they reached 2^62.", "type": "array", @@ -6362,50 +6159,17 @@ "input_convention", "display_convention", "scope", - "binding_constraints", - "input_count", - "inexact_inputs", - "zeroed_inputs", - "inexact_examples", - "zeroed_examples", - "max_absolute_input_error", - "max_relative_input_error", + "limited_by", + "achieved_precision", + "input_bound", + "split_bound", + "range_limit", "augmented_capacity_sum_units", "augmented_cost_sum", - "nominal_split_count", - "nominal_splits", - "misses", "weight_approximations" ], "additionalProperties": false }, - "UnverifiedConstraintJSON": { - "type": "object", - "properties": { - "kind": { - "description": "The record kind, a fixed tag.", - "enum": [ - "unverified" - ] - }, - "label": { - "description": "The input or constraint label: where the value is declared.", - "type": "string" - }, - "reason": { - "description": "The split is inside a supplied native program NetGraph cannot inspect.", - "enum": [ - "opaque_program" - ] - } - }, - "required": [ - "kind", - "label", - "reason" - ], - "additionalProperties": false - }, "UpperCertificateJSON": { "type": "object", "properties": { diff --git a/ngraph/schemas/scenario.json b/ngraph/schemas/scenario.json index 7fae84b..09b2fa5 100644 --- a/ngraph/schemas/scenario.json +++ b/ngraph/schemas/scenario.json @@ -2107,6 +2107,7 @@ }, "precision": { "type": "number", + "maximum": 0.5, "exclusiveMinimum": 0 } }, diff --git a/ngraph/workflow/max_flow_step.py b/ngraph/workflow/max_flow_step.py index 1c78361..d2cae99 100644 --- a/ngraph/workflow/max_flow_step.py +++ b/ngraph/workflow/max_flow_step.py @@ -9,8 +9,8 @@ from ngraph.analysis.capacity import CapacityQuery, capacity from ngraph.analysis.demands import materialize_config, pooled, selected_pairs from ngraph.analysis.failures import FailureRequest, Workers, checked_workers -from ngraph.analysis.precision import question_inputs from ngraph.analysis.preparation import PreparedScenario, StepNeeds +from ngraph.analysis.units import question_inputs from ngraph.model.demand import FlowPolicyPreset, PinnedRoutes, _ParsedModel from ngraph.model.selectors import Selector from ngraph.results import StepContext, StepOutput diff --git a/ngraph/workflow/tunnel_count_step.py b/ngraph/workflow/tunnel_count_step.py index 5ffb571..9bf9b79 100644 --- a/ngraph/workflow/tunnel_count_step.py +++ b/ngraph/workflow/tunnel_count_step.py @@ -7,8 +7,8 @@ from ngraph import results from ngraph.analysis.counts import TunnelCountQuery, tunnel_counts from ngraph.analysis.failures import Workers -from ngraph.analysis.precision import question_inputs from ngraph.analysis.preparation import PreparedScenario, StepNeeds +from ngraph.analysis.units import question_inputs from ngraph.utils.values import _located_error, flag, own, shared_copy, text from ngraph.workflow.base import ( WorkflowStep, diff --git a/tests/analysis/test_capacity_pools.py b/tests/analysis/test_capacity_pools.py index e0818b1..69332a0 100644 --- a/tests/analysis/test_capacity_pools.py +++ b/tests/analysis/test_capacity_pools.py @@ -57,10 +57,6 @@ def test_a_te_preset_over_combined_sources_asks_its_private_pool(): # Preparation observes one head (node 3, after a1, a2 and t) splitting # the delivered 16 units over its 16 tunnels; no source split. assert prepared.topology.pool_heads == {"workflow.m.pair[0]": 3} - assert [ - (c.kind, c.label, c.incoming_physical, c.members, c.basis) - for c in prepared.units_report.nominal_splits - ] == [("tunnel", "workflow.m.pair[0].head[3]", 16, 16, "observed")] def test_a_pooled_capacity_row_exports_its_pool_and_cost_domain(): diff --git a/tests/analysis/test_msd_search.py b/tests/analysis/test_msd_search.py index 5ab3d9e..5650524 100644 --- a/tests/analysis/test_msd_search.py +++ b/tests/analysis/test_msd_search.py @@ -186,9 +186,7 @@ def refuse_second(values, alpha, q): assert result.best_rows is result.best_accounting is None -@pytest.mark.parametrize( - "boundary", ["MSDProbe", "placement_miss", "analysis_metadata"] -) +@pytest.mark.parametrize("boundary", ["MSDProbe", "analysis_metadata"]) def test_python_result_failure_releases_every_policy_and_retry_succeeds( monkeypatch, boundary ): diff --git a/tests/analysis/test_msd_threshold.py b/tests/analysis/test_msd_threshold.py index 513e124..7cc45f1 100644 --- a/tests/analysis/test_msd_threshold.py +++ b/tests/analysis/test_msd_threshold.py @@ -43,7 +43,7 @@ def units(volume, alpha, q): @pytest.mark.parametrize( - "small,capacity,q,control", [(100, 7, 1, 0.006), (0.99, 1.01, "auto", 0.001)] + "small,capacity,q,control", [(100, 7, 1, 0.006), (0.99, 1.01, 2**-9, 0.001)] ) def test_narrow_feasible_window_probes_the_first_resolvable_alpha( small, capacity, q, control, monkeypatch @@ -51,7 +51,9 @@ def test_narrow_feasible_window_probes_the_first_resolvable_alpha( import ngraph.analysis.msd as msd p = prepared(small, capacity, q) - assert p.topology.q == (1 if q == 1 else 2**-9) + # This window is deliberately narrower than the search step. Fix q so + # the test continues to exercise the first-resolvable fallback. + assert p.topology.q == q calls = [] original = msd._apply_matrix @@ -158,7 +160,7 @@ def test_unit_threshold_at_infeasible_upper_reuses_the_recorded_probe(monkeypatc n.add_link(Link("A", "C", 3)) d = DemandSet("d", (DemandSpec(Selector("^A$"), Selector("^B$"), 4),)) p = prepare(Scenario(n, demand_sets={"d": d}), demand_sets=("d",)) - assert p.topology.q == 2**-8 + assert p.topology.q == 2**-11 calls = [] original = msd._apply_matrix @@ -169,7 +171,7 @@ def observed(*args): monkeypatch.setattr(msd, "_apply_matrix", observed) result = maximum_supported_demand(p, MSDQuery(p.demand_sets["d"])) - # half-up(4*alpha/q) first reaches1 at alpha=2^-11, already tested infeasible. + # half-up(4*alpha/q) first reaches1 at alpha=2^-14, already tested infeasible. assert ( result.search_status == "none_feasible" and result.stop_reason == "unit_resolution" @@ -180,9 +182,9 @@ def observed(*args): is result.upper_certificate is None ) - assert [row.alpha.value for row in result.history] == [2.0**-i for i in range(13)] + assert [row.alpha.value for row in result.history] == [2.0**-i for i in range(16)] assert [row.outcome for row in result.history[-2:]] == [ "infeasible", "below_resolution", ] - assert len(calls) == len(result.history) == 13 + assert len(calls) == len(result.history) == 16 diff --git a/tests/analysis/test_observed_model_boundaries.py b/tests/analysis/test_observed_model_boundaries.py index 8398fb4..4191fda 100644 --- a/tests/analysis/test_observed_model_boundaries.py +++ b/tests/analysis/test_observed_model_boundaries.py @@ -1,7 +1,6 @@ """Evaluated tunnel plans and graph validation survive empty traffic selections.""" from dataclasses import replace -from fractions import Fraction import netgraph_core as core import pytest @@ -18,15 +17,11 @@ Link, Network, Node, - PlacementQuery, Scenario, Selector, - SensitivityQuery, TunnelCountQuery, UnitsSettings, capacity, - edge_sensitivity, - place, prepare, resolve_demands, run, @@ -54,178 +49,6 @@ def t2(limit, up_to=False): ) -def constraint_value(c): - return ( - c.kind, - c.label, - c.incoming_physical, - c.basis, - c.members, - c.q_upper_bound, - c.met, - ) - - -def constraints(answer): - return [c for miss in answer.misses for c in miss.constraints] - - -@pytest.mark.parametrize( - "limit,up_to,actual", [(1, False, 1), (2, True, 1), (2, False, 2), (4, True, 1)] -) -def test_tunnel_precision_reads_the_chosen_plan_in_every_observed_reader( - limit, up_to, actual -): - config = t2(limit, up_to) - d = DemandSet("d", (DemandSpec(Selector("a"), Selector("t"), 10, config),)) - p = prepare( - Scenario(network(), demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - expected = ( - [] - if actual == 1 - else [ - ( - "tunnel", - "demand.d|0|0.head[0]", - Fraction(10), - "declared", - 2, - Fraction(0.075) * 10, - False, - ) - ] - ) - assert [constraint_value(c) for c in p.units_report.nominal_splits] == expected - assert p.units_report.target_met is (actual == 1) - asked = capacity( - p, - CapacityQuery( - Selector("a"), Selector("t"), config, offer=10, design_demand=10, workers=1 - ), - ) - placed = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - counted = tunnel_counts( - p, - TunnelCountQuery( - p.demand_sets["d"], - () if up_to else (limit,), - 10, - 10, - up_to=limit if up_to else None, - workers=1, - ), - ) - sensitive = edge_sensitivity( - p, - SensitivityQuery( - Selector("a"), Selector("t"), config, offer=10, design_demand=10, workers=1 - ), - ) - for answer, result, prefix in ( - (asked, asked.cases[0].states["converged"].rows[0].result, "capacity.pair[0]"), - (placed, placed.cases[0].states["converged"].rows[0].result, "demand.d|0|0"), - ( - counted, - counted.rows[0].result, - f"tunnel_counts.demand[d|0|0].{'up_to' if up_to else 'count'}[{limit}]", - ), - (sensitive, sensitive.result.baseline, "sensitivity.baseline"), - ): - assert result.plan.tunnels_per_head == actual - assert result.plan.design_by_head == ((0, 10),) - assert result.plan.heads_with_capacity == (0,) - assert len(result.by_tunnel) == actual - local = [c for c in constraints(answer) if c.label.startswith(prefix + ".")] - assert [constraint_value(c) for c in local] == ( - [] - if actual == 1 - else [ - ( - "tunnel", - prefix + ".head[0]", - Fraction(10), - "declared", - 2, - Fraction(0.075) * 10, - False, - ) - ] - ) - automatic = prepare( - Scenario(network(), demand_sets={"d": d}, units=UnitsSettings("auto", 0.075)), - demand_sets=("d",), - ) - assert automatic.topology.q == (1 if actual == 1 else 0.5) - - -@pytest.mark.parametrize( - "cap,design,offer", - [(0, 10, 10), (1, 10, 10), (100, 12, 10), (100, 10, 0), (100, 0, 10)], -) -def test_tunnel_design_bounds_omit_only_heads_without_capacity(cap, design, offer): - p = prepare(Scenario(network(cap), units=UnitsSettings(1, 0.075))) - config = t2(2) - answer = capacity( - p, - CapacityQuery( - Selector("a"), - Selector("t"), - config, - offer=offer, - design_demand=design, - workers=1, - ), - ) - row = answer.cases[0].states["converged"].rows[0] - assert row.result.plan.tunnels_per_head == 2 - assert len(row.result.by_tunnel) == (2 if cap == 100 else 0) - local = [c for c in constraints(answer) if c.kind == "tunnel"] - expected = [] - if cap > 0 and offer > 0: - expected.append( - ( - "tunnel", - "capacity.pair[0].head[0]", - Fraction(offer), - "declared", - 2, - Fraction(0.075) * offer, - False, - ) - ) - if cap > 0 and design > 0 and design != offer: - expected.append( - ( - "tunnel", - "capacity.pair[0].design.head[0]", - Fraction(design), - "declared", - 2, - Fraction(0.075) * design, - False, - ) - ) - assert [constraint_value(c) for c in local] == expected - - -def test_dead_t1_heads_keep_the_requested_split_count(): - p = prepare(Scenario(network(0), units=UnitsSettings(1, 0.075))) - config = core.FlowPolicyConfig( - routing=core.DistributedTE(core.TunnelCount(2)), admission=core.Admission.LOSSY - ) - a = capacity( - p, - CapacityQuery( - Selector("a"), Selector("t"), config, offer=10, design_demand=10, workers=1 - ), - ) - assert [(c.kind, c.members, c.incoming_physical) for c in constraints(a)] == [ - ("tunnel", 2, Fraction(10)) - ] - - def program(p): graph = p.topology.graph edge = p.topology._edge_id_by_ref[DirectedLink("a|t|0", "forward")] @@ -416,18 +239,6 @@ def test_support_and_failure_mask_intersect_in_capacity_and_counts(): assert [ r.states["converged"].rows[0].result.plan.design_by_head for r in a.cases ] == [((0, 5), (1, 5)), ((0, 10),)] - assert len(a.misses) == 1 and a.misses[0].location.case_id == "baseline" - assert [constraint_value(c) for c in a.misses[0].constraints] == [ - ( - "source", - "capacity.pair[0].sources", - Fraction(10), - "declared", - 2, - Fraction(0.075) * 10, - False, - ) - ] ds = resolve_demands( p, DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, config),)) ) @@ -449,7 +260,6 @@ def test_support_and_failure_mask_intersect_in_capacity_and_counts(): ) assert baseline.rows[0].result.plan.design_by_head == ((0, 5), (1, 5)) assert failed.rows[0].result.plan.design_by_head == ((0, 10),) - assert len(baseline.misses) == 1 and failed.misses == () def test_pinned_diagonal_preparation_and_step_accept_an_own_program(): @@ -468,56 +278,6 @@ def query(self, prepared, failures=None): assert len(rows) == 1 and isinstance(rows[0], SelfPair) and rows[0].node == "a" -@pytest.mark.parametrize( - "allocation", [core.SourceAllocation.EQUAL, core.SourceAllocation.WEIGHTED] -) -def test_controller_precision_reads_native_records_without_a_search( - allocation, monkeypatch -): - config = core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=allocation, - source_weights=[1, 1, 0] - if allocation == core.SourceAllocation.WEIGHTED - else None, - ) - ) - d = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, config),)) - p = prepare( - Scenario( - network(sources=("a", "b")), - demand_sets={"d": d}, - units=UnitsSettings(1, 0.075), - ), - demand_sets=("d",), - ) - original = core.FlowPolicy.records - records = [] - - def read(owner): - records.append(owner) - return original(owner) - - def search(*args, **kwargs): - raise AssertionError("placement precision must use native source records") - - monkeypatch.setattr(core.FlowPolicy, "records", read) - monkeypatch.setattr(core, "spf", search) - a = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert len(records) == 1 - assert [constraint_value(c) for c in constraints(a)] == [ - ( - "source", - "demand.d|0|0.sources", - Fraction(10), - "declared", - 2, - Fraction(0.075) * 10, - False, - ) - ] - - def test_each_count_row_uses_its_own_demand_count_and_up_to_plan(): p = prepare(Scenario(network(sources=("a", "b")), units=UnitsSettings(1, 0.075))) config = t2(2) @@ -531,21 +291,6 @@ def test_each_count_row_uses_its_own_demand_count_and_up_to_plan(): ), ) answer = tunnel_counts(p, TunnelCountQuery(ds, (2, 4), 10, 10, up_to=4, workers=1)) - expected = [] - for index in range(2): - for count in (2, 4): - expected.append( - ( - "tunnel", - f"tunnel_counts.demand[d|{index}|0].count[{count}].head[{index}]", - Fraction(10), - "declared", - count, - Fraction(0.075) * 10 / (count - 1), - False, - ) - ) - assert [constraint_value(c) for c in constraints(answer)] == expected assert [row.result.plan.tunnels_per_head for row in answer.rows] == [ 2, 4, @@ -559,49 +304,6 @@ def test_each_count_row_uses_its_own_demand_count_and_up_to_plan(): ] * 3 + [((1, 10),)] * 3 -def test_every_controller_leaf_model_is_checked_once(monkeypatch): - import ngraph.analysis.demands as binding - from ngraph import FlowPolicyPreset, RoutingDomain, WCMPController - - n = Network(nodes={s: Node(s) for s in ("S", "A", "T1", "T2")}) - for u, v in [("S", "A"), ("A", "T1"), ("A", "T2")]: - n.add_link(Link(u, v, 100)) - d = DemandSet( - "d", - ( - DemandSpec( - Selector("^S$"), - Selector("^T"), - 10, - FlowPolicyPreset.SHORTEST_PATHS_WCMP, - mode="pairwise", - ), - ), - ) - p = prepare( - Scenario( - n, - demand_sets={"d": d}, - units=UnitsSettings(1), - routing=RoutingDomain(Selector("^A$"), Selector("^(S|T1|T2)$")), - wcmp_controller=WCMPController(), - ), - demand_sets=("d",), - ) - original = binding.validate_model - calls = [] - - def check(topology, config): - calls.append(config) - return original(topology, config) - - monkeypatch.setattr(binding, "validate_model", check) - resolved = resolve_demands(p, d) - assert len(resolved.demands) == 2 - assert len({row.config for row in resolved.demands}) == 2 - assert calls == [row.config for row in resolved.demands] - - @pytest.mark.parametrize("admission", [core.Admission.LOSSLESS, core.Admission.LOSSY]) @pytest.mark.parametrize( "routing", @@ -694,140 +396,6 @@ def evaluate(graph, sources, destinations, options): assert r.demands == () and sum(a.discarded_units for a in r.accounting) == 14 -def test_only_the_live_t2_head_lays_out_tunnels_while_both_sources_split(): - n = network(sources=("a", "b")) - n.replace_link("b|t|0", capacity=0) - config = t2(2) - d = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, config),)) - p = prepare( - Scenario(n, demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - asked = capacity( - p, - CapacityQuery( - Selector("a|b"), - Selector("t"), - config, - offer=10, - design_demand=10, - workers=1, - ), - ) - placed = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - for answer, prefix in [(asked, "capacity.pair[0]"), (placed, "demand.d|0|0")]: - row = answer.cases[0].states["converged"].rows[0] - assert row.result.plan.design_by_head == ((0, 5), (1, 5)) - assert row.result.plan.heads_with_capacity == (0,) - assert len(row.result.by_tunnel) == 2 - assert row.result.delivered == 5 and row.result.source_drops == 5 - assert [constraint_value(c) for c in constraints(answer)] == [ - ( - "source", - prefix + ".sources", - Fraction(10), - "declared", - 2, - Fraction(0.075) * 10, - False, - ), - ( - "tunnel", - prefix + ".head[0]", - Fraction(5), - "declared", - 2, - Fraction(0.075) * 5, - False, - ), - ] - - -@pytest.mark.parametrize("offer,design", [(0, 10), (10, 12), (12, 10)]) -def test_up_to_chosen_one_adds_neither_offer_nor_design_split(offer, design): - p = prepare(Scenario(network(), units=UnitsSettings(1, 0.075))) - config = t2(2, True) - answer = capacity( - p, - CapacityQuery( - Selector("a"), - Selector("t"), - config, - offer=offer, - design_demand=design, - workers=1, - ), - ) - assert answer.cases[0].states["converged"].rows[0].result.plan.tunnels_per_head == 1 - assert answer.misses == () - d = resolve_demands( - p, DemandSet("d", (DemandSpec(Selector("a"), Selector("t"), 10, config),)) - ) - counted = tunnel_counts( - p, TunnelCountQuery(d, (), design, offer, up_to=2, workers=1) - ) - assert counted.rows[0].result.plan.tunnels_per_head == 1 and counted.misses == () - - -@pytest.mark.parametrize("up_to,expected_q", [(False, 0.5), (True, 1)]) -def test_capacity_preparation_uses_the_observed_plan_at_each_q(up_to, expected_q): - step = MaxFlow( - name="ask", - source=Selector("a"), - target=Selector("t"), - model=t2(2, up_to), - offer=10, - design_demand=10, - workers=1, - ) - p = prepare(Scenario(network(), [step], units=UnitsSettings("auto", 0.075))) - assert p.topology.q == expected_q - assert p.units_report.target_met - assert [ - (c.kind, c.members, c.q_upper_bound, c.met) - for c in p.units_report.nominal_splits - ] == ([] if up_to else [("tunnel", 2, Fraction(0.075) * 10, True)]) - - -@pytest.mark.parametrize("precision,q", [(0.1, 1 / 16), (0.07, 1 / 32), (0.05, 1 / 32)]) -def test_automatic_preset_resolution_keeps_live_heads_with_zero_tunnel_units( - precision, q -): - scenario = Scenario.from_yaml( - f""" -network: - nodes: {{a: {{}}, t: {{}}}} - links: [{{source: a, target: t, capacity: 10}}] -demands: - d: [{{source: "^a$", target: "^t$", volume: 10, preset: TE_ECMP_16_LSP}}] -units: {{resolution: auto, precision: {precision}}} -workflow: - - {{type: TrafficMatrixPlacement, name: tm, demand_set: d}} -""" - ) - p = prepare(scenario) - assert p.topology.q == q - assert p.units_report.target_met is True - splits = [c for c in p.units_report.nominal_splits if c.kind == "tunnel"] - assert len(splits) == 1 - split = splits[0] - assert split.members == 16 - assert split.incoming_physical == 10 - assert split.q_upper_bound == Fraction(precision) * 10 / 15 - assert split.met is True - result = run(p) - state = result.steps[0].result.cases[0].states["converged"] - assert state.totals.delivered_units == int(10 / q) - assert result.units_report.target_met is True - assert result.units_report.misses == () - plan = state.rows[0].result.plan - assert plan.tunnels_per_head == 16 - assert plan.insufficient_resolution is False - assert plan.max_bandwidth_per_tunnel > 0 - # The combined-demand pool head follows the two physical nodes. - assert plan.heads_with_capacity == (2,) - - def shared_link_up_to_scenario(heads, capacity_, volume, precision, resolution="auto"): nodes = ", ".join(f"s{i}: {{}}" for i in range(heads)) links = "\n".join( @@ -856,81 +424,3 @@ def shared_link_up_to_scenario(heads, capacity_, volume, precision, resolution=" - {{type: TrafficMatrixPlacement, name: tm, demand_set: d}} """ ) - - -@pytest.mark.parametrize( - "heads,cap,volume,precision", [(12, 10, 1000, 0.1), (3, 2, 100, 0.5)] -) -def test_automatic_up_to_resolution_outgrows_the_zero_unit_fallback( - heads, cap, volume, precision -): - p = prepare(shared_link_up_to_scenario(heads, cap, volume, precision)) - # q=1 cannot give every head one shared-link unit; q=.5 can. - assert p.topology.q == 0.5 - assert p.units_report.target_met is True - assert [ - (c.kind, c.incoming_physical, c.basis, c.members, c.q_upper_bound, c.met) - for c in p.units_report.nominal_splits - ] == [ - ( - "source", - Fraction(volume), - "declared", - heads, - Fraction(precision) * volume / (heads - 1), - True, - ) - ] - result = run(p) - state = result.steps[0].result.cases[0].states["converged"] - assert state.totals.delivered_units == cap * 2 - assert result.units_report.target_met is True - assert result.units_report.misses == () - (row,) = state.rows - assert row.result.plan.tunnels_per_head == 1 - assert row.result.plan.max_bandwidth_per_tunnel == 1 - assert row.result.plan.insufficient_resolution is False - assert len(row.result.plan.heads_with_capacity) == heads - - -@pytest.mark.parametrize("cap,expected_members", [(0, 0), (10, 256), (1000, 1)]) -def test_live_up_to_head_uses_limit_only_without_a_whole_tunnel_unit( - cap, expected_members -): - p = prepare(shared_link_up_to_scenario(12, cap, 1000, 0.1, resolution=2)) - answer = capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - t2(256, True), - offer=24, - design_demand=48, - workers=1, - ), - ) - row = answer.cases[0].states["converged"].rows[0] - assert row.result.plan.tunnels_per_head == 1 - assert (row.result.plan.max_bandwidth_per_tunnel == 0) == (cap < 1000) - heads = row.result.plan.heads_with_capacity - assert len(heads) == (0 if cap == 0 else 12) - tunnel = [c for c in constraints(answer) if c.kind == "tunnel"] - expected = [] - if expected_members > 1: - # Node insertion puts the twelve s nodes before x and t. - for design, amount in [(False, 24), (True, 48)]: - for head in range(12): - expected.append( - ( - "tunnel", - "capacity.pair[0]." - + ("design." if design else "") - + f"head[{head}]", - Fraction(amount, 12), - "declared", - 256, - Fraction(0.1) * Fraction(amount, 12) / 255, - False, - ) - ) - assert [constraint_value(c) for c in tunnel] == expected diff --git a/tests/analysis/test_persistent_msd.py b/tests/analysis/test_persistent_msd.py index bdb37eb..ded06f0 100644 --- a/tests/analysis/test_persistent_msd.py +++ b/tests/analysis/test_persistent_msd.py @@ -214,10 +214,10 @@ def test_every_probe_is_quantized_at_the_prepared_q(): probed = [p for p in result.history if p.totals is not None] assert [p.alpha.value for p in probed][:7] == [1, 2, 4, 8, 16, 32, 64] for probe in probed: - # Half-up of the exact scaled request at the prepared q (q = 1/8 here: alpha - # 64.0625 asks 512.5 -> 513 units). + # Half-up of the exact scaled request at the prepared q (q = 1/64 here: alpha + # 64.0078125 asks 4096.5 -> 4097 units). exact = Fraction(probe.alpha.numerator, probe.alpha.denominator) / Fraction(q) assert probe.totals.reference_request_units == int(exact + Fraction(1, 2)) - # The capacity is 64 / q = 512 units: the best probe asks exactly those. + # The capacity is 64 / q = 4096 units: the best probe asks exactly those. best = result.history[result.best_probe_index] - assert best.totals.reference_request_units == 512 == best.totals.delivered_units + assert best.totals.reference_request_units == 4096 == best.totals.delivered_units diff --git a/tests/analysis/test_preparation_contracts.py b/tests/analysis/test_preparation_contracts.py new file mode 100644 index 0000000..6488a00 --- /dev/null +++ b/tests/analysis/test_preparation_contracts.py @@ -0,0 +1,316 @@ +"""Preparation ownership, input collection, range and diagonal model validation.""" + +from dataclasses import dataclass + +import netgraph_core as core +import pytest + +from ngraph import ( + CapacityQuery, + Link, + Network, + Node, + Scenario, + Selector, + StepOutput, + UnitsSettings, + WorkflowStep, + capacity, + prepare, + run, +) +from ngraph.analysis.preparation import StepNeeds +from ngraph.results import SelfPair +from ngraph.workflow import MaxFlow + + +def test_the_range_screen_counts_the_private_pool_ingress(): + # Link capacity C = 1.5 * 2^40, both directions, and the pool ingress C: + # 4.5 * 2^40 / q < 2^62 holds from 2^-19 (the physical sum alone allows + # 2^-20). The input 0.0005 asks q<=1e-6 (2^-20); the pool forces q=2^-19. + prepared = prepare( + Scenario.from_yaml( + """ +network: + nodes: {s: {}, t: {}} + links: [{source: s, target: t, capacity: 1649267441664}] +demands: + d: [{source: "^s$", target: "^t$", volume: 0.0005, preset: TE_ECMP_16_LSP}] +workflow: + - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} +""" + ) + ) + report = prepared.units_report + assert prepared.topology.q == 2**-19 and not report.target_met + assert report.augmented_capacity_sum_units == 9 * 2**40 * 2**18 + + +def test_a_minimum_unreserved_bandwidth_is_an_input_amount(): + # min_unreserved_bandwidth 0.001: 2p * 0.001 = 2e-6 -> 2^-19. + prepared = prepare( + Scenario.from_yaml( + """ +network: + nodes: {s: {}, t: {}} + links: [{source: s, target: t, capacity: 10}] +demands: + d: + - source: "^s$" + target: "^t$" + volume: 1 + model: + routing: + type: distributed_te + tunnel_count: {per_head: 1} + min_unreserved_bandwidth: 0.001 +workflow: + - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} +""" + ) + ) + report = prepared.units_report + assert prepared.topology.q == 2**-19 + assert report.input_bound.label == "demand.d|0.min_unreserved" + + +def one_link(): + network = Network(nodes={"s": Node("s"), "t": Node("t")}) + network.add_link(Link("s", "t", 1)) + return network + + +T1 = "{routing: {type: distributed_te, tunnel_count: {per_head: 1}EXTRA}}" + + +@pytest.mark.parametrize( + "fields,label,q", + [ + # 2p * 0.0626 = 0.0001252 -> 2^-13; the graph count alone gives 2^-12. + ("offer: 0.0626", "workflow.ask.offer", 2**-13), + ( + f"design_demand: 0.0626, model: {T1.replace('EXTRA', '')}", + "workflow.ask.design_demand", + 2**-13, + ), + # 2p * 0.001 = 2e-6 -> 2^-19. + ( + "design_demand: 1, " + f"model: {T1.replace('EXTRA', ', min_unreserved_bandwidth: 0.001')}", + "workflow.ask.min_unreserved", + 2**-19, + ), + ], + ids=["offer", "design", "minimum"], +) +def test_preparation_collects_every_capacity_step_amount(fields, label, q): + prepared = prepare( + Scenario.from_yaml( + f""" +network: + nodes: {{s: {{}}, t: {{}}}} + links: [{{source: s, target: t, capacity: 1}}] +workflow: + - {{type: MaxFlow, name: ask, source: "^s$", target: "^t$", workers: 1, {fields}}} +""" + ) + ) + report = prepared.units_report + assert prepared.topology.q == q and report.target_met + assert report.input_bound.label == label + + +@dataclass +class Custom(WorkflowStep): + """A custom capacity step that declares one amount of its own.""" + + def needs(self): + return StepNeeds( + numerical=True, + capacity=lambda prepared: CapacityQuery( + Selector("^s$"), Selector("^t$"), workers=1 + ), + inputs={"amount": ("offer", 0.0626)}, + ) + + def run(self, context): + return StepOutput({}, None) + + +def test_a_custom_step_amount_determines_q_and_the_report(): + prepared = prepare(Scenario(one_link(), (Custom(name="custom"),))) + report = prepared.units_report + assert prepared.topology.q == 2**-13 + assert report.input_bound.label == "workflow.custom.amount" + + +@pytest.mark.parametrize("mode", ["combine", "pairwise"]) +@pytest.mark.parametrize("valid", [False, True]) +def test_diagonal_capacity_validates_the_same_model_as_other_pairs(mode, valid): + n = Network(nodes={s: Node(s) for s in ("a", "b")}) + n.add_link(Link("a", "b", 10)) + model = core.FlowPolicyConfig( + routing=core.ShortestPaths( + allocation=core.RouteAllocation.WEIGHTED, + weight_basis=core.WeightBasis.EXPLICIT, + edge_weights=[1, 1] if valid else [1], + ) + ) + p = prepare(Scenario(n, units=UnitsSettings(1))) + query = CapacityQuery(Selector("a"), Selector("a"), model, mode=mode, workers=1) + step = MaxFlow(source=Selector("a"), target=Selector("a"), model=model, mode=mode) + if valid: + rows = capacity(p, query).cases[0].states["converged"].rows + assert len(rows) == 1 and isinstance(rows[0], SelfPair) + assert rows[0].node == "a" + result = run(prepare(Scenario(n, [step], units=UnitsSettings(1)))) + assert isinstance( + result.steps[0].result.cases[0].states["converged"].rows[0], SelfPair + ) + else: + with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): + capacity(p, query) + with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): + prepare(Scenario(n, [step], units=UnitsSettings(1))) + with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): + capacity(p, CapacityQuery(Selector("a"), Selector("b"), model, workers=1)) + + +def network(): + value = Network( + nodes={name: Node(name, attrs={"owned": [1]}) for name in ("a", "t")} + ) + value.add_link(Link("a", "t", 1, 3)) + return value + + +def test_preparation_shares_the_input_records_without_copying_them(): + source = network() + prepared = prepare(Scenario(source, units=UnitsSettings(1))) + view = prepared.scenario.network + assert view is not source and view.nodes is not source.nodes + assert all(view.nodes[name] is node for name, node in source.nodes.items()) + assert all(view.links[name] is link for name, link in source.links.items()) + with pytest.raises(TypeError, match="read-only"): + source.nodes["a"].attrs["owned"].append(2) + source.replace_node("a", attrs={"owned": [1, 2]}) + assert prepared.scenario.network.nodes["a"].attrs["owned"] == [1] + again = prepare(Scenario(source, units=UnitsSettings(1))) + assert again.scenario.network.nodes["a"].attrs["owned"] == [1, 2] + + +REASON = "CentralizedTE with tunnel_count and several sources requires source_allocation=EQUAL" + + +def capacity_rosters(allocation, large_first=False, singletons=False): + groups = ["z", "a", "a"] if large_first else ["a", "z", "z"] + if singletons: + groups = ["a", "y", "z"] + n = Network( + nodes={ + **{ + f"s{i}": Node(f"s{i}", attrs={"group": g}) for i, g in enumerate(groups) + }, + "t": Node("t"), + } + ) + for i in range(3): + n.add_link(Link(f"s{i}", "t", 10, 1)) + config = core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=allocation, tunnel_count=core.TunnelCount(1) + ) + ) + step = MaxFlow( + name="cap", + source=Selector("^s", group_by="group"), + target=Selector("^t$"), + mode="pairwise", + model=config, + workers=1, + ) + return Scenario(n, [step], units=UnitsSettings(1)) + + +@pytest.mark.parametrize("large_first", [False, True]) +def test_every_relevant_roster_is_validated_at_prepare(large_first): + with pytest.raises(ValueError) as error: + prepare(capacity_rosters(core.SourceAllocation.OPTIMIZED, large_first)) + assert type(error.value) is ValueError + assert str(error.value) == "workflow[0] (name 'cap'): " + REASON + assert ( + type(error.value.__cause__) is ValueError + and str(error.value.__cause__) == REASON + ) + + +@pytest.mark.parametrize( + "allocation,singletons,expected", + [ + (core.SourceAllocation.EQUAL, False, [10, 20]), + (core.SourceAllocation.OPTIMIZED, True, [10, 10, 10]), + ], +) +def test_valid_rosters_prepare_and_execute(allocation, singletons, expected): + p = prepare(capacity_rosters(allocation, singletons=singletons)) + result = run(p) + assert [ + r.result.delivered + for r in result.steps[0].result.cases[0].states["converged"].rows + ] == expected + + +class MaterializedPinned(MaxFlow): + def query(self, prepared, failures=None): + from ngraph import CapacityQuery, DirectedLink + from ngraph.analysis.programs import dag_from_edges + + t = prepared.topology + program = core.ForwardingProgram.from_dag( + t.graph, + [t.node_id_by_name["s0"]], + [t.node_id_by_name["t"]], + dag_from_edges( + t.graph, [t._edge_id_by_ref[DirectedLink("s0|t|0", "forward")]] + ), + ) + return CapacityQuery( + self.source, + self.target, + core.FlowPolicyConfig(routing=core.Pinned(program)), + mode="pairwise", + workers=1, + ) + + +@pytest.mark.parametrize("multiple", [False, True]) +def test_opaque_pinned_capacity_checks_each_materialized_pair(multiple): + n = Network(nodes={x: Node(x) for x in ("s0", "s1", "t")}) + for s in ("s0", "s1"): + n.add_link(Link(s, "t", 10, 1)) + step = MaterializedPinned( + name="pinned", + source=Selector("^s" if multiple else "^s0$", group_by="name"), + target=Selector("^t$"), + mode="pairwise", + ) + s = Scenario(n, [step], units=UnitsSettings(1)) + if multiple: + with pytest.raises(ValueError) as error: + prepare(s) + assert type(error.value) is ValueError + assert ( + str(error.value) + == "workflow[0] (name 'pinned'): program endpoints do not match policy endpoints" + ) + assert type(error.value.__cause__) is ValueError + assert ( + str(error.value.__cause__) + == "program endpoints do not match policy endpoints" + ) + else: + p = prepare(s) + assert ( + run(p).steps[0].result.cases[0].states["converged"].rows[0].result.delivered + == 10 + ) diff --git a/tests/analysis/test_preparation_units.py b/tests/analysis/test_preparation_units.py index bdeff65..d750442 100644 --- a/tests/analysis/test_preparation_units.py +++ b/tests/analysis/test_preparation_units.py @@ -1,7 +1,5 @@ """Physical expansion and exact candidate selection, independent expected bounds.""" -from fractions import Fraction - import pytest from ngraph.analysis.demands import resolve_demands, scaled_expansion @@ -37,31 +35,6 @@ def spec(volume=0.25, *, name="load", policy=FlowPolicyPreset.SHORTEST_PATHS_ECM ) -def test_auto_candidate_is_coarsest_legal_power_with_exact_input_target(): - # For capacity1 and volume1/4, precision1/1000 demands q<=1/2000. - # The largest legal power is2^-11; the next coarser2^-10 fails. - result = prepared(line(), [spec()], UnitsSettings()) - topology, sets = result.topology, result.demand_sets - assert topology.q == 2**-11 - report = result.units_report - assert report.target_met - assert report.input_count == 2 - assert report.nominal_split_count == 0 - assert report.augmented_capacity_sum_units == 4096 - assert report.augmented_cost_sum == 6 - assert report.scope.library_demand_sets == ("load",) - assert sets["load"].demands[0].nominal_units == 512 - bound = [ - constraint - for constraint in report.binding_constraints - if constraint.kind == "input" - ] - assert len(bound) == 1 - assert bound[0].label == "demand.load|0.volume" - assert bound[0].q_upper_bound == 2 * Fraction(float(0.001)) * Fraction(1, 4) - assert Fraction(topology.q) <= bound[0].q_upper_bound < Fraction(topology.q * 2) - - def test_alpha_scales_physical_total_and_preserves_zero_policy_identity(): result = prepared(line(), [spec()], UnitsSettings(1)) demands = result.demand_sets["load"] @@ -108,7 +81,6 @@ def test_nested_expansion_uses_one_parent_round_then_declared_rr(): f"nested|0|{i}" for i in range(4) ] assert resolved["nested"].accounting[0].declared_units == 10 - assert result.units_report.nominal_split_count >= 2 def test_duplicate_explicit_and_generated_ids_across_sets_refuse_with_control(): @@ -125,41 +97,12 @@ def test_duplicate_explicit_and_generated_ids_across_sets_refuse_with_control(): def test_zero_parent_counts_but_does_not_constrain_auto_resolution(): result = prepared(line(), [spec(0)], UnitsSettings()) - # Capacity1 is the only positive input: q<=.002, hence2^-9. - assert result.topology.q == 2**-9 - assert result.units_report.input_count == 2 - assert result.units_report.zeroed_inputs == 0 + # Zero volume remains an in-scope leaf: floor3 gives q<=.001/3. + assert result.topology.q == 2**-12 + assert result.units_report.input_bound.label == "link.s|t|0.capacity" assert result.demand_sets["load"].demands[0].nominal_units == 0 -def test_equal_zero_capacity_member_and_capacity_weighted_member_count_differ(): - network = Network() - for name in ("s", "a", "b", "t"): - network.add_node(Node(name)) - for source, target, capacity in ( - ("s", "a", 0), - ("a", "t", 5), - ("s", "b", 5), - ("b", "t", 5), - ): - network.add_link(Link(source, target, capacity, 1)) - source = Selector("^s$") - target = Selector("^t$") - reports = [] - for preset in ( - FlowPolicyPreset.SHORTEST_PATHS_ECMP, - FlowPolicyPreset.SHORTEST_PATHS_WCMP, - ): - demands = DemandSet("load", (DemandSpec(source, target, 10, preset),)) - reports.append(prepared(network, [demands], UnitsSettings(1)).units_report) - assert [ - (split.members, split.incoming_physical) - for split in reports[0].nominal_splits - if split.kind == "router" - ] == [(2, Fraction(10))] - assert not [split for split in reports[1].nominal_splits if split.kind == "router"] - - @pytest.mark.parametrize("zero_link", [("s", "a"), ("a", "t")]) def test_explicit_positive_weight_on_zero_capacity_prunes_the_dead_branch(zero_link): import netgraph_core as ngc @@ -199,35 +142,9 @@ def test_explicit_positive_weight_on_zero_capacity_prunes_the_dead_branch(zero_l network, [demands], UnitsSettings(1), weights={"configured": weights} ) resolved = result.demand_sets - assert not [ - split for split in result.units_report.nominal_splits if split.kind == "router" - ] assert resolved["load"].demands[0].config.routing.edge_weights is not None -def test_observation_miss_counts_its_active_capacity_inputs_too(): - from ngraph.analysis.placement import PlacementQuery, place - - network = line() - network.replace_link("s|t|0", capacity=0.25) - preparation = prepared(network, [spec()], UnitsSettings(1)) - result = place( - preparation, - PlacementQuery(preparation.demand_sets["load"], alpha=4, workers=1), - ) - (miss,) = result.misses - assert result.units_report.misses == (*preparation.units_report.misses, miss) - assert miss.location.case_id == "baseline" and miss.location.probe_index is None - assert miss.inexact_inputs == miss.zeroed_inputs == 1 - assert [sample.label for sample in miss.examples] == ["link.s|t|0.capacity"] - assert miss.examples[0].error == Fraction(-1, 4) - assert { - constraint.label - for constraint in miss.constraints - if constraint.kind == "input" - } == {"demand.load|0.volume", "link.s|t|0.capacity"} - - def test_totals_above_2_53_are_summed_in_exact_units_not_physical_floats(): # At q = 1/2, volumes 2^52 and 0.5 are 2^53 and 1 units: the exact total is 2^53 + 1. # Summed as physical floats first, 2^52 + 0.5 is a binary64 tie that rounds to even diff --git a/tests/analysis/test_preparation_work.py b/tests/analysis/test_preparation_work.py deleted file mode 100644 index 8536724..0000000 --- a/tests/analysis/test_preparation_work.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Preparation avoids repeated work while retaining input checks and ownership.""" - -import netgraph_core as ngc -import pytest - -import ngraph.analysis.precision as precision -import ngraph.analysis.units as units -from ngraph import ( - CapacityQuery, - DemandSet, - DemandSpec, - Link, - Network, - Node, - Scenario, - Selector, - UnitsSettings, - capacity, - prepare, -) - - -def network(): - value = Network( - nodes={name: Node(name, attrs={"owned": [1]}) for name in ("a", "t")} - ) - value.add_link(Link("a", "t", 1, 3)) - return value - - -def test_preparation_shares_the_input_records_without_copying_them(): - source = network() - prepared = prepare(Scenario(source, units=UnitsSettings(1))) - view = prepared.scenario.network - assert view is not source and view.nodes is not source.nodes - assert all(view.nodes[name] is node for name, node in source.nodes.items()) - assert all(view.links[name] is link for name, link in source.links.items()) - with pytest.raises(TypeError, match="read-only"): - source.nodes["a"].attrs["owned"].append(2) - source.replace_node("a", attrs={"owned": [1, 2]}) - assert prepared.scenario.network.nodes["a"].attrs["owned"] == [1] - again = prepare(Scenario(source, units=UnitsSettings(1))) - assert again.scenario.network.nodes["a"].attrs["owned"] == [1, 2] - - -def test_complete_input_observations_are_built_once_for_a_manual_candidate(monkeypatch): - calls = [] - original = units.input_samples - - def observed(inputs, q): - calls.append(tuple(inputs)) - return original(inputs, q) - - monkeypatch.setattr(units, "input_samples", observed) - prepared = prepare(Scenario(network(), units=UnitsSettings(1))) - assert calls == [("link.a|t|0.capacity",)] - assert prepared.units_report.input_count == 1 - assert prepared.units_report.augmented_capacity_sum_units == 2 - - -def test_input_quantization_skips_coarse_candidates_excluded_by_precision(monkeypatch): - converted = [] - original = units.quantize - - def observed(value, q, label): - converted.append(q) - return original(value, q, label) - - monkeypatch.setattr(units, "quantize", observed) - load = DemandSet("load", (DemandSpec(Selector("a"), Selector("t"), 0.25),)) - value = prepare( - Scenario(network(), demand_sets={"load": load}), demand_sets=("load",) - ) - # Input target: q <= 2*0.001*0.25, so the coarsest legal power is2^-11. - assert value.topology.q == 2**-11 - assert converted and all(q <= 2**-11 for q in converted) - - -def test_empty_optimized_split_branch_is_not_visited_but_input_misses_remain( - monkeypatch, -): - value = prepare(Scenario(network(), units=UnitsSettings(1))) - - def forbidden(*args, **kwargs): - raise AssertionError("empty per-pair split check") - - with monkeypatch.context() as patch: - patch.setattr(precision, "query_splits", forbidden) - result = capacity( - value, CapacityQuery(Selector("a"), Selector("t"), offer=0.125, workers=1) - ) - assert result.cases[0].states["converged"].rows[0].result.delivered == 0 - assert not result.units_report.target_met - assert any( - c.label == "capacity.offer" - for miss in result.units_report.misses - for c in miss.constraints - ) - with pytest.raises(AssertionError, match="per-pair"): - capacity( - value, - CapacityQuery( - Selector("a"), - Selector("t"), - config=ngc.FlowPolicyConfig(routing=ngc.ShortestPaths()), - workers=1, - ), - ) - - -def test_precision_range_conflict_selects_finest_range_valid_quantum(): - value = network() - value.replace_link("a|t|0", capacity=float(2**61)) - value.add_link(Link("a", "t", 0.25, 1)) - p = prepare(Scenario(value)) - report = p.units_report - # The tiny input requires q<=0.0005. At q=1 the two directions of the - # large link sum to2^62, which is illegal; q=2 sums to2^61 and is legal. - # No precision-compatible q fits; the finest range-valid fallback is2. - assert p.topology.q == 2 - assert not report.target_met and report.zeroed_inputs == 1 - assert report.augmented_capacity_sum_units == 2**61 - ranges = [c for c in report.binding_constraints if c.kind == "capacity_sum"] - assert len(ranges) == 1 - assert (ranges[0].tested_q, ranges[0].units_value, ranges[0].exclusive_limit) == ( - 1, - 2**62, - 2**62, - ) - assert any( - c.kind == "input" and c.label == "link.a|t|1.capacity" and not c.met - for c in report.binding_constraints - ) - value.replace_link("a|t|0", capacity=1e100) - with pytest.raises( - ValueError, match="no legal resolution.*complete prepared input" - ): - prepare(Scenario(value)) - assert prepare(Scenario(network())).topology.graph.num_edges() == 2 - - -@pytest.mark.parametrize( - "preset,mode,solves", - [ - ("TE_MAXFLOW_UNLIM", "pairwise", False), - ("SHORTEST_PATHS_MAXFLOW", "combine", False), - ("SHORTEST_PATHS_ECMP", "pairwise", True), - ], -) -def test_prepare_solves_a_capacity_question_only_for_its_split_checks( - monkeypatch, preset, mode, solves -): - from ngraph import FlowPolicyPreset - from ngraph.workflow.max_flow_step import MaxFlow - - value = Network(nodes={name: Node(name) for name in ("a1", "a2", "t1", "t2")}) - for s in ("a1", "a2"): - for t in ("t1", "t2"): - value.add_link(Link(s, t, 3, 1)) - calls = [] - for name in ("evaluate_demand", "evaluate_pairs"): - original = getattr(ngc, name) - - def observed(graph, *args, original=original, name=name, **kwargs): - options = args[-1] if name == "evaluate_demand" else args[1] - pairs = 1 if name == "evaluate_demand" else len(args[0]) - calls.append((pairs, sum(options.eligibility.node_allowed()))) - return original(graph, *args, **kwargs) - - monkeypatch.setattr(ngc, name, observed) - step = MaxFlow( - name="cap", - source=Selector("^(a.)"), - target=Selector("^(t.)"), - mode=mode, - preset=FlowPolicyPreset[preset], - workers=1, - ) - prepare(Scenario(value, (step,), units=UnitsSettings(1))) - if solves: - assert calls and all(open_nodes == 4 for _, open_nodes in calls) - assert sum(pairs for pairs, _ in calls) == 4 - else: - # One native check per candidate quantum, with no eligible node. - assert calls and all(call == (1, 0) for call in calls) diff --git a/tests/analysis/test_publication_faults.py b/tests/analysis/test_publication_faults.py index a7bacff..8f47276 100644 --- a/tests/analysis/test_publication_faults.py +++ b/tests/analysis/test_publication_faults.py @@ -23,9 +23,6 @@ ("policy", "records", 3, False), ("ngraph.analysis.placement", "PlacementRow", 3, False), ("ngraph.analysis.costs", "cost_distribution", 3, False), - ("ngraph.analysis.placement", "placement_miss", 1, False), - ("ngraph.analysis.placement", "placement_inputs", 1, False), - ("ngraph.analysis.placement", "placement_inputs", 1, True), ("ngraph.analysis.placement", "placement_totals", 1, False), ("ngraph.analysis.placement", "CaseState", 1, False), ("ngraph.analysis.placement", "CaseResult", 1, False), diff --git a/tests/analysis/test_q_recipe_rules.py b/tests/analysis/test_q_recipe_rules.py deleted file mode 100644 index 5c7da5e..0000000 --- a/tests/analysis/test_q_recipe_rules.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Each rule of the q recipe, by a hand-derived scenario (precision p = 0.001). - -A split of ``n`` members carrying ``x`` asks ``q <= p * x / (n - 1)``; an input -amount ``x`` asks ``q <= 2 * p * x``. q is the coarsest power of two meeting -every bound, so each case names the bound that decides it. -""" - -from dataclasses import dataclass -from fractions import Fraction -from pathlib import Path - -import pytest - -import ngraph.analysis.preparation as preparation -from ngraph import ( - CapacityQuery, - Link, - Network, - Node, - Scenario, - Selector, - StepOutput, - WorkflowStep, - prepare, -) -from ngraph.analysis.preparation import StepNeeds - - -def splits(prepared): - return { - (c.kind, c.label, c.incoming_physical, c.members) - for c in prepared.units_report.nominal_splits - } - - -def test_a_split_inside_an_expansion_uses_its_share_of_the_parent(): - # Pairwise to t1 and t2: two leaves of 1/2, each across a two-way router - # at s: p * 1/2 = 5e-4 -> 2^-11 (the whole volume would give 2^-10). - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s: {}, m1: {}, m2: {}, t1: {}, t2: {}, j: {}} - links: - - {source: s, target: m1, capacity: 100} - - {source: s, target: m2, capacity: 100} - - {source: m1, target: j, capacity: 100} - - {source: m2, target: j, capacity: 100} - - {source: j, target: t1, capacity: 100} - - {source: j, target: t2, capacity: 100} -demands: - d: [{source: "^s$", target: "^t[12]$", volume: 1, mode: pairwise}] -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - assert prepared.topology.q == 2**-11 - assert splits(prepared) == { - ("expansion", "demands.d[0](d|0).pairs", Fraction(1), 2), - ("router", "demand.d|0|0.router.s", Fraction(1, 2), 2), - ("router", "demand.d|0|1.router.s", Fraction(1, 2), 2), - } - - -def test_a_pinned_dag_entry_receives_its_weighted_share_of_the_head(): - # Two entries of weight 1 at s: the DAG entry carries 1/2 and splits it - # at a: p * 1/2 -> 2^-11 (the head's whole amount would give 2^-10). - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s: {}, a: {}, b: {}, c: {}, d: {}, t: {}} - links: - - {source: s, target: a, capacity: 100} - - {source: a, target: c, capacity: 100} - - {source: a, target: d, capacity: 100} - - {source: c, target: t, capacity: 100} - - {source: d, target: t, capacity: 100} - - {source: s, target: b, capacity: 100} - - {source: b, target: t, capacity: 100} -supports: - split_at_a: - edges: - - {link: "s|a|0", direction: forward} - - {link: "a|c|0", direction: forward} - - {link: "a|d|0", direction: forward} - - {link: "c|t|0", direction: forward} - - {link: "d|t|0", direction: forward} -routes: - r1: {head: s, support: split_at_a, weight: 1} - r2: {head: s, nodes: [s, b, t], weight: 1} -demands: - d: - - source: "^s$" - target: "^t$" - volume: 1 - model: {routing: {type: pinned, routes: [r1, r2], allocation: equal}} -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - assert prepared.topology.q == 2**-11 - assert splits(prepared) == { - ("tunnel", "demand.d|0|0.entries.s", Fraction(1), 2), - ("router", "demand.d|0|0.entry[0].router.a", Fraction(1, 2), 2), - } - - -def test_a_weighted_dag_passes_each_router_its_weighted_share(): - # WCMP by capacity at s: a receives 300/400 and splits it two ways: - # p * 3/4 = 7.5e-4 -> 2^-11; the incoming amounts name each share. - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s: {}, a: {}, b: {}, c: {}, d: {}, e: {}, t: {}} - links: - - {source: s, target: a, capacity: 300} - - {source: s, target: b, capacity: 100} - - {source: a, target: c, capacity: 100} - - {source: a, target: d, capacity: 100} - - {source: b, target: e, capacity: 100} - - {source: c, target: t, capacity: 100} - - {source: d, target: t, capacity: 100} - - {source: e, target: t, capacity: 100} -demands: - d: [{source: "^s$", target: "^t$", volume: 1, preset: SHORTEST_PATHS_WCMP}] -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - assert prepared.topology.q == 2**-11 - assert splits(prepared) == { - ("router", "demand.d|0|0.router.s", Fraction(1), 2), - ("router", "demand.d|0|0.router.a", Fraction(3, 4), 2), - } - - -def test_a_placed_demand_shares_its_volume_only_among_sources_that_reach(): - # s2 reaches no destination, so s1 carries the whole volume across its - # two-way router: p * 1 -> 2^-10 (counting s2 would halve it: 2^-11). - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s1: {}, s2: {}, a: {}, b: {}, t: {}, x: {}} - links: - - {source: s1, target: a, capacity: 100} - - {source: s1, target: b, capacity: 100} - - {source: a, target: t, capacity: 100} - - {source: b, target: t, capacity: 100} - - {source: s2, target: x, capacity: 100} -demands: - d: [{source: "^s[12]$", target: "^t$", volume: 1}] -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - assert prepared.topology.q == 2**-10 - assert splits(prepared) == {("router", "demand.d|0|0.router.s1", Fraction(1), 2)} - - -def test_a_capacity_question_shares_only_among_sources_that_reach(): - # Equal source allocation over s1 (reaches t, capacity 1) and s2 (does - # not): one live source, no split; the inputs decide: 2p * 1 -> 2^-9. - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s1: {}, s2: {}, t: {}, x: {}} - links: - - {source: s1, target: t, capacity: 1} - - {source: s2, target: x, capacity: 1} -workflow: - - type: MaxFlow - name: cap - source: "^s[12]$" - target: "^t$" - workers: 1 - model: {routing: {type: centralized_te, source_allocation: equal}} -""" - ) - ) - assert prepared.topology.q == 2**-9 - assert splits(prepared) == set() - - -def test_the_range_screen_counts_the_private_pool_ingress(): - # Link capacity C = 1.5 * 2^40, both directions, and the pool ingress C: - # 4.5 * 2^40 / q < 2^62 holds from 2^-19 (the physical sum alone allows - # 2^-20). The 16-tunnel split cannot meet p, so the finest valid q wins. - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s: {}, t: {}} - links: [{source: s, target: t, capacity: 1649267441664}] -demands: - d: [{source: "^s$", target: "^t$", volume: 0.0143, preset: TE_ECMP_16_LSP}] -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - report = prepared.units_report - assert prepared.topology.q == 2**-19 and not report.target_met - assert report.augmented_capacity_sum_units == 9 * 2**40 * 2**18 - - -def test_a_minimum_unreserved_bandwidth_is_an_input_amount(): - # min_unreserved_bandwidth 0.001: 2p * 0.001 = 2e-6 -> 2^-19. - prepared = prepare( - Scenario.from_yaml( - """ -network: - nodes: {s: {}, t: {}} - links: [{source: s, target: t, capacity: 10}] -demands: - d: - - source: "^s$" - target: "^t$" - volume: 1 - model: - routing: - type: distributed_te - tunnel_count: {per_head: 1} - min_unreserved_bandwidth: 0.001 -workflow: - - {type: TrafficMatrixPlacement, name: tmp, demand_set: d, workers: 1} -""" - ) - ) - report = prepared.units_report - assert prepared.topology.q == 2**-19 and report.input_count == 3 - assert [c.label for c in report.binding_constraints] == [ - "demand.d|0.min_unreserved" - ] - - -def one_link(): - network = Network(nodes={"s": Node("s"), "t": Node("t")}) - network.add_link(Link("s", "t", 1)) - return network - - -T1 = "{routing: {type: distributed_te, tunnel_count: {per_head: 1}EXTRA}}" - - -@pytest.mark.parametrize( - "fields,label,q", - [ - # 2p * 0.2505 = 5.01e-4 -> 2^-11; the capacity alone gives 2^-9. - ("offer: 0.2505", "workflow.ask.offer", 2**-11), - ( - f"design_demand: 0.2505, model: {T1.replace('EXTRA', '')}", - "workflow.ask.design_demand", - 2**-11, - ), - # 2p * 0.001 = 2e-6 -> 2^-19. - ( - "design_demand: 1, " - f"model: {T1.replace('EXTRA', ', min_unreserved_bandwidth: 0.001')}", - "workflow.ask.min_unreserved", - 2**-19, - ), - ], - ids=["offer", "design", "minimum"], -) -def test_preparation_collects_every_capacity_step_amount(fields, label, q): - prepared = prepare( - Scenario.from_yaml( - f""" -network: - nodes: {{s: {{}}, t: {{}}}} - links: [{{source: s, target: t, capacity: 1}}] -workflow: - - {{type: MaxFlow, name: ask, source: "^s$", target: "^t$", workers: 1, {fields}}} -""" - ) - ) - report = prepared.units_report - assert prepared.topology.q == q and report.target_met - assert [c.label for c in report.binding_constraints] == [label] - - -@dataclass -class Custom(WorkflowStep): - """A custom capacity step that declares one amount of its own.""" - - def needs(self): - return StepNeeds( - numerical=True, - capacity=lambda prepared: CapacityQuery( - Selector("^s$"), Selector("^t$"), workers=1 - ), - inputs={"amount": ("offer", 0.2505)}, - ) - - def run(self, context): - return StepOutput({}, None) - - -def test_a_custom_step_amount_determines_q_and_the_report(): - prepared = prepare(Scenario(one_link(), (Custom(name="custom"),))) - report = prepared.units_report - assert prepared.topology.q == 2**-11 and report.input_count == 2 - assert [c.label for c in report.binding_constraints] == ["workflow.custom.amount"] - - -def test_candidates_above_the_declared_split_bounds_are_never_compiled(monkeypatch): - compiled = [] - original = preparation.compile_topology - - def counted(network, q, **kwargs): - compiled.append(q) - return original(network, q, **kwargs) - - monkeypatch.setattr(preparation, "compile_topology", counted) - text = Path("scenarios/square_mesh.yaml").read_text() - prepared = prepare(Scenario.from_yaml(text)) - # Twelve eligible pairs give q <= p * 12 / 11: 2^-9 is too coarse; - # 2^-10 is the coarsest legal candidate and is compiled just once. - assert compiled == [prepared.topology.q] == [2**-10] diff --git a/tests/analysis/test_query_publication_faults.py b/tests/analysis/test_query_publication_faults.py index 68cc5bd..3cc6838 100644 --- a/tests/analysis/test_query_publication_faults.py +++ b/tests/analysis/test_query_publication_faults.py @@ -30,7 +30,6 @@ ("capacity", "ngraph.analysis.capacity", "CapacityRow", 5), ("capacity", "ngraph.analysis.capacity", "CaseState", 1), ("capacity", "ngraph.analysis.capacity", "CaseResult", 1), - ("capacity", "ngraph.analysis.capacity", "query_miss", 1), ("capacity", "ngraph.analysis.capacity", "CapacityAnalysis", 1), ("spf", "netgraph_core", "spf", 5), ("spf", "netgraph_core", "resolve_to_paths", 5), @@ -40,7 +39,6 @@ ("ksp", "ngraph.analysis.paths", "PathRow", 5), ("ksp", "ngraph.analysis.paths", "PathAnalysis", 1), ("sensitivity", "netgraph_core", "evaluate_edge_failures", 1), - ("sensitivity", "ngraph.analysis.sensitivity", "query_miss", 1), ("sensitivity", "ngraph.analysis.sensitivity", "SensitivityAnalysis", 1), ] @@ -104,7 +102,7 @@ def test_query_publication_failure_keeps_previous_result_and_same_thread_retry( previous = call() expected = project(previous) capacities = tuple(int(v) for v in prepared.topology.graph.edge_capacity_units()) - observations = prepared._input_observations + report = prepared.units_report if grown: assert project(call()) == expected with monkeypatch.context() as patch: @@ -119,6 +117,6 @@ def test_query_publication_failure_keeps_previous_result_and_same_thread_retry( tuple(int(v) for v in prepared.topology.graph.edge_capacity_units()) == capacities ) - assert prepared._input_observations is observations + assert prepared.units_report is report assert project(previous) == expected assert project(call()) == expected diff --git a/tests/analysis/test_record_selection.py b/tests/analysis/test_record_selection.py index da7c81b..f42e5ab 100644 --- a/tests/analysis/test_record_selection.py +++ b/tests/analysis/test_record_selection.py @@ -102,10 +102,7 @@ def records(owner): (row,) = result.cases[0].states["converged"].rows assert row.result.delivered == row.reference_request_units == 4 assert row.result.source_drops == 0 and not row.result.edge_drops - assert len(calls) == int( - kind in ("split", "residual", "equal", "weighted") - or (kind == "optimized_spf" and costs) - ) + assert len(calls) == int(costs and kind in ("split", "residual", "optimized_spf")) report = row.cost_distribution if not costs: assert report is None and not row.unavailable_outputs @@ -120,7 +117,7 @@ def records(owner): ] == [(3, 3, 4)] -def test_later_residual_weights_keep_earlier_controller_records(): +def test_later_residual_weights_use_capacity_left_by_earlier_demand(): network = Network(nodes={name: Node(name) for name in ("s", "a", "b", "t")}) for source, target, capacity in ( ("s", "a", 4), @@ -179,7 +176,7 @@ def test_later_residual_weights_keep_earlier_controller_records(): if reference.direction == "forward" and reference.link in ("s|b|0", "b|t|0") else 0 ) - # The second policy has one positive member at each router after the first - # consumes a's branch. Using nominal 4/8 would invent a two-member split - # with q bound 2*.2*4/3 < 1, falsely missing the precision target. - assert result.units_report.target_met + # The report is the preparation's input/count budget: L=12, Gamma=5, + # c_min=4 and q1 give 5/4, outside p=1/5 even with exact routing. + assert result.units_report.achieved_precision == 5 / 4 + assert not result.units_report.target_met diff --git a/tests/analysis/test_selection_invariants.py b/tests/analysis/test_selection_invariants.py index aa977d7..17d46b3 100644 --- a/tests/analysis/test_selection_invariants.py +++ b/tests/analysis/test_selection_invariants.py @@ -1,4 +1,4 @@ -"""Router-only precision conflicts and direct shared-group membership.""" +"""Range/input conflicts and direct shared-group membership.""" from ngraph import ( Condition, @@ -20,7 +20,7 @@ ) -def test_finest_range_valid_q_when_only_router_split_conflicts(): +def test_static_input_bound_and_range_can_both_be_satisfied(): n = Network( nodes={k: Node(k) for k in ["A", "D", "X", "Y", *(f"M{i}" for i in range(8))]} ) @@ -34,11 +34,11 @@ def test_finest_range_valid_q_when_only_router_split_conflicts(): demand_sets=("d",), ) report = p.units_report - # Eight equal branches: q<=0.5*4/(8-1)=2/7, so power-of-two q<=0.25; directed capacity - # sum at q=0.25 exceeds2^62, while q=0.5 is range-valid. - assert report.q == 0.5 and report.target_met is False - assert any(c.kind == "router" for c in report.binding_constraints) - assert any(c.kind == "capacity_sum" for c in report.binding_constraints) + # v_min=4 gives B_in=4; L=12+34=46, Gamma=13.5 and c_min=64 + # give B_split=64/27>2. q=2 fits range as well as both numeric bounds. + assert report.q == 2 and report.target_met is True + assert report.limited_by == "split_count" and report.range_limit is None + assert report.split_bound.count == 27 / 2 def test_shared_group_expansion_uses_direct_not_child_members(): diff --git a/tests/analysis/test_simultaneous.py b/tests/analysis/test_simultaneous.py index 7c5324f..4192b7e 100644 --- a/tests/analysis/test_simultaneous.py +++ b/tests/analysis/test_simultaneous.py @@ -31,7 +31,7 @@ run, ) from ngraph.analysis.topology import compile_topology -from ngraph.model.network import DirectedLink, Link +from ngraph.model.network import Link from ngraph.results import ForwardingFailure, SyntheticIngress from ngraph.workflow.maximum_supported_demand_step import MaximumSupportedDemand from ngraph.workflow.traffic_matrix_placement_step import TrafficMatrixPlacement @@ -462,47 +462,6 @@ def test_a_simultaneous_baseline_outcome_with_converged_only(): ] -def test_precision_reads_the_common_view_in_a_simultaneous_list(): - # a is pinned to the second link and fills it (20 of 20); b weighs both links by - # residual snapshot and sends one unit, which H puts on the first link. Ordered: - # b reads the second link's residual 0, prunes it and does not split. In one - # simultaneous list b reads the common view (a released): two members, as b - # alone. Reading a's flow out of the view would prune the second link again. - links = [("S", "T", 20), ("S", "T", 20)] - second = DirectedLink("S|T|1", "forward") - pinned = PinnedRoutes(("a",), admission=LOSSY) - routes = {"a": RouteSpec("S", edges=(second,))} - both = prepared( - links, - [ - demand("^S$", "^T$", 20, pinned, id="a"), - demand("^S$", "^T$", 1, RESIDUAL, id="b", priority=1), - ], - routes=routes, - ) - alone = prepared(links, [demand("^S$", "^T$", 1, RESIDUAL, id="b")]) - - def bounds(analysis): - return sorted( - c.q_upper_bound - for miss in analysis.misses - for c in miss.constraints - if getattr(c, "kind", None) == "router" and c.label.startswith("demand.b") - ) - - ordered = place( - both, PlacementQuery(both.demand_sets["d"], workers=1, states=["converged"]) - ) - together = simultaneous(both, states=["converged"]) - single = place( - alone, PlacementQuery(alone.demand_sets["d"], workers=1, states=["converged"]) - ) - rows = together.cases[0].states["converged"].rows - assert [r.result.delivered for r in rows] == [20, 1] - assert bounds(ordered) == [] - assert bounds(together) == bounds(single) != [] - - # --- pool offer budgets ------------------------------------------------------------------------------------------ @@ -681,10 +640,7 @@ def test_a_budget_counts_in_the_range_check(): assert int(lossy.topology.graph.edge_capacity_units().sum()) < 2**62 (pooled,) = lossy.demand_sets["d"].demands assert pooled.pool.offer_limit_units > 0 - assert any( - c.label == "topology.augmented_capacity_sum" - for c in lossy.units_report.binding_constraints - ) + assert lossy.units_report.range_limit.label == "topology.augmented_capacity_sum" def test_automatic_resolution_passes_a_q_whose_budget_does_not_fit(): diff --git a/tests/analysis/test_source_boundaries.py b/tests/analysis/test_source_boundaries.py deleted file mode 100644 index b590d6e..0000000 --- a/tests/analysis/test_source_boundaries.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Source precision follows positive reachable senders and the actual support.""" - -from fractions import Fraction - -import netgraph_core as core -import pytest - -from ngraph import ( - CapacityQuery, - DemandSet, - DemandSpec, - DirectedLink, - FailureMode, - FailurePolicy, - FailureRequest, - FailureRule, - Link, - Network, - Node, - PlacementQuery, - Scenario, - Selector, - SensitivityQuery, - TunnelCountQuery, - UnitsSettings, - capacity, - edge_sensitivity, - place, - prepare, - resolve_demands, - run, - tunnel_counts, -) -from ngraph.analysis.programs import dag_from_edges -from ngraph.results import SelfPair -from ngraph.workflow import MaxFlow - - -def network(): - value = Network(nodes={name: Node(name) for name in ("a", "b", "t")}) - for source in ("a", "b"): - value.add_link(Link(source, "t", 100)) - return value - - -def weighted(weights): - return core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=core.SourceAllocation.WEIGHTED, - source_weights=weights, - ) - ) - - -def check_source(constraints, label, amount, basis="declared"): - assert [ - ( - c.kind, - c.label, - c.incoming_physical, - c.basis, - c.members, - c.q_upper_bound, - c.met, - ) - for c in constraints - ] == [ - ( - "source", - label + ".sources", - Fraction(amount), - basis, - 2, - Fraction(0.075) * amount, - False, - ) - ] - - -@pytest.mark.parametrize("weights", [(1, 1, 0), (2, 1, 0)]) -def test_weighted_sources_have_the_same_local_bound_on_every_reader(weights): - model = weighted(weights) - demands = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, model),)) - prepared = prepare( - Scenario(network(), demand_sets={"d": demands}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - assert prepared.units_report.target_met is False - check_source(prepared.units_report.nominal_splits, "demand.d|0|0", 10) - answer = capacity( - prepared, - CapacityQuery(Selector("a|b"), Selector("t"), model, offer=10, workers=1), - ) - assert len(answer.misses) == 1 - check_source(answer.misses[0].constraints, "capacity.pair[0]", 10) - placed = place(prepared, PlacementQuery(prepared.demand_sets["d"], workers=1)) - assert len(placed.misses) == 1 - check_source(placed.misses[0].constraints, "demand.d|0|0", 10) - small = network() - for key in tuple(small.links): - small.replace_link(key, capacity=4) - sensitive = edge_sensitivity( - prepare(Scenario(small, units=UnitsSettings(1, 0.075))), - SensitivityQuery(Selector("a|b"), Selector("t"), model, workers=1), - ) - amount = 8 if weights[0] == 1 else 6 - assert sensitive.result.baseline.delivered == amount - constraint = next( - c - for m in sensitive.misses - for c in m.constraints - if c.label == "sensitivity.baseline.sources" - ) - check_source((constraint,), "sensitivity.baseline", amount, "observed") - - -@pytest.mark.parametrize("weights", [(1, 0, 0), (0, 0, 0), (0, 1, 0)]) -def test_zero_weight_sources_do_not_invent_a_split(weights): - model = weighted(weights) - d = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, model),)) - p = prepare( - Scenario(network(), demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - assert p.units_report.nominal_splits == () - assert p.units_report.target_met is True - for offer in (0, 10): - answer = capacity( - p, - CapacityQuery( - Selector("a|b"), Selector("t"), model, offer=offer, workers=1 - ), - ) - assert answer.misses == () - assert place(p, PlacementQuery(p.demand_sets["d"], workers=1)).misses == () - - -@pytest.mark.parametrize("count", [1, 2]) -@pytest.mark.parametrize("live", [0, 1, 2]) -def test_tunnel_precision_support_equals_the_same_edges_masked(count, live): - p = prepare(Scenario(network(), units=UnitsSettings(1, 0.075))) - forward = [ - p.topology._edge_id_by_ref[DirectedLink(f"{s}|t|0", "forward")] - for s in ("a", "b") - ] - edges = forward[:live] - support = dag_from_edges(p.topology.graph, edges) - supported = core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=core.SourceAllocation.EQUAL, - support=support, - tunnel_count=core.TunnelCount(count), - ), - admission=core.Admission.LOSSY, - ) - unrestricted = core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=core.SourceAllocation.EQUAL, - tunnel_count=core.TunnelCount(count), - ), - admission=core.Admission.LOSSY, - ) - mask = p.topology.graph.eligibility( - excluded_edges=[ - e for e in range(p.topology.graph.num_edges()) if e not in edges - ] - ) - answers = [] - for config, eligibility in ((supported, None), (unrestricted, mask)): - answer = capacity( - p, - CapacityQuery( - Selector("a|b"), - Selector("t"), - config, - offer=10, - design_demand=10, - failures=( - FailureRequest( - FailurePolicy( - modes=( - FailureMode( - 1, - ( - FailureRule( - "link", - path=".*" if live == 0 else r"^b\|t$", - ), - ), - ), - ) - ), - 1, - 1, - ) - if eligibility is not None and live < 2 - else None - ), - workers=1, - ), - ) - row = answer.cases[-1].states["converged"].rows[0] - assert row.result.plan.design_by_head == tuple( - (i, 10 // live) for i in range(live) - ) - constraints = tuple( - c - for m in answer.misses - if m.location.case_id == answer.cases[-1].case.id - for c in m.constraints - ) - answers.append( - tuple( - (c.kind, c.incoming_physical, c.members, c.q_upper_bound, c.met) - for c in constraints - ) - ) - expected = ( - [("source", Fraction(10), 2, Fraction(0.075) * 10, False)] - if live == 2 - else [] - ) - if count == 2: - expected.extend( - ( - "tunnel", - Fraction(10, live), - 2, - Fraction(0.075) * Fraction(10, live), - False, - ) - for _ in range(live) - ) - assert list(answers[-1]) == expected - assert all(c.basis == "declared" for c in constraints) - assert [c.label for c in constraints] == [ - *(["capacity.pair[0].sources"] if live == 2 else []), - *(f"capacity.pair[0].head[{head}]" for head in range(live) if count == 2), - ] - assert sum(c.kind == "source" for c in constraints) == (live == 2) - assert sum(c.kind == "tunnel" for c in constraints) == ( - live if count == 2 else 0 - ) - demands = resolve_demands( - p, DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, config),)) - ) - counted = tunnel_counts( - p, - TunnelCountQuery( - demands, (count,), 10, 10, eligibility=eligibility, workers=1 - ), - ) - assert ( - counted.rows[0].result.plan.design_by_head == row.result.plan.design_by_head - ) - assert ( - tuple( - (c.kind, c.incoming_physical, c.members, c.q_upper_bound, c.met) - for m in counted.misses - for c in m.constraints - ) - == answers[-1] - ) - assert answers[0] == answers[1] - - -@pytest.mark.parametrize("mode", ["combine", "pairwise"]) -@pytest.mark.parametrize("valid", [False, True]) -def test_diagonal_capacity_validates_the_same_model_as_other_pairs(mode, valid): - n = Network(nodes={s: Node(s) for s in ("a", "b")}) - n.add_link(Link("a", "b", 10)) - model = core.FlowPolicyConfig( - routing=core.ShortestPaths( - allocation=core.RouteAllocation.WEIGHTED, - weight_basis=core.WeightBasis.EXPLICIT, - edge_weights=[1, 1] if valid else [1], - ) - ) - p = prepare(Scenario(n, units=UnitsSettings(1))) - query = CapacityQuery(Selector("a"), Selector("a"), model, mode=mode, workers=1) - step = MaxFlow(source=Selector("a"), target=Selector("a"), model=model, mode=mode) - if valid: - rows = capacity(p, query).cases[0].states["converged"].rows - assert len(rows) == 1 and isinstance(rows[0], SelfPair) - assert rows[0].node == "a" - result = run(prepare(Scenario(n, [step], units=UnitsSettings(1)))) - assert isinstance( - result.steps[0].result.cases[0].states["converged"].rows[0], SelfPair - ) - else: - with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): - capacity(p, query) - with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): - prepare(Scenario(n, [step], units=UnitsSettings(1))) - with pytest.raises(ValueError, match="edge_weights: expected 2 entries"): - capacity(p, CapacityQuery(Selector("a"), Selector("b"), model, workers=1)) - - -def test_positive_but_unreachable_weighted_sources_have_no_share_or_constraint(): - n = network() - n.replace_link("b|t|0", disabled=True) - model = weighted((1, 1, 0)) - d = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, model),)) - p = prepare( - Scenario(n, demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - assert p.units_report.nominal_splits == () - assert place(p, PlacementQuery(p.demand_sets["d"], workers=1)).misses == () - assert ( - capacity( - p, CapacityQuery(Selector("a|b"), Selector("t"), model, offer=10, workers=1) - ).misses - == () - ) - - -def test_failure_eligibility_removes_weighted_source_precision_in_both_readers(): - model = weighted((1, 1, 0)) - d = DemandSet("d", (DemandSpec(Selector("a|b"), Selector("t"), 10, model),)) - p = prepare( - Scenario(network(), demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - failures = FailureRequest( - FailurePolicy(modes=(FailureMode(1, (FailureRule("link", path=r"^b\|t$"),)),)), - 1, - 1, - ) - for answer in ( - place(p, PlacementQuery(p.demand_sets["d"], failures=failures, workers=1)), - capacity( - p, - CapacityQuery( - Selector("a|b"), - Selector("t"), - model, - offer=10, - failures=failures, - workers=1, - ), - ), - ): - assert len(answer.cases) == 2 - assert len(answer.misses) == 1 - assert answer.misses[0].location.case_id == "baseline" - - -def test_each_placed_tunnel_plan_stays_paired_with_its_own_demand(): - model = core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=core.SourceAllocation.EQUAL, - tunnel_count=core.TunnelCount(1), - ) - ) - d = DemandSet( - "d", - ( - DemandSpec(Selector("a|b"), Selector("t"), 10, model), - DemandSpec(Selector("a"), Selector("t"), 20, model), - ), - ) - p = prepare( - Scenario(network(), demand_sets={"d": d}, units=UnitsSettings(1, 0.075)), - demand_sets=("d",), - ) - check_source(p.units_report.nominal_splits, "demand.d|0|0", 10) - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert len(answer.misses) == 1 - check_source(answer.misses[0].constraints, "demand.d|0|0", 10) diff --git a/tests/analysis/test_split_rules.py b/tests/analysis/test_split_rules.py deleted file mode 100644 index 687841b..0000000 --- a/tests/analysis/test_split_rules.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Placement and capacity questions split the same traffic by the same rules.""" - -from fractions import Fraction - -import netgraph_core as ngc -import pytest - -from ngraph import ( - CapacityQuery, - DemandSet, - DemandSpec, - EdgeWeight, - FailureMode, - FailurePolicy, - FailureRequest, - FailureRule, - Link, - Network, - Node, - PinnedRoutes, - PlacementQuery, - RouteSpec, - Scenario, - Selector, - SupportSpec, - UnitsSettings, - WeightSpec, - capacity, - place, - prepare, -) -from ngraph.model.demand import FlowPolicyPreset -from ngraph.model.network import DirectedLink -from ngraph.results import SplitConstraint -from ngraph.workflow.max_flow_step import MaxFlow - -PLACED = "demand.load|0|0" -ASKED = "workflow.ask.pair[0]" - - -def diamond(sources=("s",)): - network = Network(nodes={name: Node(name) for name in (*sources, "a", "b", "t")}) - for source in sources: - network.add_link(Link(source, "a", 10, 1)) - network.add_link(Link(source, "b", 10, 1)) - network.add_link(Link("a", "t", 10, 1)) - network.add_link(Link("b", "t", 10, 1)) - return network - - -def splits(policy, network, design=None, **declared): - """The nominal splits of one placed demand and of one capacity question.""" - source = Selector("^s") - scenario = Scenario( - network, - [ - MaxFlow( - name="ask", - source=source, - target=Selector("^t$"), - offer=8, - design_demand=design, - **policy, - ) - ], - demand_sets={ - "load": DemandSet( - "load", - (DemandSpec(source, Selector("^t$"), 8, next(iter(policy.values()))),), - ) - }, - units=UnitsSettings(1, 0.5), - **declared, - ) - report = prepare(scenario, demand_sets=("load",)).units_report - found = {} - for prefix in (PLACED, ASKED): - found[prefix] = { - (c.kind, c.label.removeprefix(prefix), c.incoming_physical, c.members) - for c in report.nominal_splits - if c.label.startswith(prefix) - } - return found[PLACED], found[ASKED] - - -def test_shortest_paths_router_split_is_the_same_for_both_questions(): - placed, asked = splits({"preset": FlowPolicyPreset.SHORTEST_PATHS_ECMP}, diamond()) - assert placed == asked == {("router", ".router.s", Fraction(8), 2)} - - -def test_weighted_pinned_dag_split_is_the_same_for_both_questions(): - network = diamond() - links = {(link.source, link.target): link.id for link in network.links.values()} - edges = tuple( - DirectedLink(links[pair], "forward") - for pair in (("s", "a"), ("s", "b"), ("a", "t"), ("b", "t")) - ) - weights = WeightSpec(tuple(EdgeWeight(edge, 1) for edge in edges)) - declaration = PinnedRoutes( - ("dag",), - allocation=ngc.RouteAllocation.WEIGHTED, - weight_basis=ngc.WeightBasis.EXPLICIT, - edge_weights="w", - ) - placed, asked = splits( - {"model": declaration}, - network, - routes={"dag": RouteSpec("s", support="d")}, - supports={"d": SupportSpec(edges)}, - weights={"w": weights}, - ) - assert placed == asked == {("router", ".entry[0].router.s", Fraction(8), 2)} - - -@pytest.mark.parametrize( - "routing", - [ - ngc.DistributedTE(ngc.TunnelCount(2)), - ngc.CentralizedTE( - source_allocation=ngc.SourceAllocation.EQUAL, - tunnel_count=ngc.TunnelCount(2), - ), - ], -) -def test_tunnel_source_and_head_splits_are_the_same_for_both_questions(routing): - network = diamond(("s1", "s2")) - heads = sorted(network.nodes).index("s1"), sorted(network.nodes).index("s2") - placed, asked = splits( - {"model": ngc.FlowPolicyConfig(routing=routing)}, network, design=8 - ) - assert ( - placed - == asked - == { - ("source", ".sources", Fraction(8), 2), - *(("tunnel", f".head[{head}]", Fraction(4), 2) for head in heads), - } - ) - - -def failed_route_splits(routes, declaration, supports=None): - """Split constraints of the case that fails node a, for both questions.""" - network = Network(nodes={name: Node(name) for name in ("s", "a", "b", "c", "t")}) - for middle in ("a", "b", "c"): - network.add_link(Link("s", middle, 20, 1)) - network.add_link(Link(middle, "t", 20, 1)) - demand = DemandSpec(Selector("^s$"), Selector("^t$"), 12, declaration) - prepared = prepare( - Scenario( - network, - demand_sets={"d": DemandSet("d", (demand,))}, - routes=routes, - supports=supports or {}, - units=UnitsSettings(1, 0.01), - ), - demand_sets=("d",), - ) - policy = FailurePolicy(modes=(FailureMode(1, (FailureRule("node", path="^a$"),)),)) - request = FailureRequest(policy, 1, 1) - placed = place( - prepared, PlacementQuery(prepared.demand_sets["d"], failures=request, workers=1) - ) - asked = capacity( - prepared, - CapacityQuery( - Selector("^s$"), - Selector("^t$"), - prepared.demand_sets["d"].demands[0].config, - offer=12, - failures=request, - workers=1, - declaration=declaration, - ), - ) - assert placed.cases[1].states["converged"].rows[0].result.delivered == 12 - - def failed(analysis): - return { - (c.kind, c.incoming_physical, c.members) - for miss in analysis.misses - if miss.location.case_id == "failure-000001" - for c in miss.constraints - if isinstance(c, SplitConstraint) - } - - return failed(placed), failed(asked) - - -def test_a_failed_fixed_route_is_not_a_live_split_for_either_question(): - routes = { - "a": RouteSpec("s", nodes=("s", "a", "t")), - "b": RouteSpec("s", nodes=("s", "b", "t")), - } - placed, asked = failed_route_splits(routes, PinnedRoutes(("a", "b"))) - # Only route b survives the case, so the 12 units do not split. - assert placed == asked == set() - - -def test_a_failed_weighted_dag_sibling_leaves_the_whole_amount_to_the_live_dag(): - network_edges = (("s", "b"), ("b", "t"), ("s", "c"), ("c", "t")) - routes = { - "a": RouteSpec("s", nodes=("s", "a", "t"), weight=1), - "dag": RouteSpec("s", support="d", weight=2), - } - - def edge(source, target): - return DirectedLink(f"{source}|{target}|0", "forward") - - supports = {"d": SupportSpec(tuple(edge(*pair) for pair in network_edges))} - placed, asked = failed_route_splits(routes, PinnedRoutes(("a", "dag")), supports) - # Route a fails: the DAG gets 12 * 2/2, not 12 * 2/3, and splits it at s. - assert placed == asked == {("router", Fraction(12), 2)} - - -@pytest.mark.parametrize( - ("allocation", "router_members", "offered", "flows"), - [ - # ECMP keeps the zero-capacity member: s splits 10 over two members, 5 each; the - # zero link drops its 5. - (ngc.RouteAllocation.EQUAL, 2, {"s|a|0": 5, "s|b|0": 5}, {"s|b|0": 5}), - # Capacity WCMP prunes it: no router split, all 10 offered to s->b (5 carried). - (ngc.RouteAllocation.WEIGHTED, None, {"s|b|0": 10}, {"s|b|0": 5}), - ], -) -def test_python_split_facts_and_native_offers_are_twins( - allocation, router_members, offered, flows -): - # s->a capacity 0 and s->b 5 (then a->t, b->t 5, all cost 1); LOSSY offer 10, q = 1. - network = Network() - for name in ("s", "a", "b", "t"): - network.add_node(Node(name)) - for source, target, value in ( - ("s", "a", 0), - ("a", "t", 5), - ("s", "b", 5), - ("b", "t", 5), - ): - network.add_link(Link(source, target, value, 1)) - routing = ( - ngc.ShortestPaths(allocation=allocation) - if allocation == ngc.RouteAllocation.EQUAL - else ngc.ShortestPaths( - allocation=allocation, weight_basis=ngc.WeightBasis.CAPACITY - ) - ) - config = ngc.FlowPolicyConfig(routing=routing, admission=ngc.Admission.LOSSY) - demands = DemandSet( - "load", (DemandSpec(Selector("^s$"), Selector("^t$"), 10, config),) - ) - prep = prepare( - Scenario(network, demand_sets={"load": demands}, units=UnitsSettings(1)), - demand_sets=("load",), - ) - routers = [ - (split.members, split.incoming_physical) - for split in prep.units_report.nominal_splits - if split.kind == "router" - ] - assert routers == ( - [] if router_members is None else [(router_members, Fraction(10))] - ) - analysis = place( - prep, - PlacementQuery( - prep.demand_sets["load"], - workers=1, - outputs=ngc.OutputOptions(edge_flows=True), - ), - ) - (row,) = analysis.cases[0].states["converged"].rows - refs = prep.topology.edge_refs - carried = { - refs[e].link: int(v) - for e, v in enumerate(row.result.edge_flows) - if v and refs[e].link.startswith("s|") - } - dropped = {refs[e].link: units for e, units in row.result.edge_drops if units} - native_offered = { - link: carried.get(link, 0) + dropped.get(link, 0) - for link in carried.keys() | dropped.keys() - } - assert native_offered == offered and carried == flows - if router_members is not None: - # Python's equal share of the incoming 10 is what native offers each member. - assert set(native_offered.values()) == {10 // router_members} diff --git a/tests/analysis/test_tunnel_counts.py b/tests/analysis/test_tunnel_counts.py index c4721ad..5e77f16 100644 --- a/tests/analysis/test_tunnel_counts.py +++ b/tests/analysis/test_tunnel_counts.py @@ -780,31 +780,3 @@ def test_a_query_mask_keeps_the_scenario_disabled_elements(): plan.total_tunnel_cost, row.result.delivered, ) == (10, 10, 10, 8) - - -def test_split_misses_read_the_query_mask(): - prep = prepared( - [("S1", "X", 96), ("S2", "X", 96), ("X", "T", 96)], - [spec("^S", "^T$", t2(1), id="te")], - q="auto", - ) - assert prep.topology.q == 1 / 8 - s2_out = prep.topology.graph.eligibility( - excluded_edges=list(prep.topology.edges_by_link["S2|X|0"]) - ) - - def unmet(**fields): - (miss,) = ask(prep, (1,), 3, 3, **fields).misses - return sorted(c.label for c in miss.constraints if not c.met) - - # D = O = 3: atoms need q <= 2 * 0.001 * 3; two live heads split 3 at 0.001 * 3. - assert unmet() == [ - "tunnel_counts.demand[te|0].count[1].sources", - "tunnel_counts.design_demand", - "tunnel_counts.offer", - ] - # With S2's only link excluded one head is live: no sources split. - assert unmet(eligibility=s2_out) == [ - "tunnel_counts.design_demand", - "tunnel_counts.offer", - ] diff --git a/tests/analysis/test_tunnel_resolution.py b/tests/analysis/test_tunnel_resolution.py deleted file mode 100644 index 525b515..0000000 --- a/tests/analysis/test_tunnel_resolution.py +++ /dev/null @@ -1,812 +0,0 @@ -"""A live native tunnel plan with no whole-unit size misses precision.""" - -from copy import deepcopy -from dataclasses import replace -from fractions import Fraction -from typing import get_type_hints - -import jsonschema -import netgraph_core as core -import pytest - -from ngraph import ( - CapacityQuery, - DemandSet, - DemandSpec, - FailureMode, - FailurePolicy, - FailureRequest, - FailureRule, - FlowPolicyPreset, - Link, - MSDQuery, - Network, - Node, - PlacementQuery, - Scenario, - Selector, - SensitivityQuery, - TunnelCountQuery, - UnitsSettings, - capacity, - edge_sensitivity, - maximum_supported_demand, - place, - prepare, - run, - tunnel_counts, -) -from ngraph.analysis.counts import count_splits -from ngraph.analysis.precision import query_splits -from ngraph.analysis.programs import dag_from_edges -from ngraph.export import project as record_wire -from ngraph.workflow import MaxFlow, TunnelCountAnalysis -from tests.results.test_analysis_wire import SCHEMA, project - - -def config(count=16, up_to=False, t1=False, admission=core.Admission.LOSSY): - tunnels = core.TunnelCount(count, up_to=up_to) - routing = ( - core.DistributedTE(tunnels) - if t1 - else core.CentralizedTE( - source_allocation=core.SourceAllocation.EQUAL, tunnel_count=tunnels - ) - ) - return core.FlowPolicyConfig(routing=routing, admission=admission) - - -def scenario( - heads=12, - cap=10, - volume=1000, - count=16, - q=0.5, - up_to=False, - t1=False, - pooled=False, - parallel=False, - huge=False, -): - network = Network( - nodes={ - name: Node(name) for name in (*(f"s{i}" for i in range(heads)), "x", "t") - } - ) - for i in range(heads): - network.add_link(Link(f"s{i}", "x", 1000)) - network.add_link(Link("x", "t", cap / 2 if parallel else cap)) - if parallel: - network.add_link(Link("x", "t", cap / 2)) - if huge: - network.add_node(Node("u")) - network.add_node(Node("v")) - network.add_link(Link("u", "v", float(2**61))) - model = FlowPolicyPreset.TE_ECMP_16_LSP if pooled else config(count, up_to, t1) - demands = DemandSet( - "d", (DemandSpec(Selector("^s"), Selector("^t$"), volume, model),) - ) - return Scenario(network, demand_sets={"d": demands}, units=UnitsSettings(q, 0.1)) - - -def prepared(**kwargs): - return prepare(scenario(**kwargs), demand_sets=("d",)) - - -def two_stars(capacities=(10, 10), q=0.5): - names = [ - name - for prefix in ("a", "b") - for name in (*(f"{prefix}{i}" for i in range(12)), f"x{prefix}", f"t{prefix}") - ] - network = Network(nodes={name: Node(name) for name in names}) - for prefix, cap in zip(("a", "b"), capacities, strict=True): - for i in range(12): - network.add_link(Link(f"{prefix}{i}", f"x{prefix}", 1000)) - network.add_link(Link(f"x{prefix}", f"t{prefix}", cap)) - demands = DemandSet( - "d", - tuple( - DemandSpec( - Selector(f"^{prefix}\\d"), Selector(f"^t{prefix}$"), 1000, config() - ) - for prefix in ("a", "b") - ), - ) - return Scenario(network, demand_sets={"d": demands}, units=UnitsSettings(q, 0.1)) - - -def causes(constraints): - return [record_wire(c) for c in constraints if c.kind == "tunnel_resolution"] - - -def missed(answer): - return causes(c for miss in answer.misses for c in miss.constraints) - - -def expected(label="demand.d|0|0", q=0.5, heads=None): - return { - "kind": "tunnel_resolution", - "label": label, - "q": q, - "heads": sorted(f"s{i}" for i in range(12)) if heads is None else heads, - "met": False, - } - - -def first_result(answer): - return answer.cases[0].states["converged"].rows[0].result - - -def test_manual_and_automatic_resolution_observe_the_shared_link(monkeypatch): - # 12*16=192 paths need one unit each on the shared link. At q=.5 - # its20 units fail; at1/16 its160 fail; at1/32 its320 fit. - import ngraph.analysis.preparation as preparation - - original = preparation._nominal_splits - seen = [] - - def observe(p, demands, precision): - seen.append(p.topology.q) - return original(p, demands, precision) - - monkeypatch.setattr(preparation, "_nominal_splits", observe) - p = prepared() - assert p.units_report.target_met is False - assert causes(p.units_report.binding_constraints) == [expected()] - assert p.units_report.nominal_split_count == 13 - assert len(p.units_report.nominal_splits) == 13 - assert all(c.met for c in p.units_report.nominal_splits) - assert p.units_report.inexact_inputs == p.units_report.zeroed_inputs == 0 - a = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert missed(a) == [expected()] - assert first_result(a).delivered == 0 - assert first_result(a).plan.max_fractional_bandwidth_per_tunnel == Fraction(5, 48) - assert project(run(p))["units_report"]["binding_constraints"][-1] == expected() - seen.clear() - fine = prepared(q="auto") - assert seen == [2, 1, 0.5, 0.25, 0.125, 0.0625, 0.03125] - assert fine.topology.q == 1 / 32 - assert fine.units_report.target_met is True - assert causes(fine.units_report.binding_constraints) == [expected(q=1 / 16)] - assert fine.units_report.nominal_split_count == 13 - assert fine.units_report.misses == () - from tests.typing.valid_client import read_units - - assert read_units(fine.units_report) == 1 / 32 - assert project(run(fine))["units_report"]["binding_constraints"] == [ - expected(q=1 / 16) - ] - answer = place(fine, PlacementQuery(fine.demand_sets["d"], workers=1)) - assert missed(answer) == [] - assert answer.units_report.target_met is True - assert first_result(answer).plan.max_bandwidth_per_tunnel == 1 - assert first_result(answer).delivered == 320 # physical10 - - -def test_workflow_capacity_preparation_checks_each_existing_plan(monkeypatch): - s = scenario() - step = MaxFlow( - name="cap", - source=Selector("^s"), - target=Selector("^t$"), - model=config(), - offer=1000, - design_demand=1000, - workers=1, - ) - calls = [] - original = core.evaluate_demand - - def evaluate(*args, **kwargs): - calls.append(1) - return original(*args, **kwargs) - - monkeypatch.setattr(core, "evaluate_demand", evaluate) - p = prepare(replace(s, workflow=(step,), demand_sets={})) - assert causes(p.units_report.binding_constraints) == [ - expected("workflow.cap.pair[0]") - ] - assert p.units_report.target_met is False - assert len(calls) == 1 - calls.clear() - fine = prepare( - replace(s, workflow=(step,), demand_sets={}, units=UnitsSettings("auto", 0.1)) - ) - assert fine.topology.q == 1 / 32 and fine.units_report.target_met is True - assert causes(fine.units_report.binding_constraints) == [ - expected("workflow.cap.pair[0]", q=1 / 16) - ] - assert len(calls) == 7 # one existing evaluation per candidate, no reporting solve - - -@pytest.mark.parametrize( - "capacities,bad_prefixes", [((10, 20), ("a",)), ((10, 10), ("a", "b"))] -) -def test_automatic_binding_keeps_only_plans_at_the_next_coarser_unit( - capacities, bad_prefixes -): - # At1/16 the first star has160 path units<192; the second has320>=192. - # At1/8 both failed, but that older observation does not bind1/32. - p = prepare(two_stars(capacities, q="auto"), demand_sets=("d",)) - assert p.topology.q == 1 / 32 and p.units_report.target_met is True - want = [ - expected( - f"demand.d|{index}|0", 1 / 16, sorted(f"{prefix}{i}" for i in range(12)) - ) - for index, prefix in enumerate(("a", "b")) - if prefix in bad_prefixes - ] - assert causes(p.units_report.binding_constraints) == want - assert p.units_report.nominal_split_count == 26 - assert p.units_report.misses == () - # Manual preparation does not describe an unobserved coarser plan. - manual = prepare(two_stars(capacities, q=1 / 32), demand_sets=("d",)) - assert manual.units_report.target_met is True - assert causes(manual.units_report.binding_constraints) == [] - - -def test_binding_head_identity_comes_from_the_coarser_observation(): - network = Network( - nodes={name: Node(name) for name in ("s0", "s1", "s2", "s3", "x", "t")} - ) - for i, cap in enumerate((100, 100, 100, 0.75)): - network.add_link(Link(f"s{i}", "x", cap)) - network.add_link(Link("x", "t", 4)) - demands = DemandSet( - "d", (DemandSpec(Selector("^s"), Selector("^t$"), 1000, config(1)),) - ) - p = prepare( - Scenario(network, demand_sets={"d": demands}, units=UnitsSettings("auto", 2)), - demand_sets=("d",), - ) - # p=2 admits candidate q2: the .75 branch rounds to0, leaving3 live - # heads behind2 shared units. At q1 all4 are live and4 shared units fit. - assert p.topology.q == 1 and p.units_report.target_met is True - assert causes(p.units_report.binding_constraints) == [ - expected(q=2, heads=["s0", "s1", "s2"]) - ] - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert len(first_result(answer).plan.heads_with_capacity) == 4 - assert missed(answer) == [] - - -def test_coarser_plan_records_append_to_current_binding_bounds(): - p = prepared(q="auto", volume=90) - assert p.topology.q == p.units_report.q == 1 / 32 - assert p.units_report.target_met is True - assert [c.kind for c in p.units_report.binding_constraints] == ["tunnel"] * 12 + [ - "tunnel_resolution" - ] - # Each of12 heads splits90/12 over16 tunnels: the bound is p/2. - # It lies between1/32 and1/16, so these12 met bounds also bind q. - numeric = [ - c for c in p.units_report.binding_constraints if c.kind != "tunnel_resolution" - ] - assert [(c.kind, c.label, c.q_upper_bound, c.met) for c in numeric] == [ - ("tunnel", f"demand.d|0|0.head[{i}]", Fraction(0.1) / 2, True) - for i in range(12) - ] - assert causes(p.units_report.binding_constraints) == [expected(q=1 / 16)] - - -def test_unsuccessful_automatic_preparation_keeps_only_current_resolution(): - s = scenario(heads=2, cap=2**-32, volume=1, count=2) - p = prepare(replace(s, units=UnitsSettings("auto", 1)), demand_sets=("d",)) - # At both2^-31 and2^-32 the shared link has1 unit<4 paths. The floor - # remains insufficient; historical coarser causes must not be appended. - assert p.topology.q == 2**-32 and p.units_report.target_met is False - assert causes(p.units_report.binding_constraints) == [ - expected(q=2**-32, heads=["s0", "s1"]) - ] - - -def test_each_bad_placement_plan_has_its_own_resolution_record(): - p = prepare(two_stars(), demand_sets=("d",)) - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - want = [ - expected(f"demand.d|{index}|0", heads=sorted(f"{prefix}{i}" for i in range(12))) - for index, prefix in enumerate(("a", "b")) - ] - assert missed(answer) == want - assert causes(p.units_report.binding_constraints) == want - assert [ - row.result.delivered for row in answer.cases[0].states["converged"].rows - ] == [0, 0] - project(answer) - - -def test_each_bad_capacity_pair_has_its_own_resolution_record(): - p = prepare(replace(two_stars(), demand_sets={})) - answer = capacity( - p, - CapacityQuery( - Selector(r"^(a|b)\d"), - Selector("^t(a|b)$"), - config(), - mode="pairwise", - offer=1000, - design_demand=1000, - workers=1, - ), - ) - want = [ - expected( - f"capacity.pair[{index}]", heads=sorted(f"{prefix}{i}" for i in range(12)) - ) - for index, prefix in ((0, "a"), (3, "b")) - ] - assert missed(answer) == want - rows = answer.cases[0].states["converged"].rows - assert len(rows) == 4 - assert [len(row.result.plan.heads_with_capacity) for row in rows] == [12, 0, 0, 12] - assert [row.result.delivered for row in rows] == [0, 0, 0, 0] - project(answer) - - -def test_insufficient_up_to_count_row_has_its_own_resolution_record(): - p = prepared(count=1, q=2) - answer = tunnel_counts( - p, TunnelCountQuery(p.demand_sets["d"], (1,), 1000, 1000, up_to=256, workers=1) - ) - want = [ - expected(f"tunnel_counts.demand[d|0|0].{suffix}", 2) - for suffix in ("count[1]", "up_to[256]") - ] - assert missed(answer) == want - assert [ - ( - row.count, - row.up_to, - row.result.plan.tunnels_per_head, - row.result.plan.insufficient_resolution, - row.result.delivered, - ) - for row in answer.rows - ] == [(1, False, 1, True, 0), (256, True, 1, True, 0)] - project(answer) - - -def test_no_legal_resolution_still_refuses_and_has_a_succeeding_control(): - s = scenario() - s.network.replace_link("x|t|0", capacity=1e100) - with pytest.raises(ValueError, match="no legal resolution represents"): - prepare(replace(s, units=UnitsSettings("auto", 0.1)), demand_sets=("d",)) - assert prepared(q="auto").units_report.target_met is True - - -@pytest.mark.parametrize("offer,design", [(1000, 1000), (0, 0), (1000, 0), (0, 1000)]) -def test_capacity_observes_once_independent_of_offer_and_design(offer, design): - p = prepared() - answer = capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - config(), - offer=offer, - design_demand=design, - workers=1, - ), - ) - assert missed(answer) == [expected("capacity.pair[0]")] - assert first_result(answer).delivered == 0 - assert project(answer)["units_report"]["misses"][0]["constraints"][-1] == expected( - "capacity.pair[0]" - ) - - -def test_zero_nominal_and_lossless_zero_observation_keep_the_constraint(): - p = prepared(volume=0) - assert p.units_report.nominal_split_count == 0 - assert causes(p.units_report.binding_constraints) == [expected()] - assert p.units_report.target_met is False - for design in (None, 1000): - answer = capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - config(admission=core.Admission.LOSSLESS), - design_demand=design, - workers=1, - ), - ) - assert first_result(answer).delivered == 0 - assert missed(answer) == [expected("capacity.pair[0]")] - - -@pytest.mark.parametrize( - "routing", - [ - core.ShortestPaths(), - core.CentralizedTE(source_allocation=core.SourceAllocation.EQUAL), - ], -) -def test_nontunnel_zero_delivery_has_no_plan_constraint(routing): - p = prepared(cap=0) - answer = capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - core.FlowPolicyConfig(routing=routing, admission=core.Admission.LOSSLESS), - offer=1000, - workers=1, - ), - ) - assert first_result(answer).plan is None - assert first_result(answer).delivered == 0 - assert missed(answer) == [] - - -def test_later_count_query_does_not_rechoose_q_or_change_preparation(): - p = prepared(count=1) - original = p.units_report - assert original.target_met is True - answer = tunnel_counts( - p, - TunnelCountQuery(p.demand_sets["d"], (1, 16), 1000, 1000, up_to=256, workers=1), - ) - assert missed(answer) == [expected("tunnel_counts.demand[d|0|0].count[16]")] - assert [r.result.delivered for r in answer.rows] == [20, 0, 20] - assert [r.result.plan.tunnels_per_head for r in answer.rows] == [1, 16, 1] - assert answer.prepared.units_report is original - assert original.target_met is True and p.topology.q == 0.5 - # This helper is also preparation's count path: it has no native results. - local, extra = count_splits(p, answer.query, "workflow.counts", p.topology.disabled) - assert causes((*local, *extra)) == [] - - -def test_sensitivity_baseline_has_a_plan_but_edge_entries_do_not(monkeypatch): - p = prepared() - import ngraph.analysis.precision as precision - - original = precision.query_splits - observations = [] - - def observe(*args, **kwargs): - splits, extra = original(*args, **kwargs) - observations.append((kwargs.get("result"), causes(extra))) - return splits, extra - - monkeypatch.setattr(precision, "query_splits", observe) - answer = edge_sensitivity( - p, - SensitivityQuery( - Selector("^s"), - Selector("^t$"), - config(), - offer=1000, - design_demand=1000, - workers=1, - ), - ) - assert missed(answer) == [expected("sensitivity.baseline")] - assert observations[0][0] is not None - assert all(plan is None and not extra for plan, extra in observations[1:]) - assert len(observations) == len(answer.result.entries) + 1 - pair = answer.endpoints - splits, extra = query_splits( - p.topology, p._named, answer.query, pair, 0, p.topology.disabled, "no-plan", 0.1 - ) - assert causes((*splits, *extra)) == [] - assert [c.members for c in splits if c.kind == "tunnel"] == [16] * 12 - - -@pytest.mark.parametrize( - "up_to,q,want", [(False, 0.5, True), (True, 2, True), (True, 0.5, False)] -) -def test_fixed_and_up_to_read_actual_resolution(up_to, q, want): - p = prepared(count=256 if up_to else 16, up_to=up_to, q=q) - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert missed(answer) == ([expected(q=q)] if want else []) - result = first_result(answer) - assert result.plan.insufficient_resolution is want - assert result.plan.tunnels_per_head == (1 if up_to else 16) - if up_to and want: - assert [ - c.members for c in p.units_report.nominal_splits if c.kind == "tunnel" - ] == [256] * 12 - - -@pytest.mark.parametrize( - "kwargs", - [{"t1": True}, {"cap": 0}, {"q": 1 / 32}, {"count": 1}, {"q": 1 / 32, "volume": 0}], -) -def test_t1_dead_and_sufficient_plans_do_not_report_resolution(kwargs): - p = prepared(**kwargs) - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert causes(p.units_report.binding_constraints) == missed(answer) == [] - if kwargs.get("t1"): - assert first_result(answer).plan.insufficient_resolution is None - assert first_result(answer).plan.heads_with_capacity is None - - -def test_live_heads_have_native_order_and_exclude_dead_heads(): - n = Network(nodes={s: Node(s) for s in ("z", "a", "dead", "t")}) - for s, cap in (("z", 1), ("a", 100), ("dead", 0)): - n.add_link(Link(s, "t", cap)) - d = DemandSet( - "d", (DemandSpec(Selector("^(z|a|dead)$"), Selector("^t$"), 1000, config(2)),) - ) - p = prepare( - Scenario(n, demand_sets={"d": d}, units=UnitsSettings(1, 0.1)), - demand_sets=("d",), - ) - assert causes(p.units_report.binding_constraints) == [ - expected(q=1, heads=["a", "z"]) - ] - answer = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert missed(answer) == [expected(q=1, heads=["a", "z"])] - assert first_result(answer).plan.heads_with_capacity == (0, 3) - - -@pytest.mark.parametrize("details", [False, True]) -def test_pool_head_identity_is_structured_in_every_observed_reader(details): - s = scenario(q=1, pooled=True) - counts = TunnelCountAnalysis( - name="counts", - demand_set="d", - counts=(16,), - design_demand=1000, - offer=1000, - workers=1, - ) - p = prepare(replace(s, workflow=(counts,)), demand_sets=("d",)) - head = {"pool_id": "d|0|0", "physical_sources": sorted(f"s{i}" for i in range(12))} - nominal = expected("demand.d|0|0", 1, [head]) - assert causes(p.units_report.binding_constraints) == [nominal] - placed = place( - p, PlacementQuery(p.demand_sets["d"], workers=1, export_details=details) - ) - assert missed(placed) == [nominal] - counted = tunnel_counts( - p, - TunnelCountQuery( - p.demand_sets["d"], (16,), 1000, 1000, workers=1, export_details=details - ), - ) - assert missed(counted) == [ - expected("tunnel_counts.demand[d|0|0].count[16]", 1, [head]) - ] - cfg = p.demand_sets["d"].demands[0].config - asked = capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - cfg, - offer=1000, - design_demand=1000, - declaration=FlowPolicyPreset.TE_ECMP_16_LSP, - workers=1, - export_details=details, - ), - ) - assert missed(asked) == [expected("capacity.pair[0]", 1, [head])] - for answer in (placed, counted, asked): - project(answer) - assert project(run(p))["units_report"]["binding_constraints"][-1] == nominal - - -@pytest.mark.parametrize("all_dead", [False, True]) -def test_failure_case_observes_its_own_plan(all_dead): - p = prepared(q=1 / 32, parallel=True) - failure = FailureRequest( - FailurePolicy( - modes=( - FailureMode( - 1, - ( - FailureRule( - "link", - path=r"^x\|t$", - mode="all" if all_dead else "choice", - count=1, - ), - ), - ), - ) - ), - 1, - 7, - ) - for answer in ( - capacity( - p, - CapacityQuery( - Selector("^s"), - Selector("^t$"), - config(), - offer=1000, - design_demand=1000, - failures=failure, - workers=1, - ), - ), - place( - p, - PlacementQuery( - p.demand_sets["d"], - failures=failure, - workers=1, - states=("converged", "stale", "repair"), - ), - ), - ): - assert first_result(answer).plan.max_bandwidth_per_tunnel == 1 - assert len(answer.cases) == 2 - failed = answer.cases[1].states["converged"].rows[0].result - assert failed.plan.max_bandwidth_per_tunnel == 0 - assert len(failed.plan.heads_with_capacity) == (0 if all_dead else 12) - prefix = "capacity.pair[0]" if answer.kind == "capacity" else "demand.d|0|0" - assert missed(answer) == ([] if all_dead else [expected(prefix, 1 / 32)]) - assert [m.location.case_id for m in answer.misses if causes(m.constraints)] == ( - [] if all_dead else ["failure-000001"] - ) - - -def test_supported_count_plan_and_mask_have_the_same_live_resolution(): - p = prepared(q=1 / 32, parallel=True) - blocked = p.topology.edges_by_link["x|t|1"] - mask = p.topology.graph.eligibility(excluded_edges=blocked) - ordinary = tunnel_counts( - p, - TunnelCountQuery( - p.demand_sets["d"], (16,), 1000, 1000, eligibility=mask, workers=1 - ), - ) - forward = [p.topology.edges_by_link[f"s{i}|x|0"][0] for i in range(12)] - support = dag_from_edges( - p.topology.graph, [*forward, p.topology.edges_by_link["x|t|0"][0]] - ) - cfg = core.FlowPolicyConfig( - routing=core.CentralizedTE( - source_allocation=core.SourceAllocation.EQUAL, - tunnel_count=core.TunnelCount(16), - support=support, - ), - admission=core.Admission.LOSSY, - ) - demands = replace( - p.demand_sets["d"], - demands=tuple(replace(d, config=cfg) for d in p.demand_sets["d"].demands), - ) - supported = tunnel_counts( - p, TunnelCountQuery(demands, (16,), 1000, 1000, workers=1) - ) - assert ( - missed(ordinary) - == missed(supported) - == [expected("tunnel_counts.demand[d|0|0].count[16]", 1 / 32)] - ) - - -def test_ordered_residual_and_simultaneous_use_their_native_plans(): - s = scenario(heads=1, q=1 / 32) - d = DemandSet( - "d", - ( - DemandSpec(Selector("^s"), Selector("^t$"), 9.75, config()), - DemandSpec(Selector("^s"), Selector("^t$"), 1000, config()), - ), - ) - p = prepare(replace(s, demand_sets={"d": d}), demand_sets=("d",)) - assert causes(p.units_report.binding_constraints) == [ - expected("demand.d|1|0", 1 / 32, ["s0"]) - ] - ordered = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) - assert missed(ordered) == [expected("demand.d|1|0", 1 / 32, ["s0"])] - assert [r.result.delivered for r in ordered.cases[0].states["converged"].rows] == [ - 312, - 0, - ] - simultaneous = place( - p, PlacementQuery(p.demand_sets["d"], workers=1, interaction="simultaneous") - ) - assert missed(simultaneous) == [expected("demand.d|1|0", 1 / 32, ["s0"])] - bad = prepared() - simultaneous = place( - bad, PlacementQuery(bad.demand_sets["d"], workers=1, interaction="simultaneous") - ) - assert missed(simultaneous) == [expected()] - - -def test_msd_observes_each_bad_plan_at_its_own_probe(): - p = prepared() - answer = maximum_supported_demand( - p, - MSDQuery( - p.demand_sets["d"], - alpha_min=0.5, - alpha_start=1, - alpha_max=2, - max_bracket_iters=2, - max_bisect_iters=2, - export_probe_rows=True, - ), - ) - assert answer.search_status == "none_feasible" - assert missed(answer) == [expected()] * len(answer.history) - assert [ - m.location.probe_index for m in answer.misses if causes(m.constraints) - ] == list(range(len(answer.history))) - assert p.topology.q == 0.5 - - -@pytest.mark.parametrize("which", ["floor", "range"]) -def test_finest_legal_q_keeps_resolution_and_existing_misses(which): - p = ( - prepared(heads=2, cap=2**-32, volume=1, count=2, q="auto") - if which == "floor" - else prepared(q="auto", huge=True) - ) - q = 2**-32 if which == "floor" else 2 - heads = ["s0", "s1"] if which == "floor" else None - assert p.topology.q == q and p.units_report.target_met is False - assert causes(p.units_report.binding_constraints) == [expected(q=q, heads=heads)] - assert any( - c.kind == ("input" if which == "floor" else "capacity_sum") and not c.met - for c in p.units_report.binding_constraints - ) - - -def test_constraint_wire_fields_reject_missing_null_and_near_values(): - from ngraph import TunnelResolutionConstraint, UnitsReport - - p = prepared() - actual = causes(p.units_report.binding_constraints)[0] - want = expected() - assert actual == want - assert ( - get_type_hints(TunnelResolutionConstraint)["q"] - is get_type_hints(UnitsReport)["q"] - ) - assert type(TunnelResolutionConstraint("x", 0.5, ("s0",)).q) is type( - p.units_report.q - ) - schema = { - "$ref": "#/$defs/TunnelResolutionConstraintJSON", - "$defs": SCHEMA["$defs"], - } - validator = jsonschema.Draft202012Validator(schema) - validator.validate(actual) - wrong = { - "kind": "tunnel", - "label": "demand.other", - "q": 0.25, - "heads": ["other", *want["heads"][1:]], - "met": True, - } - for key in want: - for mutation in ("absent", "null", "wrong"): - changed = deepcopy(actual) - if mutation == "absent": - del changed[key] - else: - changed[key] = None if mutation == "null" else wrong[key] - with pytest.raises(AssertionError): - assert changed == want - if mutation != "wrong" or key in ("kind", "met"): - assert list(validator.iter_errors(changed)) - for value in ([], [0], None): - assert list(validator.iter_errors({**actual, "heads": value})) - for extra in ("reason", "tunnels_per_head", "q_upper_bound"): - assert list(validator.iter_errors({**actual, extra: 1})) - - -def test_concepts_state_the_live_plan_rule(): - for q, met in ((0.5, False), (1 / 32, True)): - p = prepared(q=q) - assert p.units_report.target_met is met - assert bool(causes(p.units_report.binding_constraints)) is (not met) - - -def test_the_automatic_preparation_description_checks_live_tunnel_plans(): - coarse = prepared(q=0.5) - assert all(c.met for c in coarse.units_report.nominal_splits) - assert coarse.units_report.inexact_inputs == coarse.units_report.zeroed_inputs == 0 - fine = prepared(q="auto") - assert fine.units_report.target_met is True and fine.topology.q == 1 / 32 - assert causes(fine.units_report.binding_constraints) == [expected(q=1 / 16)] diff --git a/tests/analysis/test_units.py b/tests/analysis/test_units.py index 9cc89d5..74f0853 100644 --- a/tests/analysis/test_units.py +++ b/tests/analysis/test_units.py @@ -6,12 +6,10 @@ import numpy as np import pytest -from ngraph.analysis.precision import split_constraint from ngraph.analysis.units import ( RESOLUTIONS, UnitsSettings, input_constraint, - input_samples, quantize, scaled_units, split_units, @@ -76,26 +74,10 @@ def test_resolution_domain_and_settings_immutability(): assert UnitsSettings(precision=0.2).precision == 0.2 -def test_error_sign_relative_zero_and_exact_large_units(): - samples = input_samples( - {"up": ("offer", 0.5), "down": ("demand", 0.25), "zero": ("capacity", 0)}, 1 - ) - down, up, zero = samples - assert down.label == "down" and down.units == 0 and down.error == Fraction(-1, 4) - assert down.relative_error == -1 - assert up.units == 1 and up.error == Fraction(1, 2) and up.relative_error == 1 - assert zero.error == 0 and zero.relative_error is None - big = input_samples({"big": ("offer", 2**54)}, 1)[0] - assert big.units == 2**54 and isinstance(big.units, int) - assert big.error == 0 - - -def test_positive_rounded_zero_still_constrains_and_split_bound_is_exact(): +def test_positive_rounded_zero_still_has_its_exact_input_bound(): c = input_constraint("tiny", 0.25, 1, 0.125) assert c is not None and c.q_upper_bound == Fraction(1, 16) and not c.met assert input_constraint("zero", 0, 1, 0.125) is None - s = split_constraint("expansion", "three", Fraction(5), 3, 1, 0.125) - assert s.q_upper_bound == Fraction(5, 16) and s.members == 3 and not s.met assert input_constraint("tiny", 0.25, 2**-4, 0.125).met @@ -106,159 +88,3 @@ def test_scaled_nonfinite_and_native_range_have_controls(): with pytest.raises(ValueError, match="range-case"): quantize(float(2**62), 1, "range-case") assert quantize(float(2**61), 1, "range-case") == 2**61 - - -def _prepared_line(volume): - from ngraph import ( - DemandSet, - DemandSpec, - Link, - Network, - Node, - Scenario, - Selector, - prepare, - ) - - model = Network() - model.add_node(Node("a")) - model.add_node(Node("b")) - model.add_link(Link("a", "b", 0.25, 1)) - spec = DemandSpec(Selector("^a$"), Selector("^b$"), volume, id="d") - scenario = Scenario( - model, - demand_sets={"ds": DemandSet("ds", (spec,))}, - units=UnitsSettings(1), - ) - return model, prepare(scenario, demand_sets=("ds",)) - - -def test_prepared_atoms_are_owned_and_scaled_atoms_recomputed(monkeypatch): - from ngraph.analysis import precision - - model, prepared = _prepared_line(0.5) - atoms = dict(prepared._inputs) - assert atoms == { - "demand.d.volume": ("demand", 0.5), - "link.a|b|0.capacity": ("capacity", 0.25), - } - expected_samples = input_samples(atoms, 1) - expected_constraints = tuple( - c - for label, (_, value) in sorted(atoms.items()) - if (c := input_constraint(label, value, 1, prepared.units_report.precision)) - is not None - ) - model.replace_link("a|b|0", capacity=99) - with pytest.raises(TypeError): - prepared._input_observations["new"] = None - original = precision.input_samples - calls = [] - - def observe(inputs, q): - calls.append(dict(inputs)) - return original(inputs, q) - - monkeypatch.setattr(precision, "input_samples", observe) - samples, constraints = precision.observed_inputs(prepared, atoms) - assert samples == expected_samples - assert constraints == expected_constraints - assert calls == [] - atoms["demand.d.volume"] = ("demand", 0.25) - samples, constraints = precision.observed_inputs(prepared, atoms) - assert calls == [{"demand.d.volume": ("demand", 0.25)}] - assert samples[0].units == 0 and samples[0].error == Fraction(-1, 4) - assert ( - constraints[0].q_upper_bound - == 2 * Fraction(prepared.units_report.precision) / 4 - ) - assert prepared._input_observations["demand.d.volume"][0].units == 1 - - -def test_observation_reuse_preserves_zero_sign(): - from ngraph.analysis.precision import observed_inputs - - _, prepared = _prepared_line(0.0) - samples, constraints = observed_inputs( - prepared, {"demand.d.volume": ("demand", -0.0)} - ) - assert samples[0].binary64_hex == "-0x0.0p+0" - assert constraints == () - assert prepared._input_observations["demand.d.volume"][0].binary64_hex == ( - "0x0.0p+0" - ) - - -def test_fixed_alpha_inputs_are_shared_by_cases_and_refreshed_for_next_question( - monkeypatch, -): - from ngraph import ( - DemandSet, - DemandSpec, - FailureMode, - FailurePolicy, - FailureRequest, - FailureRule, - Link, - Network, - Node, - PlacementQuery, - Scenario, - Selector, - place, - prepare, - ) - from ngraph.analysis import placement - - network = Network(nodes={name: Node(name) for name in ("s", "t", "N1", "N2")}) - network.add_link(Link("s", "t", 8, 1)) - prepared = prepare( - Scenario( - network, - demand_sets={ - "load": DemandSet( - "load", (DemandSpec(Selector("^s$"), Selector("^t$"), 1),) - ) - }, - units=UnitsSettings(1), - ), - demand_sets=("load",), - ) - failures = FailureRequest( - FailurePolicy( - modes=[FailureMode(1, [FailureRule("node", mode="choice", path="^N")])] - ), - 32, - 42, - ) - original = placement.placement_inputs - built = [] - - def observe(*args): - value = original(*args) - built.append(value) - return value - - monkeypatch.setattr(placement, "placement_inputs", observe) - old = place( - prepared, - PlacementQuery(prepared.demand_sets["load"], 1.5, failures, workers=2), - ) - assert len(old.cases) == 3 - assert [case.states["converged"].totals.delivered_units for case in old.cases] == [ - 2 - ] * 3 - assert len(built) == 1 - new = place( - prepared, - PlacementQuery(prepared.demand_sets["load"], 2.5, failures, workers=2), - ) - assert [case.states["converged"].totals.delivered_units for case in new.cases] == [ - 3 - ] * 3 - assert len(built) == 2 and built[0] is not built[1] - assert next(s for s in built[0][0] if s.kind == "demand").binary64_value == 1.5 - assert next(s for s in built[1][0] if s.kind == "demand").binary64_value == 2.5 - assert [case.states["converged"].totals.delivered_units for case in old.cases] == [ - 2 - ] * 3 diff --git a/tests/analysis/test_units_rule.py b/tests/analysis/test_units_rule.py new file mode 100644 index 0000000..033220c --- /dev/null +++ b/tests/analysis/test_units_rule.py @@ -0,0 +1,1210 @@ +"""Static input/count bounds, final validation and fixed prepared reports.""" + +from dataclasses import dataclass +from fractions import Fraction as F +from pathlib import Path + +import netgraph_core as core +import pytest + +from ngraph import ( + CapacityQuery, + DemandSet, + DemandSpec, + FlowPolicyPreset, + Link, + Network, + Node, + PlacementQuery, + Scenario, + Selector, + UnitsSettings, + capacity, + place, + prepare, +) +from ngraph.analysis.preparation import StepNeeds +from ngraph.export import project +from ngraph.model.demand import _ParsedModel, _ShortestPathsInput +from ngraph.workflow import MaxFlow, TrafficMatrixPlacement, TunnelCountAnalysis + + +def line(capacity=1): + net = Network(nodes={n: Node(n) for n in ("s", "t")}) + net.add_link(Link("s", "t", capacity, 1)) + return net + + +def load(*policies, volume=1, name="d"): + return DemandSet( + name, + tuple( + DemandSpec(Selector("^s$"), Selector("^t$"), volume, policy) + for policy in policies + ), + ) + + +def bound(*policies, capacity=1, volume=1, precision=0.25, resolution="auto"): + ds = load(*policies, volume=volume) + return prepare( + Scenario( + line(capacity), + demand_sets={"d": ds}, + units=UnitsSettings(resolution, precision), + ), + demand_sets=("d",), + ) + + +@pytest.mark.parametrize("requested,q,count", [((), 1 / 16, 3), (("d",), 1 / 32, 6)]) +def test_simultaneous_only_and_explicit_library_use(requested, q, count): + ds = load( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, FlowPolicyPreset.SHORTEST_PATHS_ECMP + ) + step = TrafficMatrixPlacement(demand_set="d", interaction="simultaneous", workers=1) + p = prepare( + Scenario( + line(), [step], demand_sets={"d": ds}, units=UnitsSettings(precision=0.25) + ), + demand_sets=requested, + ) + assert p.topology.q == q + assert p.units_report.split_bound.count == F(count) + assert p.units_report.split_bound.group == ("d" if requested else None) + assert p.units_report.split_bound.demands == (2 if requested else 0) + + +def test_ordered_and_simultaneous_uses_count_the_set_once(): + ds = load( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, FlowPolicyPreset.SHORTEST_PATHS_ECMP + ) + steps = [ + TrafficMatrixPlacement( + name=n, demand_set="d", interaction=interaction, workers=1 + ) + for n, interaction in [("lossy", "simultaneous"), ("ordered", "ordered")] + ] + p = prepare( + Scenario( + line(), steps, demand_sets={"d": ds}, units=UnitsSettings(precision=0.25) + ), + demand_sets=("d",), + ) + assert p.topology.q == 1 / 32 + assert ( + p.units_report.split_bound.group, + p.units_report.split_bound.demands, + p.units_report.split_bound.count, + ) == ("d", 2, F(6)) + + +@pytest.mark.parametrize( + "policy", + [ + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + _ParsedModel(_ShortestPathsInput()), + core.FlowPolicyConfig(routing=core.ShortestPaths()), + ], +) +def test_equal_lossless_declarations_count_expanded_leaves(policy): + p = bound(policy, policy) + assert p.topology.q == 1 / 32 + assert p.units_report.split_bound.demands == 2 + assert p.units_report.split_bound.count == F(6) + + +@pytest.mark.parametrize( + "policy", + [ + FlowPolicyPreset.SHORTEST_PATHS_ECMP_LOSSY, + FlowPolicyPreset.SHORTEST_PATHS_WCMP, + FlowPolicyPreset.SHORTEST_PATHS_MAXFLOW, + FlowPolicyPreset.TE_MAXFLOW_UNLIM, + core.FlowPolicyConfig( + routing=core.ShortestPaths(allocation=core.RouteAllocation.WEIGHTED) + ), + ], +) +def test_out_of_scope_models_add_no_equal_split_leaves(policy): + p = bound(policy, policy) + r = p.units_report + assert p.topology.q == 1 / 16 + assert ( + r.split_bound.group, + r.split_bound.demands, + r.split_bound.tunnels, + r.split_bound.count, + ) == (None, 0, 0, F(3)) + + +@pytest.mark.parametrize( + "resolution,limited,q,target", + [("auto", "input", 2**-9, True), (1, "declared", 1, False)], +) +def test_input_bound_uses_original_binary64_and_manual_reports_both( + resolution, limited, q, target +): + p = bound( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + capacity=100, + volume=1.0156, + precision=0.001, + resolution=resolution, + ) + r = p.units_report + assert (r.q, r.exponent, r.mode, r.precision, r.limited_by, r.target_met) == ( + q, + -9 if resolution == "auto" else 0, + "auto" if resolution == "auto" else "manual", + 0.001, + limited, + target, + ) + assert ( + r.input_bound.kind, + r.input_bound.label, + r.input_bound.q_upper_bound, + r.input_bound.met, + ) == ("input", "demand.d|0.volume", 2 * F(0.001) * F(1.0156), resolution == "auto") + assert r.achieved_precision == max(F(q) / (2 * F(1.0156)), F(q) * 3 / 100) + assert r.split_bound.q_upper_bound == F(0.001) * 100 / 3 + assert r.split_bound.met == (resolution == "auto") + assert r.range_limit is None + assert p.demand_sets["d"].demands[0].nominal_units == ( + 520 if resolution == "auto" else 1 + ) + + +def test_equal_bounds_name_input_and_floor_wins_group_tie(): + net = Network(nodes={n: Node(n) for n in ("s", "t", "u", "v")}) + net.add_link(Link("s", "t", 1, 1)) + net.add_link(Link("u", "v", 1, 1)) + ds = load(FlowPolicyPreset.SHORTEST_PATHS_ECMP, volume=1 / 8) + p = prepare( + Scenario(net, demand_sets={"d": ds}, units=UnitsSettings(precision=0.5)), + demand_sets=("d",), + ) + r = p.units_report + assert r.q == 1 / 8 and r.limited_by == "input" + assert r.input_bound.q_upper_bound == r.split_bound.q_upper_bound == F(1, 8) + assert (r.split_bound.group, r.split_bound.demands, r.split_bound.tunnels) == ( + None, + 0, + 0, + ) + assert r.achieved_precision == F(1, 2) and r.target_met + assert r.input_bound.met is True and r.split_bound.met is True + + +def test_input_and_group_ties_keep_preparation_order(): + first = load( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + name="z", + ) + second = load( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + FlowPolicyPreset.SHORTEST_PATHS_ECMP, + name="a", + ) + steps = [ + MaxFlow( + name="z", + model=core.FlowPolicyConfig( + routing=core.DistributedTE(tunnel_count=core.TunnelCount(1)) + ), + source=Selector("s"), + target=Selector("t"), + offer=1, + design_demand=1, + workers=1, + ), + TrafficMatrixPlacement(name="active", demand_set="z", workers=1), + ] + p = prepare( + Scenario(line(), steps, demand_sets={"a": second, "z": first}), + demand_sets=("a", "z"), + ) + r = p.units_report + assert r.input_bound.label == "workflow.z.offer" + assert r.split_bound.capacity_label == "link.s|t|0.capacity" + assert (r.split_bound.group, r.split_bound.count) == ("z", F(6)) + assert r.scope.workflow_demand_sets == ("z",) and r.scope.library_demand_sets == ( + "a", + "z", + ) + + +def test_range_coarsens_after_static_choice_and_records_next_finer_failure(): + net = line(2**41) + net.add_link(Link("s", "t", 2**41, 1)) + ds = load(FlowPolicyPreset.SHORTEST_PATHS_ECMP, volume=0.001) + p = prepare(Scenario(net, demand_sets={"d": ds}), demand_sets=("d",)) + r = p.units_report + assert (r.q, r.limited_by, r.target_met) == (2**-18, "range", False) + assert ( + r.range_limit.kind, + r.range_limit.label, + r.range_limit.tested_q, + r.range_limit.units_value, + r.range_limit.exclusive_limit, + r.range_limit.met, + ) == ( + "capacity_sum", + "topology.augmented_capacity_sum", + 2**-19, + 2**62, + 2**62, + False, + ) + assert r.augmented_capacity_sum_units == 2**61 + assert r.achieved_precision == F(2**-18) / (2 * F(0.001)) + assert r.input_bound.met is False and r.split_bound.met is True + assert p.demand_sets["d"].demands[0].nominal_units == 262 + + +def test_resolution_floor_reports_unmet_input_bound(): + p = bound(FlowPolicyPreset.SHORTEST_PATHS_ECMP, volume=2**-40, precision=0.5) + r = p.units_report + assert (r.q, r.limited_by, r.achieved_precision, r.target_met) == ( + 2**-32, + "resolution_floor", + F(128), + False, + ) + assert not r.input_bound.met and r.split_bound.met + assert p.demand_sets["d"].demands[0].nominal_units == 0 + + +@pytest.mark.parametrize("net", [Network(), line(0)]) +def test_no_positive_denominators_are_unbounded(net): + r = prepare(Scenario(net)).units_report + assert (r.q, r.exponent, r.mode, r.limited_by, r.target_met) == ( + 2**32, + 32, + "auto", + "unbounded", + True, + ) + assert ( + r.input_bound is r.split_bound is r.range_limit is r.achieved_precision is None + ) + assert r.augmented_capacity_sum_units == 0 and r.weight_approximations == () + + +def test_disabled_capacity_still_sets_the_bound_and_count(): + net = line(100) + net.add_link(Link("s", "t", 1, 1, disabled=True)) + r = prepare(Scenario(net)).units_report + assert ( + r.q, + r.split_bound.capacity, + r.split_bound.nodes, + r.split_bound.directed_links, + r.split_bound.count, + ) == (2**-12, 1.0, 2, 4, F(7, 2)) + assert r.split_bound.capacity_label == "link.s|t|1.capacity" + + +@pytest.mark.parametrize("q", [2**-32, 2**32]) +def test_manual_endpoints_keep_declared_reason(q): + r = prepare(Scenario(Network(), units=UnitsSettings(q))).units_report + assert r.q == q and r.limited_by == "declared" and r.mode == "manual" + assert r.target_met and r.achieved_precision is None + + +def test_manual_and_exhausted_range_refusals_have_legal_controls(): + with pytest.raises( + ValueError, + match="topology.augmented_capacity_sum: no legal resolution represents the complete prepared input/augmented graph", + ): + prepare(Scenario(line(2**61), units=UnitsSettings(1))) + assert ( + prepare( + Scenario(line(2**61), units=UnitsSettings(2)) + ).units_report.augmented_capacity_sum_units + == 2**61 + ) + with pytest.raises( + ValueError, + match="no legal resolution represents the complete prepared input/augmented graph", + ): + prepare(Scenario(line(2**95))) + assert prepare(Scenario(line())).topology.q == 2**-12 + + +@pytest.mark.parametrize( + "value", [True, False, 0, -1, 0.50001, float("inf"), float("nan"), "0.1"] +) +def test_precision_refusal_is_complete_and_has_endpoint_control(value): + with pytest.raises(ValueError) as error: + UnitsSettings(precision=value) + assert ( + str(error.value) + == f"units.precision: expected a number greater than 0 and at most 0.5, got {value!r}" + ) + assert UnitsSettings(precision=0.5).precision == 0.5 + + +def test_one_final_compile_and_masked_capacity_validation(monkeypatch): + import ngraph.analysis.preparation as module + + compiled = [] + evaluated = [] + original_compile = module.compile_topology + original_evaluate = module.evaluate + + def compile(*args, **kwargs): + compiled.append(args[1]) + return original_compile(*args, **kwargs) + + def evaluate(topology, query, pairs, options, workers): + evaluated.append( + (tuple(pairs), options.eligibility.node_allowed().tolist(), workers) + ) + return original_evaluate(topology, query, pairs, options, workers) + + monkeypatch.setattr(module, "compile_topology", compile) + monkeypatch.setattr(module, "evaluate", evaluate) + step = MaxFlow( + name="cap", + source=Selector("s"), + target=Selector("t"), + model=core.FlowPolicyConfig(routing=core.ShortestPaths()), + workers=1, + ) + p = prepare(Scenario(line(100), [step])) + assert compiled == [p.topology.q] and len(evaluated) == 1 + assert evaluated[0][1:] == ([False, False], 1) + assert ( + capacity(p, step.query(p)).cases[0].states["converged"].rows[0].result.delivered + * p.topology.q + == 100 + ) + + +def test_capacity_validation_retains_full_workflow_refusal_and_cause(): + def scenario(weights): + config = core.FlowPolicyConfig( + routing=core.ShortestPaths( + allocation=core.RouteAllocation.WEIGHTED, + weight_basis=core.WeightBasis.EXPLICIT, + edge_weights=weights, + ) + ) + return Scenario( + line(100), + [ + MaxFlow( + name="cap", + source=Selector("s"), + target=Selector("t"), + model=config, + workers=1, + ) + ], + ) + + with pytest.raises(ValueError) as error: + prepare(scenario([1])) + assert ( + str(error.value) == "workflow[0] (name 'cap'): edge_weights: expected 2 entries" + ) + assert type(error.value.__cause__) is ValueError + p = prepare(scenario([1, 1])) + result = capacity(p, p.scenario.workflow[0].query(p)) + assert ( + result.cases[0].states["converged"].rows[0].result.delivered * p.topology.q + == 100 + ) + + +def test_custom_capacity_callback_runs_only_after_final_compile(monkeypatch): + calls = [] + + @dataclass + class Opaque(MaxFlow): + def needs(self): + def query(p): + calls.append(p.topology.q) + return CapacityQuery(Selector("s"), Selector("t"), workers=1) + + return StepNeeds(numerical=True, capacity=query) + + p = prepare(Scenario(line(), [Opaque(name="opaque")])) + assert calls == [p.topology.q] + assert p.units_report.split_bound.group is None + + +def test_builtin_subclass_does_not_publish_its_unknown_tunnel_count(): + class CustomMaxFlow(MaxFlow): + pass + + fields = dict( + name="cap", + source=Selector("s"), + target=Selector("t"), + preset=FlowPolicyPreset.TE_ECMP_16_LSP, + workers=1, + ) + builtin = prepare( + Scenario(line(), [MaxFlow(**fields)], units=UnitsSettings(precision=0.5)) + ) + custom = prepare( + Scenario(line(), [CustomMaxFlow(**fields)], units=UnitsSettings(precision=0.5)) + ) + assert (builtin.units_report.split_bound.tunnels, builtin.topology.q) == ( + 16, + 1 / 64, + ) + assert (custom.units_report.split_bound.tunnels, custom.topology.q) == (0, 1 / 8) + + +@pytest.mark.parametrize( + "pooled,tunnels,count,q", + [(False, 32, F(143, 4), 1 / 128), (True, 16, F(79, 4), 1 / 64)], +) +def test_capacity_tunnels_count_heads_from_the_selected_sources( + pooled, tunnels, count, q +): + net = Network(nodes={n: Node(n) for n in ("s1", "s2", "t")}) + for source in ("s1", "s2"): + net.add_link(Link(source, "t", 1, 1)) + policy = ( + {"preset": FlowPolicyPreset.TE_ECMP_16_LSP} + if pooled + else { + "model": core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=core.SourceAllocation.EQUAL, + tunnel_count=core.TunnelCount(16), + ) + ) + } + ) + p = prepare( + Scenario( + net, + [ + MaxFlow( + name="cap", + source=Selector("^s"), + target=Selector("^t$"), + mode="combine", + workers=1, + **policy, + ) + ], + units=UnitsSettings(precision=0.5), + ) + ) + b = p.units_report.split_bound + # Three nodes and four directed physical edges: floor = 7/4 + 2. + assert (b.nodes, b.directed_links, b.group, b.demands, b.tunnels, b.count) == ( + 3, + 4, + "workflow.cap", + 0, + tunnels, + count, + ) + assert p.topology.q == q + + +def test_count_question_validates_late_graph_support_during_preparation(): + from dataclasses import replace + + from ngraph import run + from ngraph.analysis.programs import dag_from_edges + + other = prepare(Scenario(line(), units=UnitsSettings(1))) + + def scenario(foreign): + class LateCount(TunnelCountAnalysis): + def query(self, prepared): + graph = other.topology.graph if foreign else prepared.topology.graph + config = core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=core.SourceAllocation.EQUAL, + tunnel_count=core.TunnelCount(1), + support=dag_from_edges(graph, [0]), + ) + ) + ds = prepared.demand_sets[self.demand_set] + return self.query_for( + replace( + ds, demands=tuple(replace(d, config=config) for d in ds.demands) + ) + ) + + return Scenario( + line(), + [ + LateCount( + name="counts", + demand_set="d", + counts=(1,), + design_demand=1, + offer=1, + workers=1, + ) + ], + demand_sets={ + "d": load( + core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=core.SourceAllocation.EQUAL, + tunnel_count=core.TunnelCount(1), + ) + ) + ) + }, + units=UnitsSettings(1), + ) + + valid = prepare(scenario(False)) + assert run(valid).steps[0].result.rows[0].result.delivered == 1 + with pytest.raises(ValueError) as error: + prepare(scenario(True)) + assert ( + str(error.value) + == "workflow[0] (name 'counts'): support belongs to another graph" + ) + assert type(error.value.__cause__) is ValueError + + +def test_count_rows_use_declared_maximum_and_masked_native_validation(monkeypatch): + # The declared set contributes one tunnel; the count question contributes 16. + config = core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=core.SourceAllocation.EQUAL, + tunnel_count=core.TunnelCount(1), + ) + ) + ds = load(config) + step = TunnelCountAnalysis( + name="counts", + demand_set="d", + counts=(2, 4), + up_to=16, + design_demand=1, + offer=1, + workers=1, + ) + validations = [] + original = core.evaluate_pairs + + def evaluated(graph, pairs, options, **kwargs): + if options.eligibility is not None and not any( + options.eligibility.node_allowed() + ): + count = getattr(options.config.routing, "tunnel_count", None) + validations.append( + ( + count.per_head if count is not None else None, + tuple(pairs), + kwargs.get("workers"), + ) + ) + return original(graph, pairs, options, **kwargs) + + monkeypatch.setattr(core, "evaluate_pairs", evaluated) + p = prepare( + Scenario( + line(), [step], demand_sets={"d": ds}, units=UnitsSettings(precision=0.5) + ) + ) + assert p.topology.q == 1 / 64 + assert ( + p.units_report.split_bound.group, + p.units_report.split_bound.count, + p.units_report.split_bound.tunnels, + ) == ("workflow.counts", F(19), 16) + assert [n for n, _, _ in validations if n in (2, 4, 16)] == [2, 4, 16] + assert all(workers == 1 for _, _, workers in validations) + + +def test_later_small_offer_keeps_prepared_report(): + p = prepare(Scenario(line(100))) + assert p.topology.q == 1 / 32 + r = capacity( + p, CapacityQuery(Selector("s"), Selector("t"), offer=1 / 128, workers=1) + ) + assert r.cases[0].states["converged"].rows[0].result.delivered == 0 + assert r.units_report is p.units_report + + +def test_readme_and_square_mesh_hand_values(): + cases = [ + ("readme_example.yml", -9, F(14), F(7, 12800), 48), + ("square_mesh.yaml", -17, F(72), F(9, 16384), 16), + ] + for name, exponent, count, achieved, L in cases: + p = prepare(Scenario.from_yaml((Path("scenarios") / name).read_text())) + r = p.units_report + assert (r.exponent, r.limited_by, r.achieved_precision, r.target_met) == ( + exponent, + "split_count", + achieved, + True, + ) + assert r.split_bound.count == count + assert r.split_bound.nodes + r.split_bound.directed_links == L + assert p.topology.graph.num_nodes() + p.topology.graph.num_edges() == L + assert project(r)["split_bound"]["count"] == { + "numerator": count.numerator, + "denominator": count.denominator, + } + + +def test_fifty_source_merge_delivery_and_msd(): + from ngraph import MSDQuery, maximum_supported_demand + + names = [f"S{i:02d}" for i in range(50)] + net = Network(nodes={name: Node(name) for name in [*names, "X1", "X2", "X3", "T"]}) + for source in names: + for spine in ("X1", "X2", "X3"): + net.add_link(Link(source, spine, 1000, 1)) + for spine in ("X1", "X2", "X3"): + net.add_link(Link(spine, "T", 1, 1)) + ds = DemandSet("d", (DemandSpec(Selector("^S"), Selector("^T$"), 100),)) + p = prepare(Scenario(net, demand_sets={"d": ds}), demand_sets=("d",)) + r = p.units_report + assert ( + r.q, + r.split_bound.nodes, + r.split_bound.directed_links, + r.split_bound.count, + r.achieved_precision, + ) == (2**-17, 54, 306, F(92), F(23, 32768)) + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + delivered = result.cases[0].states["converged"].rows[0].result.delivered + # 22 sources at 7864 and 28 at 7863 place exactly 131072 units on each + # first spine; a 23rd larger share would overflow. This is one direct + # lossless shared equal-DAG demand on positive original capacities. + assert delivered == 50 * 7863 + 22 == 393172 + assert abs(F(delivered) * F(r.q) - 3) <= F(r.precision) * 3 + msd = maximum_supported_demand( + p, MSDQuery(p.demand_sets["d"], alpha_tolerance=0.0001) + ) + assert F(msd.alpha_feasible.value) == F(491, 16384) + + +@pytest.mark.parametrize( + "q,expected,achieved", [("auto", 32769, F(7, 8192)), (1, 9, F(7, 2))] +) +def test_shared_route_delivery_and_declared_coarse_unit(q, expected, achieved): + net = Network(nodes={n: Node(n) for n in ("A", "B", "C", "X", "Y", "D")}) + for source, target, amount, cost in ( + ("A", "B", 100, 1), + ("B", "D", 100, 2), + ("A", "C", 100, 1), + ("C", "X", 2, 1), + ("C", "Y", 100, 1), + ("X", "D", 100, 1), + ("Y", "D", 100, 1), + ): + net.add_link(Link(source, target, amount, cost)) + ds = DemandSet("d", (DemandSpec(Selector("^A$"), Selector("^D$"), 12),)) + p = prepare( + Scenario(net, demand_sets={"d": ds}, units=UnitsSettings(q)), demand_sets=("d",) + ) + r = p.units_report + assert (r.q, r.limited_by, r.achieved_precision, r.target_met) == ( + 2**-12 if q == "auto" else 1, + "split_count" if q == "auto" else "declared", + achieved, + q == "auto", + ) + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + assert result.cases[0].states["converged"].rows[0].result.delivered == expected + if q == "auto": + # I=32769 gives C floor(I/2)=16384 and X ceil(C/2)=8192, exactly + # the rounded capacity. I+1 would give X 8193. All theorem premises hold. + assert abs(F(expected) * F(r.q) - 8) <= F(r.precision) * 8 + + +def test_tunnel_setup_is_a_discrete_observed_answer(): + net = line(6) + net.add_link(Link("s", "t", 4, 1)) + config = core.FlowPolicyConfig(routing=core.DistributedTE(core.TunnelCount(2))) + ds = load(config, volume=8 + 2**-12) + p = prepare(Scenario(net, demand_sets={"d": ds}), demand_sets=("d",)) + assert (p.topology.q, p.units_report.split_bound.count) == (2**-11, F(11, 2)) + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + assert result.cases[0].states["converged"].rows[0].result.delivered == 16385 + # Exact setup asks each tunnel for 4+2^-13 and retains only the capacity-6 + # path. The whole-unit result is an observation, outside the delivery theorem. + + +def test_declared_heads_times_tunnels_avoid_zero_delivery_at_small_target(): + sources = [f"S{i:02d}" for i in range(12)] + net = Network(nodes={name: Node(name) for name in ("H", "T", *sources)}) + for source in sources: + net.add_link(Link(source, "H", 1000, 1)) + net.add_link(Link("H", "T", 10, 1)) + config = core.FlowPolicyConfig( + routing=core.CentralizedTE( + source_allocation=core.SourceAllocation.EQUAL, + tunnel_count=core.TunnelCount(16), + ) + ) + ds = DemandSet("d", (DemandSpec(Selector("^S"), Selector("^T$"), 1000, config),)) + p = prepare( + Scenario(net, demand_sets={"d": ds}, units=UnitsSettings(precision=0.1)), + demand_sets=("d",), + ) + r = p.units_report + assert ( + r.q, + r.split_bound.nodes, + r.split_bound.directed_links, + r.split_bound.tunnels, + r.split_bound.count, + ) == (2**-8, 14, 26, 192, F(204)) + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + assert result.cases[0].states["converged"].rows[0].result.delivered == 2560 + + +def test_zero_capacity_member_has_no_relative_delivery_promise(): + net = line(5) + net.add_link(Link("s", "t", 0, 1)) + ds = load(FlowPolicyPreset.SHORTEST_PATHS_ECMP, volume=5) + p = prepare(Scenario(net, demand_sets={"d": ds}), demand_sets=("d",)) + assert p.topology.q == 2**-10 and p.units_report.target_met + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + assert result.cases[0].states["converged"].rows[0].result.delivered == 1 + + +def test_ordered_residual_is_an_observation_outside_single_demand_theorem(): + ds = DemandSet( + "d", + tuple( + DemandSpec(Selector("^s$"), Selector("^t$"), amount) + for amount in (9.9999, 10) + ), + ) + p = prepare(Scenario(line(10), demand_sets={"d": ds}), demand_sets=("d",)) + assert p.topology.q == 2**-10 and p.units_report.split_bound.count == F(6) + result = place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + assert [ + row.result.delivered for row in result.cases[0].states["converged"].rows + ] == [10240, 0] + + +def test_uncapped_optimized_flow_is_exact_on_rounded_capacities(): + step = MaxFlow(name="flow", source=Selector("s"), target=Selector("t"), workers=1) + p = prepare(Scenario(line(1.00015), [step])) + assert p.topology.q == 2**-12 + result = capacity(p, step.query(p)) + assert result.cases[0].states["converged"].rows[0].result.delivered == 4097 + + +def test_power_selection_keeps_the_exact_binary64_ratio_bound(): + # binary64(1/3) * 3 is exactly 1 - 2^-54; rounding the bound to float + # would admit q=1. The exact comparison requires q=1/2 and three units. + p = bound( + FlowPolicyPreset.SHORTEST_PATHS_ECMP, capacity=100, volume=1.5, precision=1 / 3 + ) + r = p.units_report + assert r.input_bound.q_upper_bound == F(2**54 - 1, 2**54) + assert (r.q, r.limited_by, r.target_met, r.input_bound.met, r.split_bound.met) == ( + 0.5, + "input", + True, + True, + True, + ) + assert r.achieved_precision == F(1, 6) + rows = ( + place(p, PlacementQuery(p.demand_sets["d"], workers=1)) + .cases[0] + .states["converged"] + .rows + ) + assert rows[0].result.delivered == 3 + assert rows[0].result.delivered * r.q == 1.5 + + +def two_sources(): + net = Network(nodes={n: Node(n) for n in "abc"}) + for source in "ab": + net.add_link(Link(source, "c", 1, 1)) + return net + + +def test_pooled_set_and_count_rows_have_one_tunnel_head(): + # L=7, floor=15/4. A pooled set has t=16, Gamma=79/4; the count + # question has t=20, Gamma=95/4. Both select q=1/64 at p=1/2. + ds = DemandSet( + "d", + ( + DemandSpec( + Selector("^[ab]$"), Selector("^c$"), 1, FlowPolicyPreset.TE_ECMP_16_LSP + ), + ), + ) + for steps, requested, group, count, tunnels in [ + ([], ("d",), "d", F(79, 4), 16), + ( + [ + TunnelCountAnalysis( + name="tc", + demand_set="d", + counts=(1,), + up_to=20, + design_demand=1, + offer=1, + workers=1, + ) + ], + (), + "workflow.tc", + F(95, 4), + 20, + ), + ]: + p = prepare( + Scenario( + two_sources(), + steps, + demand_sets={"d": ds}, + units=UnitsSettings(precision=0.5), + ), + demand_sets=requested, + ) + b = p.units_report.split_bound + assert ( + p.topology.q, + b.nodes, + b.directed_links, + b.group, + b.count, + b.tunnels, + b.demands, + ) == (1 / 64, 3, 4, group, count, tunnels, 0) + + +def test_each_capacity_pair_forms_its_own_count_group(): + # Two singleton pairs each have t=16, not a combined t=32. + step = MaxFlow( + name="mf", + source=Selector("^(a|b)$"), + target=Selector("^c$"), + mode="pairwise", + preset=FlowPolicyPreset.TE_ECMP_16_LSP, + workers=1, + ) + p = prepare(Scenario(two_sources(), [step], units=UnitsSettings(precision=0.5))) + b = p.units_report.split_bound + assert (p.topology.q, b.group, b.count, b.demands, b.tunnels) == ( + 1 / 64, + "workflow.mf", + F(79, 4), + 0, + 16, + ) + + +def test_explicit_equal_source_allocation_counts_like_the_default(): + # Pairwise expansion makes two ECMP leaves: Gamma=2*(15/4), q=1/16. + policy = _ParsedModel( + _ShortestPathsInput(source_allocation=core.SourceAllocation.EQUAL) + ) + ds = DemandSet( + "e", + (DemandSpec(Selector("^[ab]$"), Selector("^c$"), 1, policy, mode="pairwise"),), + ) + p = prepare( + Scenario( + two_sources(), demand_sets={"e": ds}, units=UnitsSettings(precision=0.5) + ), + demand_sets=("e",), + ) + b = p.units_report.split_bound + assert (p.topology.q, b.group, b.count, b.demands, b.tunnels) == ( + 1 / 16, + "e", + F(15, 2), + 2, + 0, + ) + + +def test_a_declaration_selecting_no_source_adds_no_leaf(): + ds = DemandSet( + "f", + ( + DemandSpec(Selector("^a$"), Selector("^c$"), 1), + DemandSpec(Selector("^nothing$"), Selector("^c$"), 1), + ), + ) + p = prepare( + Scenario( + two_sources(), demand_sets={"f": ds}, units=UnitsSettings(precision=0.5) + ), + demand_sets=("f",), + ) + b = p.units_report.split_bound + assert (p.topology.q, b.group, b.count, b.demands, b.tunnels) == ( + 1 / 8, + None, + F(15, 4), + 0, + 0, + ) + + +def test_resolution_floor_when_the_input_bound_is_just_below_it(): + # 2*p*v = 3*2^-34 lies strictly between half the minimum q and q. + p = bound(FlowPolicyPreset.SHORTEST_PATHS_ECMP, volume=3 * 2.0**-34, precision=0.5) + r = p.units_report + assert ( + r.q, + r.limited_by, + r.input_bound.q_upper_bound, + r.input_bound.met, + r.target_met, + ) == (2.0**-32, "resolution_floor", F(3, 2**34), False, False) + + +def boundary_document(q, volume=1, capacity=100, routing=None): + demand = dict(source="^S$", target="^T$", volume=volume) + if routing: + demand["model"] = dict(routing=routing) + else: + demand["preset"] = "SHORTEST_PATHS_ECMP" + return dict( + units=dict(resolution=q), + network=dict( + nodes={"S": {}, "T": {}}, + links=[dict(source="S", target="T", capacity=capacity)], + ), + demands={"d": [demand]}, + workflow=[ + dict(type="TrafficMatrixPlacement", name="p", demand_set="d", workers=1) + ], + ) + + +def boundary_run(doc): + import yaml + + from ngraph import run + + p = prepare(Scenario.from_yaml(yaml.safe_dump(doc, sort_keys=False))) + assert p.topology.q == doc["units"]["resolution"] + return p, run(p) + + +def boundary_rows(result): + return result.steps[0].result.cases[0].states["converged"].rows + + +def test_expanded_parent_rounding_is_distinct_from_direct_leaf_conversion(): + doc = boundary_document(1 / 8) + doc["units"]["precision"] = 0.1 + doc["network"] = dict( + nodes={n: {} for n in [*(f"S{i}" for i in range(6)), "T"]}, + links=[dict(source=f"S{i}", target="T", capacity=1000) for i in range(6)], + ) + doc["demands"]["d"][0].update(source="^S", mode="pairwise") + _, result = boundary_run(doc) + units = tuple(r.result.delivered for r in boundary_rows(result)) + # Eight rounded parent units are apportioned over six independent pairs. + assert units == (2, 2, 1, 1, 1, 1) + assert F(units[0], 8) - F(1, 6) == F(1, 12) > F(1, 16) + + +def test_scaled_product_conversion_does_not_cover_binary64_multiplication(): + alpha = float(1 - F(1, 2**28) + F(1, 2**53)) + doc = boundary_document(2**-31, 2**28 + 1, 2**28 + 1) + doc["units"]["precision"] = 2**-57 + doc["workflow"][0]["alpha"] = alpha + p, result = boundary_run(doc) + physical = F(boundary_rows(result)[0].result.delivered) * F(p.topology.q) + assert physical == 2**28 + assert abs(F(alpha * float(2**28 + 1)) - physical) == 0 + exact = F(alpha) * (2**28 + 1) + assert exact - physical == F(234881025, 2**53) + assert exact - physical > F(2**-57) * exact + F(p.topology.q) / 2 + + +def test_many_rounded_requests_change_the_later_residual(): + doc = boundary_document(2**-16, capacity=10) + doc["network"]["nodes"].update(U={}, V={}) + doc["network"]["links"].append(dict(source="U", target="V", capacity=1)) + prior = dict( + source="^S$", target="^T$", volume=1051 / 131072, preset="TE_MAXFLOW_UNLIM" + ) + doc["demands"]["d"] = [dict(prior) for _ in range(1000)] + [ + dict(source="^S$", target="^T$", volume=10, preset="SHORTEST_PATHS_ECMP") + ] + p, result = boundary_run(doc) + units = [r.result.delivered for r in boundary_rows(result)] + assert units[:-1] == [526] * 1000 + assert units[-1] == 129360 + proportional_units = (F(10) - 1000 * F(1051, 131072)) / F(p.topology.q) + assert proportional_units == 129860 + assert proportional_units - units[-1] == 500 + + +def test_declared_finite_maxflow_offer_is_not_the_uncapped_maximum(): + doc = boundary_document(2**-9) + del doc["demands"] + doc["workflow"] = [ + dict(type="MaxFlow", name="cap", source="^S$", target="^T$", offer=1, workers=1) + ] + p, result = boundary_run(doc) + assert boundary_rows(result)[0].result.delivered == 512 + uncapped = capacity(p, CapacityQuery(Selector("^S$"), Selector("^T$"), workers=1)) + assert uncapped.cases[0].states["converged"].rows[0].result.delivered == 51200 + + +def test_a_later_count_can_exceed_the_prepared_tunnel_size(): + from ngraph import TunnelCountQuery, tunnel_counts + + doc = boundary_document( + 1 / 8, + capacity=1, + routing=dict( + type="centralized_te", + source_allocation="equal", + tunnel_count=dict(per_head=1), + ), + ) + doc["units"]["precision"] = 0.5 + p, result = boundary_run(doc) + later = tunnel_counts( + p, + TunnelCountQuery( + p.demand_sets["d"], (16,), design_demand=2, offer=2, workers=1 + ), + ) + assert boundary_rows(result)[0].result.plan.max_bandwidth_per_tunnel == 8 + assert later.rows[0].result.plan.max_bandwidth_per_tunnel == 0 + + +def test_repeated_supplied_dags_retain_separate_rounding(): + from ngraph.analysis.programs import dag_from_edges + + q = 2**-14 + net = Network(nodes={n: Node(n) for n in "abcd"}) + for destination in "bc": + for _ in range(10): + net.add_link(Link("a", destination, 100, 1)) + net.add_link(Link("b", "d", 1, 1)) + net.add_link(Link("c", "d", 100, 1)) + p = prepare(Scenario(net, units=UnitsSettings(q))) + t = p.topology + dag = dag_from_edges( + t.graph, + [i for ref, i in t._edge_id_by_ref.items() if ref.direction == "forward"], + ) + source, destination = t.node_id_by_name["a"], t.node_id_by_name["d"] + program = core.ForwardingProgram.from_routes( + t.graph, + source, + destination, + [core.RouteDefinition(source, dag) for _ in range(100)], + ) + result = capacity( + p, + CapacityQuery( + Selector("^a$"), + Selector("^d$"), + core.FlowPolicyConfig(routing=core.Pinned(program)), + offer=20, + workers=1, + ), + ) + delivered = result.cases[0].states["converged"].rows[0].result.delivered + # Half of proportional traffic crosses capacity one: fluid delivery is two. + assert F(delivered) * F(q) == F(253, 128) + assert F(2) - F(delivered) * F(q) == F(3, 128) + + +def test_lossy_weighted_delivery_has_no_equal_split_certificate(): + offer = 24458019 + net = line(2 * offer) + for _ in range(19): + net.add_link(Link("s", "t", 24000.5, 1)) + p = prepare(Scenario(net, units=UnitsSettings(1))) + weights = [0] * p.topology.graph.num_edges() + for ref, i in p.topology._edge_id_by_ref.items(): + weights[i] = 1000 if ref.link == "s|t|0" else 1 + config = core.FlowPolicyConfig( + routing=core.ShortestPaths( + allocation=core.RouteAllocation.WEIGHTED, + weight_basis=core.WeightBasis.EXPLICIT, + edge_weights=weights, + ), + admission=core.Admission.LOSSY, + ) + result = capacity( + p, + CapacityQuery(Selector("^s$"), Selector("^t$"), config, offer=offer, workers=1), + ) + delivered = result.cases[0].states["converged"].rows[0].result.delivered + assert delivered == 24458019 + proportional = F(offer * 1000, 1019) + 19 * F(48001, 2) + assert delivered - proportional == F(57361, 2038) + + +def test_lossy_simultaneous_sharing_is_integer_apportionment(): + doc = boundary_document(1, capacity=3) + doc["demands"]["d"] = [ + dict(source="^S$", target="^T$", volume=v) for v in (5, 1, 1) + ] + doc["workflow"][0]["interaction"] = "simultaneous" + _, result = boundary_run(doc) + delivered = tuple(r.result.delivered for r in boundary_rows(result)) + assert delivered == (3, 0, 0) + assert tuple( + F(a) - F(3 * v, 7) for a, v in zip(delivered, (5, 1, 1), strict=True) + ) == (F(6, 7), F(-3, 7), F(-3, 7)) + + +def test_controller_weights_change_when_the_graph_quantum_changes(): + actual = [] + for q in (1, 1 / 1024): + doc = boundary_document(q, 40) + doc["network"] = dict( + nodes={n: {} for n in ("S", "X1", "X2", "T")}, + links=[ + dict(source=s, target=t, capacity=c) + for s, t, c in [ + ("S", "X1", 100), + ("S", "X2", 100), + ("X1", "T", 30.0007), + ("X2", "T", 10), + ] + ], + ) + doc["demands"]["d"][0]["preset"] = "SHORTEST_PATHS_WCMP" + doc["routing"] = dict(igp_domain=dict(path="^X"), bgp_edge=dict(path="^(S|T)$")) + doc["wcmp_controller"] = dict(scope="next_hop") + p, _ = boundary_run(doc) + actual.append( + tuple( + m.weight for m in p.demand_sets["d"].controller[0].programmed[0].members + ) + ) + assert actual == [(3, 1), (30721, 10240)] + + +def test_msd_limit_stops_before_the_proportional_threshold(): + doc = boundary_document(1 / 512, capacity=10) + doc["workflow"] = [ + dict( + type="MaximumSupportedDemand", + name="m", + demand_set="d", + alpha_max=1, + ) + ] + _, result = boundary_run(doc) + answer = result.steps[0].result + assert (F(answer.alpha_feasible.value), answer.search_status) == (F(1), "limit") + assert F(10) / F(doc["demands"]["d"][0]["volume"]) == 10 diff --git a/tests/controller/helpers.py b/tests/controller/helpers.py index 62575bd..4ec92b4 100644 --- a/tests/controller/helpers.py +++ b/tests/controller/helpers.py @@ -216,11 +216,14 @@ def filling_oracle(columns, capacity): def highest_averages(x, weights): - """Highest averages H(x, w): one unit at a time to the largest w/(a+1), earlier on ties.""" - a = [0] * len(weights) - if not any(weights): - return a - for _ in range(x): + """Highest averages, starting at guaranteed lower quotas, with earlier ties.""" + total = sum(weights) + if not total: + return [0] * len(weights) + # The final divisor is at most total/x, so every floor(x*w/total) + # seat is guaranteed. Only fewer than len(weights) seats remain to rank. + a = [x * w // total for w in weights] + for _ in range(x - sum(a)): best = None for i, w in enumerate(weights): if w and (best is None or w * (a[best] + 1) > weights[best] * (a[i] + 1)): diff --git a/tests/controller/test_backbone_matrix.py b/tests/controller/test_backbone_matrix.py index b22b43f..622a125 100644 --- a/tests/controller/test_backbone_matrix.py +++ b/tests/controller/test_backbone_matrix.py @@ -5,7 +5,7 @@ at 25,312.5 each (alpha 1.6875), forwarded together as fixed programs. The failure is the three metro1/pop2 leaf uplinks of metro1's DC. At q = 1/4096 the deliveries are those of an independent prototype (separate filling solver, one weighted -program per demand); at the recipe's q the test checks the exact native identities +program per demand); at a declared coarse q the test checks the exact native identities (conservation, capacity, losses) and the figures at that q. """ @@ -182,8 +182,13 @@ def deliveries(prep, state): ), ], ) -def test_at_q_1_over_4096_the_matrix_matches_the_independent_prototype(scope, witness): - prep = backbone(scope, q=1 / 4096) +@pytest.mark.parametrize("declared_q", [None, 1 / 4096], ids=["auto", "manual"]) +def test_at_q_1_over_4096_the_matrix_matches_the_independent_prototype( + scope, witness, declared_q +): + prep = backbone(scope, q=declared_q) + # L=physical nodes+2*links and c_min=100 give the static bound q=2^-12. + assert prep.topology.q == 1 / 4096 baseline, failed = matrix(prep) for state in (baseline, failed): check_identities(prep.topology, state) @@ -225,8 +230,8 @@ def test_at_q_1_over_4096_the_matrix_matches_the_independent_prototype(scope, wi ), ], ) -def test_at_the_recipes_q_the_matrix_keeps_its_identities(scope, witness, drops): - prep = backbone(scope) +def test_at_declared_coarse_q_the_matrix_keeps_its_identities(scope, witness, drops): + prep = backbone(scope, q=0.25) assert prep.topology.q == 0.25 baseline, failed = matrix(prep) for state in (baseline, failed): diff --git a/tests/controller/test_failure_tables.py b/tests/controller/test_failure_tables.py index 06c070f..2d981a6 100644 --- a/tests/controller/test_failure_tables.py +++ b/tests/controller/test_failure_tables.py @@ -934,31 +934,6 @@ def test_a_zero_share_group_whose_member_fails_changes_nothing(): assert states["converged"].dead_caps == (DeadCap("T", "S", "s", 0, Fraction(1, 2)),) -def test_precision_misses_describe_published_converged_rows_by_their_case_table(): - # q = 1 misses the local target at S's router split. The failure case's row - # splits over its case table's three members, the baseline's over four. - prep = r30() - failures = request("link", "^S\\|A1$") - analysis = place( - prep, - PlacementQuery(prep.demand_sets["d"], failures=failures, workers=1, states=ALL), - ) - routers = { - miss.location.case_id: [ - c.members for c in miss.constraints if getattr(c, "kind", None) == "router" - ] - for miss in analysis.misses - } - assert routers == {"baseline": [4], "failure-000001": [3]} - stale = place( - prep, - PlacementQuery( - prep.demand_sets["d"], failures=failures, workers=1, states=["stale"] - ), - ) - assert stale.misses == () - - def test_dead_caps_reach_the_wire_on_converged_and_repair_only(): prep = r30() analysis = place( diff --git a/tests/controller/test_filling.py b/tests/controller/test_filling.py index c274170..57395e4 100644 --- a/tests/controller/test_filling.py +++ b/tests/controller/test_filling.py @@ -7,8 +7,7 @@ import netgraph_core as ngc import pytest -from ngraph.analysis.controller import fill, integer_weights, member_column -from ngraph.analysis.precision import propagate +from ngraph.analysis.controller import fill, integer_weights, member_column, propagate from tests.controller.helpers import ( delivered, demand, @@ -495,3 +494,19 @@ def unit_by_unit(weights, total): weights, total, ) + + +def test_highest_averages_lower_quota_oracle_keeps_unit_priority_and_ties(): + from tests.controller.helpers import highest_averages + + # Independent one-unit construction controls the accelerated test oracle. + for weights in ([1, 1, 1], [2, 1], [0, 7, 2, 0, 1], [1, 1000, 1], [0, 0]): + allocated = [0] * len(weights) + for total in range(31): + assert highest_averages(total, weights) == allocated + if any(weights): + index = max( + (i for i, w in enumerate(weights) if w), + key=lambda i: (Fraction(weights[i], allocated[i] + 1), -i), + ) + allocated[index] += 1 diff --git a/tests/controller/test_owner_figures_final_q.py b/tests/controller/test_owner_figures_final_q.py index 38fd2cd..ae17c11 100644 --- a/tests/controller/test_owner_figures_final_q.py +++ b/tests/controller/test_owner_figures_final_q.py @@ -5,7 +5,7 @@ EdgeId order (ECMP keeps a zero-capacity member), highest averages over explicit or flattened weights (a zero-capacity member has weight 0), and for tunnel counts the chains' fluid sizing, round robin over the plan's tunnels and the per-chain -minimum. Values at q = 1 or 1/4096 are kept only as labelled comparisons. +minimum. Values at declared q = 1, 1/128 or 1/4096 are kept as labelled comparisons. """ import math @@ -39,6 +39,19 @@ ) IDEAL = {"S1": 3200, "S2": 2400, "S3": 3125, "S4": 3100, "S5": 3000, "S6": 2125} +# Each EBB fixture has 66 physical nodes and 96 links: L=258, floor=133/2. +# Positive minimum capacities are 100, 50, 25, 100, 50, 25. A 256-tunnel +# count question has Gamma=645/2, whereas native/controller leaves use the floor. +NATIVE_Q = { + "S1": 1 / 1024, + "S2": 1 / 2048, + "S3": 1 / 4096, + "S4": 1 / 1024, + "S5": 1 / 2048, + "S6": 1 / 4096, +} +COUNT_Q = {name: q / 4 for name, q in NATIVE_Q.items()} + def rr(x, k): return [x // k + (1 if i < x % k else 0) for i in range(k)] @@ -165,8 +178,12 @@ def fits(x): def test_the_s2_count_curve_at_the_final_q(): prep, rows = count_rows("S2", S2_CURVE) q = prep.topology.q - # The up-to row's 256 members bind the recipe: 0.001 * 2400 / 255 -> 2^-7. - assert q == 1 / 128 + assert q == COUNT_Q["S2"] + for row in rows: + check_tunnel_oracles(prep, "S2", row) + # Retain the independently derived coarse-unit curve as a declared-q control. + prep, rows = count_rows("S2", S2_CURVE, q=1 / 128) + q = prep.topology.q for row in rows: check_tunnel_oracles(prep, "S2", row) lossless = [physical(r.lossless_at_design_units, q) for r in rows] @@ -263,9 +280,17 @@ def test_the_s2_count_curve_at_q_1_in_whole_units(): def test_t2_rows_of_every_ebb_state(state, final, at_q1): prep, rows = count_rows(state, (32, 64)) q = prep.topology.q - assert q == 1 / 128 + assert q == COUNT_Q[state] for row in rows: check_tunnel_oracles(prep, state, row) + assert ( + rows[-1].result.plan.tunnels_per_head, + physical(rows[-1].lossless_at_design_units, q), + ) == final[-2:] + # The literal figures below describe the declared 1/128 control; the automatic + # rows above are checked by the integer forwarding/sizing oracles at COUNT_Q. + prep, rows = count_rows(state, (32, 64), q=1 / 128) + q = prep.topology.q k32, k64, limit = rows assert ( physical(k32.lossless_at_design_units, q), @@ -308,7 +333,10 @@ def native_ebb(state, policy, volume): @pytest.mark.parametrize( ("state", "q", "figure", "q1"), - [("S2", 1 / 16, 1601, 1616), ("S5", 1 / 16, Fraction(3201, 2), 1608)], + [ + ("S2", 1 / 2048, Fraction(204801, 128), 1616), + ("S5", 1 / 2048, Fraction(409601, 256), 1608), + ], ) def test_native_ecmp_lossless_at_the_final_q(state, q, figure, q1): units, chosen = native_ebb(state, ECMP, 10000) @@ -343,13 +371,13 @@ def native_ebb_q1(state, policy, volume): def test_native_ecmp_s4_keeps_its_zero_member_at_the_final_q(): units, q = native_ebb("S4", ECMP, 10000) # The first 6 units avoid the zero-capacity seventh member. - assert (q, units, physical(units, q)) == (1 / 8, 6, Fraction(3, 4)) + assert (q, units, physical(units, q)) == (1 / 1024, 6, Fraction(3, 512)) units, q = native_ebb("S4", ECMP_LOSSY, 3100) minima = chain_minima("S4", q) offer = int(Fraction(3100) / Fraction(q)) assert units == sum(min(a, c) for a, c in zip(rr(offer, 32), minima, strict=True)) - # 3100 / q = 49600 units, 1550 per member, the zero member's 1550 lost. - assert (q, units, physical(units, q)) == (1 / 16, 48050, Fraction(24025, 8)) + # 3100/q=3174400 units, 99200 per member; the zero member loses its share. + assert (q, units, physical(units, q)) == (1 / 1024, 3075200, Fraction(24025, 8)) assert ( native_ebb_q1("S4", ECMP, 10000), native_ebb_q1("S4", ECMP_LOSSY, 3100), @@ -367,11 +395,11 @@ def test_wcmp_scope_native_ecmp_at_the_final_q(): q = Fraction(prep.topology.q) units = delivered(prep)[0]["d|0|0"] # QA1 (third uplink) halves its arrival onto the 20-link: x = 4 (40/q + 1) + 2 units. - assert (q, units) == (Fraction(1, 32), int(4 * (40 / q + 1) + 2)) + assert (q, units) == (Fraction(1, 1024), int(4 * (40 / q + 1) + 2)) assert physical(units, q) == 160 + 6 * q -def raw_plane(state, volume, admission): +def raw_plane(state, volume, admission, resolution=None): links = ebb_links(state) caps = {(u, v): c for u, v, c, *_ in links} plane = [ @@ -402,6 +430,8 @@ def raw_plane(state, volume, admission): "{type: shortest_paths, allocation: weighted, weight_basis: explicit, edge_weights: raw}, " f"admission: {admission}}}}}" ) + if resolution is not None: + lines.append(f"units: {{resolution: {resolution}}}") prep = prepare(Scenario.from_yaml("\n".join(lines)), demand_sets=("d",)) result = place( prep, PlacementQuery(prep.demand_sets["d"], states=("converged",), workers=1) @@ -427,14 +457,15 @@ def raw_plane(state, volume, admission): ) def test_raw_plane_weights_lossless_at_the_final_q(state, q, figure, q1): units, chosen, minima, weights = raw_plane(state, 10000, "lossless") - assert chosen == q + assert chosen == NATIVE_Q[state] assert units == largest( lambda x: all( a <= c for a, c in zip(highest_averages(x, weights), minima, strict=True) ), sum(minima), ) - assert physical(units, q) == figure + coarse, coarse_q, _, _ = raw_plane(state, 10000, "lossless", resolution=q) + assert coarse_q == q and physical(coarse, coarse_q) == figure def test_raw_plane_s4_lossy_at_the_final_q(): @@ -443,7 +474,9 @@ def test_raw_plane_s4_lossy_at_the_final_q(): assert units == sum( min(a, c) for a, c in zip(highest_averages(offer, weights), minima, strict=True) ) - assert (q, physical(units, q)) == (1 / 16, Fraction(48490, 16)) + assert q == NATIVE_Q["S4"] + coarse, coarse_q, _, _ = raw_plane("S4", 3100, "lossy", resolution=1 / 16) + assert (coarse_q, physical(coarse, coarse_q)) == (1 / 16, Fraction(48490, 16)) @pytest.mark.parametrize( @@ -479,7 +512,7 @@ def test_groups_a_and_b_at_the_final_q(members, groups, volume, carried): scale = 1 for f in flat: scale = scale * f.denominator // math.gcd(scale, f.denominator) - assert q == Fraction(1, 1024) + assert q == (Fraction(1, 512) if members == 3 else Fraction(1, 1024)) assert units == highest_averages(int(volume / q), [int(f * scale) for f in flat]) assert [physical(u, q) for u in units] == carried @@ -493,8 +526,8 @@ def test_groups_c_and_d_at_the_final_q(): q="auto", ) assert (c.topology.q, physical(delivered(c)[0]["d|0|0"], c.topology.q)) == ( - 1 / 16, + 1 / 2048, 2400, ) units, q = bound(ebb("S4", scope="group", trees={"planes": planes()}, q="auto")) - assert (q, physical(units, q)) == (1 / 8, 2400) + assert (q, physical(units, q)) == (1 / 1024, 2400) diff --git a/tests/controller/test_reports.py b/tests/controller/test_reports.py index 347e4d3..fae3ebc 100644 --- a/tests/controller/test_reports.py +++ b/tests/controller/test_reports.py @@ -65,8 +65,8 @@ def test_an_approximation_travels_from_preparation_to_every_report(): assert [e.label for e in step.result.units_report.weight_approximations] == [ "controller.ebb.DC_B.router.DC_A" ] - # The approximation changes neither the target nor the constraints or misses. - for name in ("target_met", "binding_constraints", "misses"): + # The approximation changes neither the target nor the input/count bounds. + for name in ("target_met", "input_bound", "split_bound", "achieved_precision"): assert getattr(result.units_report, name) == getattr(control.units_report, name) # The controller key reports the apportioned weights and nothing of the error. placed = wire["steps"][0]["result"]["controller"][0]["programmed"][0]["members"] diff --git a/tests/controller/test_tables.py b/tests/controller/test_tables.py index 6e255d9..221ab6e 100644 --- a/tests/controller/test_tables.py +++ b/tests/controller/test_tables.py @@ -1,7 +1,6 @@ """Destination tables: who is programmed, in what order, by which rule, and how they forward.""" import json -from fractions import Fraction from io import StringIO import netgraph_core as ngc @@ -422,35 +421,6 @@ def run(scope, volume, **kwargs): assert value == 166 -def test_precision_describes_programmed_rows_with_exact_downstream_amounts(): - links = [ - ("S", "P", 100), - ("S", "Q", 100), - ("P", "A", 100), - ("P", "B", 100), - ("A", "T", 100), - ("B", "T", 100), - ("Q", "T", 100), - ] - prep = prepared( - links, - "^(P|Q|A|B)$", - "^(S|T)$", - [demand("^S$", "^T$", 40)], - scope="group", - trees={"g": tree("S", (1, [(1, "S|P|0")]), (3, [(1, "S|Q|0")]))}, - q=2, - precision=0.1, - ) - splits = {c.label: c for c in prep.units_report.nominal_splits} - at_p = splits["demand.d|0|0.router.P"] - # S sends 40 * 1/4 to P, which splits it in two: bound 0.1 * 10 / 1, below q=2. - assert at_p.incoming_physical == 10 - assert at_p.q_upper_bound == Fraction(0.1) * 10 - assert at_p.met is False - assert splits["demand.d|0|0.router.S"].incoming_physical == 40 - - def controller_json(prep): _, analysis = delivered(prep) output = StringIO() @@ -671,30 +641,6 @@ def test_the_controller_key_lists_routers_in_node_order(): ] -def test_precision_of_a_multi_source_programmed_demand(): - links = [ - ("S0", "P", 100), - ("S1", "P", 100), - ("P", "A", 100), - ("P", "B", 100), - ("A", "T", 100), - ("B", "T", 100), - ] - prep = prepared( - links, - "^(P|A|B)$", - "^(S0|S1|T)$", - [demand("^(S0|S1)$", "^T$", 40)], - q=2, - precision=0.1, - ) - splits = {c.label: c for c in prep.units_report.nominal_splits} - # 40 splits equally over S0 and S1; their 20 + 20 meet at P, which splits in two. - assert set(splits) == {"demand.d|0|0.sources", "demand.d|0|0.router.P"} - for label in splits: - assert (splits[label].incoming_physical, splits[label].members) == (40, 2) - - def test_a_positive_amount_below_q_programs_its_source(): u = demand("^U$", "^T$", 0.25, id="u", priority=1) prep = stacked(S_DEMAND, u, q=1) @@ -830,23 +776,6 @@ def test_the_longest_child_orders_routers_even_when_it_finishes_first(): assert [prep.topology.node_names[r] for r in order] == ["n30", "n12", "S1", "S0"] -def test_precision_reads_each_demand_s_own_destination_table(): - prep = prepared( - [*STACKED, ("A", "T2", 10), ("B", "T2", 10)], - STACKED_DOMAIN, - "^(S|T|T2)$", - [S_DEMAND, demand("^U$", "^T2$", 1, id="u", priority=1)], - precision=0.1, - ) - splits = sorted((c.label, c.members) for c in prep.units_report.nominal_splits) - # U's demand splits over U-A and U-B toward T2; toward T only S and U programmed. - assert splits == [ - ("demand.s|0.router.S", 2), - ("demand.s|0.router.U", 2), - ("demand.u|0.router.U", 2), - ] - - def test_an_unknown_destination_table_is_a_labelled_key_error(): prep = stacked(S_DEMAND) with pytest.raises( diff --git a/tests/dev/test_api_docs_generator.py b/tests/dev/test_api_docs_generator.py index b8a109c..2e29e07 100644 --- a/tests/dev/test_api_docs_generator.py +++ b/tests/dev/test_api_docs_generator.py @@ -83,14 +83,14 @@ def _entry_text(page: str, path: str) -> str: def test_multiline_alias_definition_is_valid_python(): import ast - definition, _ = _MODULE._defined_value("ngraph", "UnitConstraint") - ast.parse("UnitConstraint = " + definition) - page = _entry_text(PAGE.read_text(), "ngraph.UnitConstraint") + definition, _ = _MODULE._defined_value("ngraph", "AnalysisResult") + ast.parse("AnalysisResult = " + definition) + page = _entry_text(PAGE.read_text(), "ngraph.AnalysisResult") block = re.search(r"```python\n(.*?)\n```", page, re.S) assert block is not None ast.parse(block[1]) with pytest.raises(SyntaxError): - ast.parse("UnitConstraint = A\n | B") + ast.parse("AnalysisResult = A\n | B") @pytest.mark.parametrize( diff --git a/tests/dev/test_results_schema.py b/tests/dev/test_results_schema.py index 26073b5..fa24451 100644 --- a/tests/dev/test_results_schema.py +++ b/tests/dev/test_results_schema.py @@ -22,7 +22,7 @@ def test_packaged_schema_equals_the_generated_schema(): def test_plain_record_definitions_follow_their_declared_fields(): tools = generator() - from ngraph.results import ExactAlpha, SelfPair, SplitConstraint + from ngraph.results import ExactAlpha, SelfPair, SplitCountConstraint alpha = tools["record"](ExactAlpha) assert list(alpha["properties"]) == ["value", "numerator", "denominator"] @@ -50,13 +50,17 @@ def test_plain_record_definitions_follow_their_declared_fields(): "node": {"type": "string"}, "capacity_units": {"enum": [0]}, } - split = tools["record"](SplitConstraint) + split = tools["record"](SplitCountConstraint) assert split["required"] == [ "kind", - "label", - "incoming_physical", - "basis", - "members", + "capacity_label", + "capacity", + "nodes", + "directed_links", + "group", + "demands", + "tunnels", + "count", "q_upper_bound", "met", ] diff --git a/tests/docs/test_narrated_answers.py b/tests/docs/test_narrated_answers.py index b7f0df6..1b0b6b0 100644 --- a/tests/docs/test_narrated_answers.py +++ b/tests/docs/test_narrated_answers.py @@ -89,7 +89,7 @@ def test_readme_says_what_its_example_prints(): assert result.cases[0].states["converged"].rows[0].result.delivered == int(shown) -def test_readme_names_the_question_and_its_precision_miss(): +def test_readme_names_the_question_and_its_unmet_precision_target(): """README: a CapacityQuery without a model asks TE_MAXFLOW_UNLIM; `cases[0]` is the case without failures with its one state; q = 1 misses the default target, nothing rounded.""" @@ -99,16 +99,16 @@ def test_readme_names_the_question_and_its_precision_miss(): case = space["result"].cases[0] assert case.case.kind == "baseline" and tuple(case.states) == ("converged",) report = space["prepared"].units_report - assert not report.target_met and report.inexact_inputs == report.zeroed_inputs == 0 + assert not report.target_met and report.limited_by == "declared" -def test_basic_states_its_precision_miss(): +def test_basic_states_its_unmet_precision_target(): """Basic Example: q = 1 misses the default target, nothing rounded.""" report = examples(BASIC, "network.add_link(Link(source")["prepared"].units_report - assert not report.target_met and report.inexact_inputs == report.zeroed_inputs == 0 + assert not report.target_met and report.limited_by == "declared" -def test_the_api_and_tunnel_count_examples_state_their_precision_miss(): +def test_the_api_and_tunnel_count_examples_state_their_unmet_precision_target(): """Python API and TunnelCountAnalysis: their examples fix q = 1, which misses the default precision target; nothing is rounded.""" api = "docs/reference/api.md" @@ -120,9 +120,7 @@ def test_the_api_and_tunnel_count_examples_state_their_precision_miss(): for prepared in (space["prepared"], counts): report = prepared.units_report assert prepared.topology.q == 1 and report.mode == "manual" - assert ( - not report.target_met and report.inexact_inputs == report.zeroed_inputs == 0 - ) + assert not report.target_met # How NetGraph computes @@ -213,26 +211,6 @@ def test_source_roster_narration(): assert delivered[5, 0] == after and delivered[0, 5] == 0 # "admits nothing" -def test_precision_narration(): - space = examples(CONCEPTS, "alpha: 0.05") - text = narration(CONCEPTS, "alpha: 0.05") - (exponent,) = numbers(text, "`q` is 2\\^(-?\\d+)") - assert space["prepared"].topology.q == 2 ** int(exponent) - alpha, volume, bound = ints( - ( - numbers(text, "alpha ([\\d.]+)") - + numbers(text, "volume of ([\\d.]+)") - + numbers(text, "`q ≤ ([\\d.]+)`") - ) - ) - [miss] = space["result"].units_report.misses - [step] = space["prepared"].scenario.workflow - [demand] = space["prepared"].scenario.demand_sets["pair"].demands - assert step.alpha == float(alpha) and demand.volume * step.alpha == float(volume) - [own] = [c for c in miss.constraints if c.label.endswith(".volume")] - assert own.q_upper_bound == Fraction(float(bound)) - - def test_tunnel_narration(): space = examples(CONCEPTS, "distributed_te") text = narration(CONCEPTS, "distributed_te") @@ -981,25 +959,6 @@ def test_the_alpha_and_expansion_worked_examples(): assert shares == [int(first), *map(int, second)] -def chain(amount): - return f""" -units: {{resolution: 1}} -network: - nodes: {{A: {{}}, B: {{}}, C: {{}}}} - links: [{{source: A, target: B, capacity: {amount}}}, {{source: B, target: C, capacity: {amount}}}] -""" - - -def test_the_precision_threshold_at_unit_resolution(): - """Precision: "At `q` = 1 and the default `p`, every input must be at least 500".""" - (least,) = stated(CONCEPTS, "at least (\\d+)") - met = [ - prepare(Scenario.from_yaml(chain(int(least) + d))).units_report.target_met - for d in (-1, 0) - ] - assert met == [False, True] - - def test_the_failure_coverage_figure(): """Failures: "`n` × ln(`n`/ε) draws, rounded up, miss some link with chance at most ε: 32 draws for 5 links at ε = 1 %"; and the stated draws do cover the five links of a diff --git a/tests/dsl/test_input_parity.py b/tests/dsl/test_input_parity.py index b1f50d5..eba77c6 100644 --- a/tests/dsl/test_input_parity.py +++ b/tests/dsl/test_input_parity.py @@ -253,8 +253,8 @@ def workflow(kind, field): lambda v: UnitsSettings(precision=v), lambda o: o.precision, lambda s: s.units.precision, - [0, -1, NAN, True, "1"], - [0.01, 1], + [0, -1, NAN, True, "1", 0.50001, 1], + [0.01, 0.5], ), ( "units resolution", diff --git a/tests/integration/test_shipped_scenarios.py b/tests/integration/test_shipped_scenarios.py index 9c0ec95..4a53b64 100644 --- a/tests/integration/test_shipped_scenarios.py +++ b/tests/integration/test_shipped_scenarios.py @@ -119,7 +119,7 @@ def test_square_mesh_divides_the_declared_total_among_twelve_pairs(): prepared = prepare( Scenario.from_yaml(Path("scenarios/square_mesh.yaml").read_text()) ) - assert prepared.topology.q == 1 / 1024 + assert prepared.topology.q == 1 / 131072 (demands,) = prepared.demand_sets.values() pairs = [ (s, t) @@ -131,7 +131,7 @@ def test_square_mesh_divides_the_declared_total_among_twelve_pairs(): (r.id, r.order, r.endpoints.sources, r.endpoints.destinations, r.nominal_units) for r in demands.demands ] == [ - (f"baseline_traffic_matrix|0|{i}", i, (s,), (t,), 1024) + (f"baseline_traffic_matrix|0|{i}", i, (s,), (t,), 131072) for i, (s, t) in enumerate(pairs) ] (accounting,) = demands.accounting @@ -140,20 +140,17 @@ def test_square_mesh_divides_the_declared_total_among_twelve_pairs(): accounting.resolved_units, accounting.discarded_units, accounting.discards, - ) == (12288, 12288, 0, ()) + ) == (1572864, 1572864, 0, ()) report = prepared.units_report assert ( report.exponent, report.target_met, report.augmented_capacity_sum_units, - ) == (-10, True, 20480) - (split,) = [s for s in report.nominal_splits if s.kind == "expansion"] - assert (split.members, split.incoming_physical, split.q_upper_bound, split.met) == ( - 12, - Fraction(12), - Fraction(float(0.001)) * 12 / 11, - True, - ) + ) == (-17, True, 2621440) + assert report.split_bound.count == Fraction(72) + assert report.split_bound.demands == 12 + assert report.split_bound.q_upper_bound == Fraction(0.001) / 72 + assert report.split_bound.met value = run(prepared) msd = next(s.result for s in value.steps if s.name == "msd_baseline") assert msd.alpha_feasible.value == 1 @@ -173,10 +170,12 @@ def test_square_mesh_divides_the_declared_total_among_twelve_pairs(): for case in placement.cases: state = case.states["converged"] assert [(r.id, r.reference_request_units) for r in state.rows] == [ - (r.id, 1024) for r in demands.demands + (r.id, 131072) for r in demands.demands ] impaired = bool(set(case.case.pattern.excluded_links) - {"N1|N3|0", "N2|N4|0"}) - expected = (12288, 10240, 2048, 2048) if impaired else (12288, 12288, 0, 0) + expected = ( + (1572864, 1310720, 262144, 262144) if impaired else (1572864, 1572864, 0, 0) + ) assert ( state.totals.reference_request_units, state.totals.delivered_units, diff --git a/tests/results/test_analysis_wire.py b/tests/results/test_analysis_wire.py index 122f3ef..6a050c8 100644 --- a/tests/results/test_analysis_wire.py +++ b/tests/results/test_analysis_wire.py @@ -370,9 +370,7 @@ class Step(WorkflowStep): def run(self, context): return StepOutput(value.query, value) - for bad in ( - replace(value.units_report, input_count=value.units_report.input_count + 1), - ): + for bad in (replace(value.units_report, exponent=value.units_report.exponent + 1),): forged = replace(context, units_report=bad) class Bad(WorkflowStep): diff --git a/tests/results/test_record_methods.py b/tests/results/test_record_methods.py index 0d4e7f4..17391eb 100644 --- a/tests/results/test_record_methods.py +++ b/tests/results/test_record_methods.py @@ -23,7 +23,7 @@ def test_records_share_value_methods_and_keep_frozen_field_metadata(): and kind.__module__ == results.__name__ and dataclasses.is_dataclass(kind) ] - assert len(records) == 67 + assert results.SplitCountConstraint in records for kind in records: assert kind.__dataclass_params__.frozen names = tuple(field.name for field in dataclasses.fields(kind)) diff --git a/tests/results/test_report_field_plants.py b/tests/results/test_report_field_plants.py index 6659390..8ce56b9 100644 --- a/tests/results/test_report_field_plants.py +++ b/tests/results/test_report_field_plants.py @@ -24,7 +24,7 @@ run, write_json, ) -from ngraph.results import PreparationScope +from ngraph.results import InputConstraint, PreparationScope, SplitCountConstraint from ngraph.workflow.build_graph import BuildGraph @@ -45,16 +45,14 @@ def fixture(): demand_sets=("load",), ) value = run(prepared) - # One physical capacity and one physical parent input are both exactly4. - # Their input bounds are2*1/4*4=2, so q1 is met and not binding (2q=2). - # One source, one edge and no expansion fork produce no split constraints. - # Both directions give capacity sum8 and cost sum6. + # v_min=c_min=4, L=4 and Gamma=3. Manual q1 has input bound2 met, + # split bound1/3 unmet, achieved3/4. Directed capacity/cost sums are8/6. expected_units = { "q": 1.0, "exponent": 0, "mode": "manual", "precision": 0.25, - "target_met": True, + "target_met": False, "input_convention": "promoted_binary64_half_up", "display_convention": "units_authoritative_binary64_display", "scope": { @@ -62,19 +60,30 @@ def fixture(): "workflow_demand_sets": [], "library_demand_sets": ["load"], }, - "binding_constraints": [], - "input_count": 2, - "inexact_inputs": 0, - "zeroed_inputs": 0, - "inexact_examples": [], - "zeroed_examples": [], - "max_absolute_input_error": {"numerator": 0, "denominator": 1}, - "max_relative_input_error": {"numerator": 0, "denominator": 1}, + "limited_by": "declared", + "achieved_precision": {"numerator": 3, "denominator": 4}, + "input_bound": { + "kind": "input", + "label": "link.S|T|0.capacity", + "q_upper_bound": {"numerator": 2, "denominator": 1}, + "met": True, + }, + "split_bound": { + "kind": "split_count", + "capacity_label": "link.S|T|0.capacity", + "capacity": 4.0, + "nodes": 2, + "directed_links": 2, + "group": None, + "demands": 0, + "tunnels": 0, + "count": {"numerator": 3, "denominator": 1}, + "q_upper_bound": {"numerator": 1, "denominator": 3}, + "met": False, + }, + "range_limit": None, "augmented_capacity_sum_units": 8, "augmented_cost_sum": 6, - "nominal_split_count": 0, - "nominal_splits": [], - "misses": [], "weight_approximations": [], } expected = { @@ -143,17 +152,14 @@ def fixture(): # The two conventions are class constants of every report, not its fields. del expected_python["input_convention"], expected_python["display_convention"] expected_python["scope"] = PreparationScope((), (), ("load",)) - for name in ( - "binding_constraints", - "inexact_examples", - "zeroed_examples", - "nominal_splits", - "misses", - "weight_approximations", - ): - expected_python[name] = () - for name in ("max_absolute_input_error", "max_relative_input_error"): - expected_python[name] = Fraction(0) + expected_python["weight_approximations"] = () + expected_python["achieved_precision"] = Fraction(3, 4) + expected_python["input_bound"] = InputConstraint( + "link.S|T|0.capacity", Fraction(2), True + ) + expected_python["split_bound"] = SplitCountConstraint( + "link.S|T|0.capacity", 4.0, 2, 2, None, 0, 0, Fraction(3), Fraction(1, 3), False + ) return value, expected, expected_python diff --git a/tests/results/test_units_miss_field_plants.py b/tests/results/test_units_miss_field_plants.py deleted file mode 100644 index 2134c43..0000000 --- a/tests/results/test_units_miss_field_plants.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Nonempty units diagnostics retain exact errors, constraints and probe locations.""" - -from fractions import Fraction - -import netgraph_core as ngc -import pytest - -from ngraph import ( - CapacityQuery, - DemandSet, - DemandSpec, - FlowPolicyPreset, - Link, - Network, - Node, - PlacementQuery, - Scenario, - Selector, - UnitsSettings, - capacity, - place, - prepare, -) -from tests.results.test_analysis_wire import project -from tests.results.test_report_field_plants import exact - - -def fraction(value): - value = Fraction(value) - return {"numerator": value.numerator, "denominator": value.denominator} - - -def input_bound(label, amount, precision): - bound = 2 * Fraction(precision) * Fraction(amount) - return { - "kind": "input", - "label": label, - "q_upper_bound": fraction(bound), - "met": Fraction(1) <= bound, - } - - -def sample(label, kind): - return { - "label": label, - "kind": kind, - "binary64_value": 0.25, - "binary64_hex": "0x1.0000000000000p-2", - "units": 0, - "error": fraction(Fraction(-1, 4)), - "relative_error": fraction(-1), - } - - -def oracle(variant): - if variant == "opaque": - return opaque_oracle() - network = Network( - nodes={ - name: Node(name) - for name in ( - ("a", "b", "t") if variant in ("split", "weighted") else ("s", "t") - ) - } - ) - precision = 1e-18 if variant == "range" else 0.125 - volume = 1 if variant in ("split", "weighted") else 0.25 - if variant in ("split", "weighted"): - network.add_link(Link("a", "t", 4, 1)) - network.add_link(Link("b", "t", 4, 3)) - else: - network.add_link(Link("s", "t", 2**60 if variant == "range" else 0.25, 3)) - demand_set = DemandSet( - "load", - ( - DemandSpec( - Selector("^[ab]$" if variant in ("split", "weighted") else "^s$"), - Selector("^t$"), - volume, - ngc.FlowPolicyConfig( - routing=ngc.CentralizedTE( - source_allocation=ngc.SourceAllocation.WEIGHTED, - source_weights=[1, 1, 0], - ) - ) - if variant == "weighted" - else FlowPolicyPreset.SHORTEST_PATHS_ECMP, - ), - ), - ) - context = prepare( - Scenario( - network, - demand_sets={"load": demand_set}, - units=UnitsSettings("auto" if variant == "range" else 1, precision), - ), - demand_sets=("load",), - ) - analysis = place( - context, - PlacementQuery( - context.demand_sets["load"], - alpha=4 if variant == "rounding" else 1, - workers=1, - ), - ) - demand_label = "demand.load|0.volume" - nominal_constraints = [input_bound(demand_label, volume, precision)] - observed_constraints = [ - input_bound(demand_label, 1 if variant == "rounding" else volume, precision) - ] - nominal_splits = [] - nominal_examples = ( - [sample(demand_label, "demand")] if variant not in ("split", "weighted") else [] - ) - observed_examples = [] - if variant == "rounding": - label = "link.s|t|0.capacity" - nominal_constraints.append(input_bound(label, 0.25, precision)) - observed_constraints.append(input_bound(label, 0.25, precision)) - nominal_examples.append(sample(label, "capacity")) - observed_examples.append(sample(label, "capacity")) - if variant in ("split", "weighted"): - # Each capacity4 binds at q1: 2*p*4=1 meets q1, fails q2. - nominal_constraints.extend( - input_bound(f"link.{source}|t|0.capacity", 4, precision) - for source in ("a", "b") - ) - # One combined two-source leaf gives one equal-source split of y1. - # q bound=(1/8)*1/(2-1)=1/8, independent of integer admission. - split = { - "kind": "source", - "label": "demand.load|0|0.sources", - "incoming_physical": fraction(1), - "basis": "declared", - "members": 2, - "q_upper_bound": fraction(Fraction(1, 8)), - "met": False, - } - nominal_splits.append(split) - nominal_constraints.append(split) - observed_constraints.append(split) - if variant == "range": - # q1 fits directed sum2^61; q1/2 reaches the excluded2^62 bound. - nominal_constraints.append( - { - "kind": "capacity_sum", - "label": "topology.augmented_capacity_sum", - "tested_q": 0.5, - "units_value": 2**62, - "exclusive_limit": 2**62, - "met": False, - } - ) - observed_examples = [sample(demand_label, "demand")] - expected = { - "q": 1.0, - "exponent": 0, - "mode": "auto" if variant == "range" else "manual", - "precision": precision, - "target_met": False, - "input_convention": "promoted_binary64_half_up", - "display_convention": "units_authoritative_binary64_display", - "scope": { - "workflow_steps": [], - "workflow_demand_sets": [], - "library_demand_sets": ["load"], - }, - "binding_constraints": nominal_constraints, - "input_count": 3 if variant in ("split", "weighted") else 2, - "inexact_inputs": len(nominal_examples), - "zeroed_inputs": len(nominal_examples), - "inexact_examples": nominal_examples, - "zeroed_examples": nominal_examples, - "max_absolute_input_error": fraction(Fraction(1, 4) if nominal_examples else 0), - "max_relative_input_error": fraction(1 if nominal_examples else 0), - "augmented_capacity_sum_units": { - "rounding": 0, - "split": 16, - "weighted": 16, - "range": 2**61, - }[variant], - "augmented_cost_sum": 8 if variant in ("split", "weighted") else 6, - "nominal_split_count": len(nominal_splits), - "nominal_splits": nominal_splits, - "misses": [ - { - "location": {"step": None, "case_id": "baseline", "probe_index": None}, - "constraints": observed_constraints, - "inexact_inputs": len(observed_examples), - "zeroed_inputs": len(observed_examples), - "examples": observed_examples, - } - ], - "weight_approximations": [], - } - exact(project(analysis)["units_report"], expected) - - -def opaque_oracle(): - network = Network(nodes={name: Node(name) for name in ("s", "t")}) - network.add_link(Link("s", "t", 4, 3)) - context = prepare(Scenario(network, units=UnitsSettings(1, 0.125))) - graph = context.topology.graph - dag = ngc.spf(graph, [0], ngc.SpfOptions(ngc.Direction.FORWARD)).dag - program = ngc.ForwardingProgram.from_dag(graph, [0], [1], dag) - result = capacity( - context, - CapacityQuery( - Selector("^s$"), - Selector("^t$"), - ngc.FlowPolicyConfig(routing=ngc.Pinned(program)), - offer=2, - workers=1, - ), - ) - expected = { - "q": 1.0, - "exponent": 0, - "mode": "manual", - "precision": 0.125, - "target_met": False, - "input_convention": "promoted_binary64_half_up", - "display_convention": "units_authoritative_binary64_display", - "scope": { - "workflow_steps": [], - "workflow_demand_sets": [], - "library_demand_sets": [], - }, - "binding_constraints": [input_bound("link.s|t|0.capacity", 4, 0.125)], - "input_count": 1, - "inexact_inputs": 0, - "zeroed_inputs": 0, - "inexact_examples": [], - "zeroed_examples": [], - "max_absolute_input_error": fraction(0), - "max_relative_input_error": fraction(0), - "augmented_capacity_sum_units": 8, - "augmented_cost_sum": 6, - "nominal_split_count": 0, - "nominal_splits": [], - "misses": [ - { - "location": {"step": None, "case_id": "baseline", "probe_index": None}, - "constraints": [ - input_bound("capacity.offer", 2, 0.125), - { - "kind": "unverified", - "label": "capacity.pair[0]", - "reason": "opaque_program", - }, - ], - "inexact_inputs": 0, - "zeroed_inputs": 0, - "examples": [], - } - ], - "weight_approximations": [], - } - exact(project(result)["units_report"], expected) - - -@pytest.mark.parametrize( - "variant", ["rounding", "split", "weighted", "range", "opaque"] -) -def test_nonempty_units_report_is_exact(variant): - oracle(variant) diff --git a/tests/results/test_wire_key_order.py b/tests/results/test_wire_key_order.py index 16cdda5..281ba6b 100644 --- a/tests/results/test_wire_key_order.py +++ b/tests/results/test_wire_key_order.py @@ -120,21 +120,25 @@ def test_exported_keys_keep_their_hand_written_order(): def test_every_plain_record_writes_its_constants_and_fields_in_declared_order(): from fractions import Fraction - from ngraph.results import ExactAlpha, PoolHead, PoolMetadata, SplitConstraint + from ngraph.results import ExactAlpha, PoolHead, PoolMetadata, SplitCountConstraint - split = SplitConstraint( - "router", "x", Fraction(3, 2), "observed", 2, Fraction(1), True + split = SplitCountConstraint( + "link.x.capacity", 4.0, 2, 2, None, 0, 0, Fraction(3), Fraction(1, 3), False ) assert list(project(split)) == [ "kind", - "label", - "incoming_physical", - "basis", - "members", + "capacity_label", + "capacity", + "nodes", + "directed_links", + "group", + "demands", + "tunnels", + "count", "q_upper_bound", "met", ] - assert project(split)["incoming_physical"] == {"numerator": 3, "denominator": 2} + assert project(split)["count"] == {"numerator": 3, "denominator": 1} pool = project(PoolMetadata(PoolHead("p", ("a", "b")))) assert pool == { "head": {"pool_id": "p", "physical_sources": ["a", "b"]}, diff --git a/tests/scenario/test_atomic_inputs.py b/tests/scenario/test_atomic_inputs.py index c6e7ae1..8144eaa 100644 --- a/tests/scenario/test_atomic_inputs.py +++ b/tests/scenario/test_atomic_inputs.py @@ -155,10 +155,9 @@ def test_expansion_ids_keep_source_ordinal_and_leaf_index_without_double_append( assert [d.id for d in resolved.demands] == ["x|0|0|0", "x|0|1|0", "x|1|0"] -def test_uncapped_observed_router_bound_refines_candidate_after_input_screen(): +def test_capacity_question_uses_static_graph_count(): network = Network() - # At q=1/2 every capacity input meets precision=1/4. Three equal members - # deliver3, but q<=precision*3/(3-1)=3/8 fails. q=1/4 meets it. + # L=5+12=17, Gamma=25/4, so B_split=(1/4)/(25/4)=1/25 -> q=1/32. for name in ("s", "a", "b", "c", "t"): network.add_node(Node(name)) for middle in ("a", "b", "c"): @@ -172,15 +171,10 @@ def test_uncapped_observed_router_bound_refines_candidate_after_input_screen(): workers=1, ) prepared = prepare(Scenario(network, [step], units=UnitsSettings(precision=0.25))) - assert prepared.topology.q == 0.25 - (split,) = [c for c in prepared.units_report.nominal_splits if c.kind == "router"] - assert split.basis == "observed" and split.incoming_physical == Fraction(3) - assert split.members == 3 and split.q_upper_bound == Fraction(3, 8) and split.met + assert prepared.topology.q == 0.03125 + assert prepared.units_report.split_bound.count == Fraction(25, 4) coarse = prepare(Scenario(network, [step], units=UnitsSettings(0.5, 0.25))) assert coarse.topology.q == 0.5 and not coarse.units_report.target_met - assert any( - c.basis == "observed" and not c.met for c in coarse.units_report.nominal_splits - ) analysis = capacity( coarse, CapacityQuery( @@ -191,7 +185,6 @@ def test_uncapped_observed_router_bound_refines_candidate_after_input_screen(): ), ) assert analysis.cases[0].states["converged"].rows[0].result.delivered == 6 - assert analysis.units_report.misses[0].constraints[0].basis == "observed" def test_default_equal_source_bound_keeps_reachable_zero_capacity_heads(): @@ -212,16 +205,12 @@ def test_default_equal_source_bound_keeps_reachable_zero_capacity_heads(): workers=1, ) prepared = prepare(Scenario(network, [step], units=UnitsSettings(precision=0.25))) - assert prepared.topology.q == 0.125 - (split,) = [c for c in prepared.units_report.nominal_splits if c.kind == "source"] - assert ( - split.members == 3 - and split.incoming_physical == 1 - and split.q_upper_bound == Fraction(1, 8) - ) - assert split.basis == "declared" and split.met + # L=10 gives floor9/2 and 0.25/(9/2) chooses q=1/32. + assert prepared.topology.q == 0.03125 + assert prepared.units_report.split_bound.demands == 0 value = run(prepared).steps[0].result - assert value.cases[0].states["converged"].rows[0].result.delivered == 3 + # 32 offered units split 11,11,10; the two zero-capacity heads lose theirs. + assert value.cases[0].states["converged"].rows[0].result.delivered == 11 def test_finite_tunnel_design_split_is_bounded_separately_from_large_offer(): @@ -239,13 +228,7 @@ def test_finite_tunnel_design_split_is_bounded_separately_from_large_offer(): model: {routing: {type: distributed_te, tunnel_count: {per_head: 4}}, admission: lossy} """) prepared = prepare(scenario) - assert prepared.topology.q == 0.0625 - (split,) = [ - c for c in prepared.units_report.nominal_splits if ".design." in c.label - ] - assert ( - split.basis == "declared" - and split.incoming_physical == 1 - and split.members == 4 - ) - assert split.q_upper_bound == Fraction(1, 12) + assert prepared.topology.q == 0.5 + assert prepared.units_report.input_bound.label == "workflow.tunnels.design_demand" + assert prepared.units_report.split_bound.tunnels == 4 + assert prepared.units_report.split_bound.count == 7 diff --git a/tests/scenario/test_prepared_answers.py b/tests/scenario/test_prepared_answers.py index 8e54858..3c658bf 100644 --- a/tests/scenario/test_prepared_answers.py +++ b/tests/scenario/test_prepared_answers.py @@ -1,4 +1,4 @@ -"""One prepared scenario: analyses return their own misses, steps state their needs.""" +"""One prepared scenario: analyses share its report, steps state their needs.""" from dataclasses import dataclass, fields @@ -21,7 +21,7 @@ ) from ngraph.analysis.preparation import StepNeeds from ngraph.analysis.topology import PreparedTopology -from ngraph.results import StepOutput, UnitsReport +from ngraph.results import StepOutput from ngraph.workflow.base import WorkflowStep from ngraph.workflow.max_flow_step import MaxFlow @@ -44,51 +44,6 @@ def ecmp(name): ) -def test_each_analysis_returns_only_its_own_misses(): - prepared = prepare(Scenario(network(), units=UnitsSettings(0.5, 0.25))) - assert prepared.units_report.misses == () - query = CapacityQuery( - Selector("^s$"), - Selector("^t$"), - ecmp("x").query(prepared).config, - workers=1, - ) - first, second = capacity(prepared, query), capacity(prepared, query) - assert len(first.misses) == len(second.misses) == 1 - assert first.misses == second.misses - assert first.prepared is prepared and first.topology is prepared.topology - assert first.units_report == prepared.units_report.with_misses(first.misses) - assert second.units_report.misses == first.misses - assert prepared.units_report.misses == () - - -def test_run_labels_each_steps_misses_and_composes_the_report_once(monkeypatch): - prepared = prepare( - Scenario( - network(), [ecmp("first"), ecmp("second")], units=UnitsSettings(0.5, 0.25) - ) - ) - composed = [] - original = UnitsReport.with_misses - - def counted(report, misses): - composed.append(misses) - return original(report, misses) - - monkeypatch.setattr(UnitsReport, "with_misses", counted) - result = run(prepared) - assert len(composed) == 1 - assert [miss.location.step for miss in result.units_report.misses] == [ - "first", - "second", - ] - assert [step.result.misses[0].location.step for step in result.steps] == [ - None, - None, - ] - assert result.prepared is prepared and result.topology is prepared.topology - - @dataclass class Needs(WorkflowStep): """A custom step that asks preparation to bind one demand set.""" @@ -145,3 +100,62 @@ def test_prepared_topology_holds_the_graph_and_its_labels_only(): "pool_budgets", "_edge_id_by_ref", ] + + +def test_every_analysis_and_run_keep_the_prepared_report(): + import netgraph_core as ngc + + from ngraph import ( + MSDQuery, + PathQuery, + PlacementQuery, + SensitivityQuery, + TunnelCountQuery, + edge_sensitivity, + maximum_supported_demand, + paths, + place, + tunnel_counts, + ) + + load = DemandSet( + "load", + ( + DemandSpec( + Selector("^s$"), + Selector("^t$"), + 1, + ngc.FlowPolicyConfig( + routing=ngc.CentralizedTE( + source_allocation=ngc.SourceAllocation.EQUAL, + tunnel_count=ngc.TunnelCount(16), + ) + ), + ), + ), + ) + p = prepare( + Scenario( + network(), + [ecmp("cap")], + demand_sets={"load": load}, + units=UnitsSettings(0.5, 0.25), + ), + demand_sets=("load",), + ) + source, target = Selector("^s$"), Selector("^t$") + config = ngc.FlowPolicyConfig( + routing=ngc.CentralizedTE(source_allocation=ngc.SourceAllocation.OPTIMIZED) + ) + ds = p.demand_sets["load"] + values = ( + capacity(p, CapacityQuery(source, target, workers=1)), + place(p, PlacementQuery(ds, workers=1)), + edge_sensitivity(p, SensitivityQuery(source, target, config, workers=1)), + paths(p, PathQuery(source, target)), + maximum_supported_demand(p, MSDQuery(ds, alpha_max=1)), + tunnel_counts(p, TunnelCountQuery(ds, (1,), 1, 1, workers=1)), + run(p), + ) + assert all(value.units_report is p.units_report for value in values) + assert all(value.prepared is p and value.topology is p.topology for value in values) diff --git a/tests/scenario/test_publication_faults.py b/tests/scenario/test_publication_faults.py index 5c4b1ee..2cec0a7 100644 --- a/tests/scenario/test_publication_faults.py +++ b/tests/scenario/test_publication_faults.py @@ -8,22 +8,17 @@ import pytest from ngraph import ( - FlowPolicyPreset, Link, Network, Node, Scenario, - Selector, - UnitsReport, UnitsSettings, prepare, run, write_json, ) -from ngraph.results import ReportLocation from ngraph.workflow.build_graph import BuildGraph from ngraph.workflow.cost_power import CostPower -from ngraph.workflow.max_flow_step import MaxFlow from ngraph.workflow.network_stats import NetworkStats from tests.workflow.test_cost_power import _build_simple_components @@ -78,12 +73,10 @@ def failed(*args, **kwargs): ("ngraph.scenario", "replace", 1), ("ngraph.scenario", "prepare_scenario", 1), ("ngraph.analysis.preparation", "PreparedScenario", 1), - ("ngraph.analysis.preparation", "with_splits", 1), ("ngraph.analysis.topology", "MappingProxyType", 3), ("ngraph.analysis.preparation", "MembershipIndex.build", 1), ("ngraph.analysis.preparation", "make_report", 1), - ("ngraph.analysis.units", "input_samples", 1), - ("ngraph.analysis.units", "input_constraint", 2), + ("ngraph.analysis.units", "input_constraint", 1), ], ) def test_prepare_publication_failure_and_retry( @@ -149,75 +142,3 @@ def test_domain_and_run_publication_failure_and_retry( assert list(graph.edge_capacity_units()) == capacities assert project(previous) == expected assert project(run(prepared)) == expected - - -@pytest.mark.parametrize("grown", [False, True]) -@pytest.mark.parametrize("boundary", ["location", "merged-report"]) -def test_run_units_merge_failure_after_complete_steps_and_same_thread_retry( - monkeypatch, grown, boundary -): - # Each step's misses are labelled with its name as it completes; the run - # merges them into one report once, after the last step. - import ngraph.results as records - import ngraph.scenario as scenarios - - network = Network(nodes={name: Node(name) for name in ("s", "a", "b", "c", "t")}) - for middle in ("a", "b", "c"): - network.add_link(Link("s", middle, 1, 1)) - network.add_link(Link(middle, "t", 1, 1)) - prepared = prepare( - Scenario( - network, - [ - MaxFlow( - name=name, - source=Selector("^s$"), - target=Selector("^t$"), - preset=FlowPolicyPreset.SHORTEST_PATHS_ECMP, - workers=1, - ) - for name in ("first", "second") - ], - units=UnitsSettings(0.5, 0.25), - ) - ) - nominal = prepared.units_report - assert nominal.misses == () and not nominal.target_met - previous = run(prepared) - assert [miss.location.step for miss in previous.units_report.misses] == [ - "first", - "second", - ] - expected = project(previous) - if grown: - assert project(run(prepared)) == expected - owner = scenarios if boundary == "location" else records - failing = 2 if boundary == "location" else 1 - original = owner.replace - hits = [] - - def failed(value, **kwargs): - result = original(value, **kwargs) - selected = ( - boundary == "location" - and isinstance(value, ReportLocation) - and "step" in kwargs - ) or ( - boundary == "merged-report" - and isinstance(value, UnitsReport) - and kwargs.get("misses") - ) - if selected: - hits.append(result) - if len(hits) == failing: - raise MemoryError("injected units merge") - return result - - with monkeypatch.context() as patch: - patch.setattr(owner, "replace", failed) - with pytest.raises(MemoryError, match="units merge"): - run(prepared) - assert len(hits) == failing - assert prepared.units_report is nominal and nominal.misses == () - assert project(previous) == expected - assert project(run(prepared)) == expected diff --git a/tests/test_startup_imports.py b/tests/test_startup_imports.py index ef30b67..a189728 100644 --- a/tests/test_startup_imports.py +++ b/tests/test_startup_imports.py @@ -137,7 +137,7 @@ def test_the_three_public_name_declarations_agree(): mapped = set(ngraph._EXPORTS) exported = set(ngraph.__all__) - {"__version__"} assert typed == mapped == exported - assert len(exported) == 124 + assert "SplitCountConstraint" in exported def test_command_line_help_loads_no_engine(): diff --git a/tests/typing/valid_client.py b/tests/typing/valid_client.py index aa3ce36..5a98c5d 100644 --- a/tests/typing/valid_client.py +++ b/tests/typing/valid_client.py @@ -50,7 +50,6 @@ PlacementAnalysis, PlacementQuery, PlacementRow, - PoolHead, RangeConstraint, ResolvedDemandSet, RouteSpec, @@ -61,16 +60,14 @@ SelfPair, SensitivityAnalysis, SensitivityQuery, - SplitConstraint, + SplitCountConstraint, StepContext, StepOutput, SupportSpec, TunnelCountAnalysis, TunnelCountQuery, - TunnelResolutionConstraint, UnitsReport, UnitsSettings, - UnverifiedConstraint, WCMPController, WeightSpec, WorkflowStep, @@ -221,38 +218,28 @@ def read_units(report: UnitsReport) -> float: sets: tuple[str, ...] = report.scope.library_demand_sets for name in sets: assert isinstance(name, str) - maximum: Fraction = report.max_absolute_input_error - relative: Fraction = report.max_relative_input_error - assert maximum >= 0 and relative >= 0 - for constraint in report.binding_constraints: - label: str = constraint.label - assert label - if isinstance(constraint, (InputConstraint, SplitConstraint)): - bound: Fraction = constraint.q_upper_bound - assert bound >= 0 - if isinstance(constraint, SplitConstraint): - assert constraint.basis in ("declared", "observed") - incoming: Fraction = constraint.incoming_physical - assert incoming >= 0 - elif isinstance(constraint, RangeConstraint): - integer: int = constraint.units_value - assert integer >= 0 - elif isinstance(constraint, TunnelResolutionConstraint): - observed_q: float = constraint.q - heads: tuple[str | PoolHead, ...] = constraint.heads - unmet: Literal[False] = constraint.met - assert observed_q in (q, 2 * q) and heads and not unmet - else: - opaque: UnverifiedConstraint = constraint - assert opaque.reason == "opaque_program" - for miss in report.misses: - case: str | None = miss.location.case_id - index: int | None = miss.location.probe_index - assert case is None or isinstance(case, str) - assert index is None or index >= 0 - for sample in miss.examples: - error: Fraction = sample.error - assert sample.binary64_hex and isinstance(error, Fraction) + achieved: Fraction | None = report.achieved_precision + assert achieved is None or achieved >= 0 + input_bound: InputConstraint | None = report.input_bound + split_bound: SplitCountConstraint | None = report.split_bound + range_limit: RangeConstraint | None = report.range_limit + if input_bound is not None: + label: str = input_bound.label + bound: Fraction = input_bound.q_upper_bound + assert label and bound > 0 + if split_bound is not None: + count: Fraction = split_bound.count + group: str | None = split_bound.group + assert count > 0 and (group is None or isinstance(group, str)) + assert split_bound.nodes >= 0 and split_bound.directed_links >= 0 + assert ( + split_bound.capacity > 0 + and split_bound.demands >= 0 + and split_bound.tunnels >= 0 + ) + if range_limit is not None: + integer: int = range_limit.units_value + assert integer >= 0 and report.limited_by == "range" for approximation in report.weight_approximations: kind: Literal["weight_approximation"] = approximation.kind relative_error: Fraction = approximation.max_relative_error diff --git a/tests/workflow/test_msd_resolution.py b/tests/workflow/test_msd_resolution.py index f5db244..275e0f0 100644 --- a/tests/workflow/test_msd_resolution.py +++ b/tests/workflow/test_msd_resolution.py @@ -47,14 +47,10 @@ def test_many_lsps_over_small_pairwise_volumes_find_tested_feasible_alpha(): ) prepared = prepare(Scenario(network, (step,), demand_sets={"load": demands})) result = run(prepared).steps[0].result - # Each parent of 1 splits among three off-diagonal pairs: p*1/(3-1) - # is .0005, whose coarsest power of two is 2^-11. Each pair's small - # design fits one tunnel, so the up-to limit adds no tunnel constraint. - assert prepared.topology.q == 1 / 2048 - assert all( - c.kind == "expansion" and c.members == 3 - for c in prepared.units_report.binding_constraints - ) + # Twelve leaves with one pool head and limit256 each: floor=15/2, + # Gamma=3072+15/2=6159/2; c_min=100 -> q=2^-15. + assert prepared.topology.q == 2**-15 + assert prepared.units_report.split_bound.tunnels == 3072 assert 64 <= result.alpha_feasible.value <= 512 assert result.history[0].outcome == "feasible" assert result.proof == "unproved" and result.upper_certificate is None diff --git a/tests/workflow/test_name_limits_and_owned_fields.py b/tests/workflow/test_name_limits_and_owned_fields.py index d4affea..7e5ae9b 100644 --- a/tests/workflow/test_name_limits_and_owned_fields.py +++ b/tests/workflow/test_name_limits_and_owned_fields.py @@ -214,7 +214,6 @@ def run(self, context): ("export_details", "yes", "expected bool, got 'yes'", " = 'yes'"), ("workers", 0, "integer outside [1, inf), got 0", " = 0"), ("counts", (), "give at least one count or up_to, got ()", ""), - ("counts", 3, "expected a sequence of integers, got 3", " = 3"), ("outputs", 3, "expected native OutputOptions, got 3", " = 3"), ("offer", -1, "expected a finite nonnegative amount, got -1", " = -1"), ], @@ -249,6 +248,16 @@ def test_count_query_owns_the_field_it_refuses(field, bad, reason, suffix): ) +def test_count_sequence_shape_is_refused_at_step_construction(): + fields = dict(name="bad", demand_set="d", offer=1, design_demand=1) + assert TunnelCountAnalysis(counts=(1,), **fields).counts == (1,) + with pytest.raises(TypeError) as error: + TunnelCountAnalysis(counts=3, **fields) + assert str(error.value) == ( + "workflow step bad: tunnel_counts.counts: expected a sequence of integers, got 3" + ) + + @pytest.mark.parametrize("resolution", [1, "auto"]) def test_distributed_minimum_names_the_declared_field(resolution): doc = { @@ -280,7 +289,10 @@ def test_distributed_minimum_names_the_declared_field(resolution): ) doc["workflow"][0]["model"]["routing"]["min_unreserved_bandwidth"] = 1 prepared = prepare(Scenario.from_yaml(yaml.safe_dump(doc))) - assert prepared._inputs["workflow.bad.min_unreserved"] == ("min_unreserved", 1.0) + assert prepared.scenario.workflow[0].needs().inputs["min_unreserved"] == ( + "min_unreserved", + 1.0, + ) def test_reason_resolver_has_no_call_site_field_or_dead_reason_fallback(): diff --git a/tests/workflow/test_tunnel_count_step.py b/tests/workflow/test_tunnel_count_step.py index 06b6645..eb219db 100644 --- a/tests/workflow/test_tunnel_count_step.py +++ b/tests/workflow/test_tunnel_count_step.py @@ -26,7 +26,6 @@ tunnel_counts, write_json, ) -from ngraph.analysis import counts from ngraph.workflow import TunnelCountAnalysis from ngraph.workflow.parse import build_workflow_steps from tests.controller.test_owner_figures import ebb_links @@ -80,23 +79,6 @@ def heads(volume=0, count=1): return DemandSpec(Selector("^(A|B)$"), Selector("^T$"), volume, t2(count), id="te") -def test_a_count_step_moves_q_by_its_own_splits_only(): - # The declared set (exact count 1, volume 0) binds nothing: capacities give 2^-3. - assert active(scenario(TWO_HEADS, [heads()])).topology.q == 1 / 8 - prep = prepare(scenario(TWO_HEADS, [heads()], (step(),))) - # Rows: sources split 0.001 * 100 / 1; each head's 4 tunnels 0.001 * 50 / 3 -> 2^-6. - assert prep.topology.q == 1 / 64 - labels = {c.label: c for c in prep.units_report.nominal_splits} - assert labels["workflow.c.demand[te|0].count[4].sources"].q_upper_bound == P * 100 - head = labels["workflow.c.demand[te|0].count[4].head[0]"] - assert (head.q_upper_bound, head.members, head.basis) == (P * 50 / 3, 4, "declared") - # The step's offer and design join the input atoms of the recipe. - bare = active(scenario(TWO_HEADS, [heads()])).units_report.input_count - assert prep.units_report.input_count == bare + 2 - inputs = {c.label for c in prep.units_report.binding_constraints} - assert "workflow.c.demand[te|0].count[4].head[0]" in inputs - - EBB_S2 = ebb_links("S2") @@ -113,221 +95,24 @@ def ebb_counts(**fields): return prepare(scenario(EBB_S2, [spec], (step(**{**values, **fields}),))) -def test_count_rows_bind_q_through_their_largest_split(): - spec = DemandSpec( - Selector("^DC_A$"), Selector("^DC_B$"), 2400, t2(1, headroom=True), id="te" - ) - # The declared exact count 1 has no tunnel split: link capacities give 2^-4. - assert active(scenario(EBB_S2, [spec])).topology.q == 1 / 16 - prep = ebb_counts() - # The up-to row's 256 members bind: 0.001 * 2400 / 255 -> 2^-7. - assert prep.topology.q == 1 / 128 - binding = [c for c in prep.units_report.binding_constraints if "up_to" in c.label] - head = prep.topology.node_id_by_name["DC_A"] - assert [c.label for c in binding] == [ - f"workflow.c.demand[te|0].up_to[256].head[{head}]" - ] - assert binding[0].q_upper_bound == P * 2400 / 255 - assert {c.basis for c in prep.units_report.nominal_splits if ".c." in c.label} == { - "declared" - } - # Without the up-to row, the k = 128 row binds: 0.001 * 2400 / 127 -> 2^-6. - assert ebb_counts(up_to=None).topology.q == 1 / 64 - - -def test_count_splits_solve_no_traffic_and_search_once_per_leaf(monkeypatch): - calls = {"evaluate": 0, "validations": 0, "searches": 0, "candidates": 0} - real_pairs, real_demand = ngc.evaluate_pairs, ngc.evaluate_demand - real_spf, real_splits = ngc.spf, counts.count_splits - - def pairs(*args, **kwargs): - calls["evaluate" if args[1] else "validations"] += 1 - return real_pairs(*args, **kwargs) - - def demand(*args, **kwargs): - calls["evaluate"] += 1 - return real_demand(*args, **kwargs) - - def search(*args, **kwargs): - calls["searches"] += 1 - return real_spf(*args, **kwargs) - - def splits(*args, **kwargs): - calls["candidates"] += 1 - return real_splits(*args, **kwargs) - - monkeypatch.setattr(ngc, "evaluate_pairs", pairs) - monkeypatch.setattr(ngc, "evaluate_demand", demand) - monkeypatch.setattr(ngc, "spf", search) - monkeypatch.setattr(counts, "count_splits", splits) - leaves = [heads(), DemandSpec(Selector("^A$"), Selector("^T$"), 0, t2(), id="one")] - assert active(scenario(TWO_HEADS, leaves)).topology.q == 2**-3 - assert calls == {"evaluate": 0, "validations": 2, "searches": 0, "candidates": 0} - prep = prepare(scenario(TWO_HEADS, leaves, (step(counts=(2, 4), up_to=8),))) - assert calls["evaluate"] == 0 - # The recipe tries each q from the capacities' 2^-3 down to 2^-8 (te's heads at - # up to 8: 0.001 * 50 / 7), and at each one reverse search per leaf serves all - # three of its rows: 2 leaves x 6 candidates. - assert prep.topology.q == 2**-8 - assert (calls["candidates"], calls["searches"]) == (6, 2 * 6) - # Two distinct configurations per candidate, plus the initial active-only bind. - assert calls["validations"] == 2 + 2 * 6 - - def library(prep, offer, count=4, **fields): return tunnel_counts( prep, TunnelCountQuery(prep.demand_sets["d"], (count,), offer, offer, **fields) ) -def test_a_library_question_outside_what_preparation_declared_misses(): - prep = prepare(scenario(TWO_HEADS, [heads()], (step(),))) - assert prep.topology.q == 1 / 64 - assert library(prep, 100).misses == () - - def unmet(analysis): - (miss,) = analysis.misses - assert ( - miss.location.step, - miss.location.case_id, - miss.location.probe_index, - ) == ( - None, - "baseline", - None, - ) - return sorted(c.label for c in miss.constraints) - - # O = D = 10: sources 0.001 * 10 and heads 0.001 * 5 / 3 are below q = 1/64. - assert unmet(library(prep, 10)) == [ - "tunnel_counts.demand[te|0].count[4].head[0]", - "tunnel_counts.demand[te|0].count[4].head[1]", - "tunnel_counts.demand[te|0].count[4].sources", - ] - # k = 16: heads 0.001 * 50 / 15 are below q; the sources split still meets it. - assert unmet(library(prep, 100, count=16)) == [ - "tunnel_counts.demand[te|0].count[16].head[0]", - "tunnel_counts.demand[te|0].count[16].head[1]", - ] - # A removed head only raises the other head's share: no miss. - mask = prep.topology.graph.eligibility( - excluded_nodes=[prep.topology.node_id_by_name["B"]] - ) - assert library(prep, 100, eligibility=mask).misses == () - - -def test_design_splits_bind_when_the_design_is_below_the_offer(): - prep = prepare(scenario(TWO_HEADS, [heads()], (step(design_demand=10, offer=100),))) - # Design 10 over heads A, B and 4 tunnels each: 0.001 * 5 / 3 -> 2^-10 (the - # offer's 0.001 * 50 / 3 alone gives 2^-6). - assert prep.topology.q == 2**-10 - assert sorted(c.label for c in prep.units_report.binding_constraints) == [ - "workflow.c.demand[te|0].count[4].design.head[0]", - "workflow.c.demand[te|0].count[4].design.head[1]", - ] - # Prepared for D = O = 100 (q = 2^-6), a library D = 10 misses its design splits: - # heads 0.001 * 5 / 3 and sources 0.001 * 10; the atoms 2 * 0.001 * 10 are met. - prep = prepare(scenario(TWO_HEADS, [heads()], (step(),))) - analysis = tunnel_counts( - prep, TunnelCountQuery(prep.demand_sets["d"], (4,), 10, 100) - ) - (miss,) = analysis.misses - assert sorted(c.label for c in miss.constraints if not c.met) == [ - "tunnel_counts.demand[te|0].count[4].design.head[0]", - "tunnel_counts.demand[te|0].count[4].design.head[1]", - "tunnel_counts.demand[te|0].count[4].design.sources", - ] - - -@pytest.mark.parametrize( - ("design", "offer", "atom"), - [(0.001, 1000, "design_demand"), (1000, 0.001, "offer")], -) -def test_the_smaller_amount_binds_its_own_input_atom(design, offer, atom): - counted = step(counts=(1,), design_demand=design, offer=offer) - prep = prepare(scenario([("A", "T", 100)], [heads()], (counted,))) - # One head at count 1 has no split; q <= 2 * 0.001 * 0.001 -> 2^-19. - assert prep.topology.q == 2**-19 - assert [c.label for c in prep.units_report.binding_constraints] == [ - f"workflow.c.{atom}" - ] - - -def test_a_pooled_leaf_splits_at_its_pool_head(): - pooled = DemandSpec( - Selector("^S[12]$"), Selector("^T$"), 0, FlowPolicyPreset.TE_ECMP_16_LSP, id="p" - ) - links = [("S1", "X", 100), ("S2", "X", 100), ("X", "T", 200)] - prep = prepare(scenario(links, [pooled], (step(),))) - # The pool's private head (node 4, after S1, S2, T, X) is the leaf's one head: its - # 4 tunnels split 100 at 0.001 * 100 / 3 -> 2^-5, and one head has no sources split. - assert prep.topology.q == 2**-5 - assert [c.label for c in prep.units_report.binding_constraints] == [ - "workflow.c.demand[p|0].count[4].head[4]" - ] - assert { - c.label: c.q_upper_bound - for c in prep.units_report.nominal_splits - if ".c." in c.label - } == {"workflow.c.demand[p|0].count[4].head[4]": P * 100 / 3} - - -def test_each_leaf_searches_its_own_heads(): - leaves = [ - DemandSpec(Selector("^A$"), Selector("^T$"), 0, t2(), id="one"), - DemandSpec(Selector("^(A|B)$"), Selector("^T$"), 0, t2(), id="two"), - ] - prep = prepare(scenario(TWO_HEADS, leaves, (step(),))) - # two's heads A and B: 50 each over 4 tunnels, 0.001 * 50 / 3 -> 2^-6 (one's - # single head alone: 0.001 * 100 / 3 -> 2^-5). - assert prep.topology.q == 2**-6 - assert sorted(c.label for c in prep.units_report.binding_constraints) == [ - "workflow.c.demand[two|0].count[4].head[0]", - "workflow.c.demand[two|0].count[4].head[1]", - ] - assert { - c.label: c.q_upper_bound - for c in prep.units_report.nominal_splits - if "[two|" in c.label - } == { - "workflow.c.demand[two|0].count[4].head[0]": P * 50 / 3, - "workflow.c.demand[two|0].count[4].head[1]": P * 50 / 3, - "workflow.c.demand[two|0].count[4].sources": P * 100, - } - - -def test_a_library_miss_keeps_the_query_input_atoms(): - counted = step(counts=(1,), design_demand=10, offer=10) - prep = prepare(scenario([("A", "T", 100)], [heads()], (counted,))) - # One head at count 1 has no split; design 10 is met, offer 0.01 needs - # q <= 2 * 0.001 * 0.01. - assert prep.topology.q > 2 * 0.001 * 0.01 - analysis = tunnel_counts( - prep, TunnelCountQuery(prep.demand_sets["d"], (1,), 10, 0.01) - ) - (miss,) = analysis.misses - assert [c.label for c in miss.constraints if not c.met] == ["tunnel_counts.offer"] - - def test_the_step_is_a_numerical_step_of_the_units_report(): prep = prepare(scenario(TWO_HEADS, [heads()], (step(),))) assert prep.units_report.scope.workflow_steps == ("c",) -def test_a_run_reports_its_count_step_misses_under_the_step(): - prep = prepare(scenario(TWO_HEADS, [heads()], (step(),), q=1 / 8)) - (result,) = run(prep).steps - (miss,) = result.result.misses - assert miss.location.case_id == "baseline" - report_misses = run(prep).units_report.misses - assert [m.location.step for m in report_misses] == ["c"] - - def test_the_declared_set_still_takes_part_in_choosing_q(): + # L=7, floor=15/4. Two heads give t=2*count. Count-step t=8 + # competes with the set, not a replacement for its declared tunnel count. for volume, count, bare, stepped in ( - (1, 16, 2**-15, 2**-15), - (100, 2, 2**-5, 2**-6), - (0, 16, 2**-3, 2**-6), + (1, 16, 2**-9, 2**-9), + (100, 1, 2**-6, 2**-7), + (0, 16, 2**-9, 2**-9), ): assert active(scenario(TWO_HEADS, [heads(volume, count)])).topology.q == bare prep = prepare(scenario(TWO_HEADS, [heads(volume, count)], (step(),))) From d8c8126ad55b6a4b709b4a4724b3b5fa5c78914a Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Thu, 29 Oct 2026 12:49:25 +0000 Subject: [PATCH 3/3] Release 0.26.0 The unit q is chosen once from a static bound and the per-case precision machinery is gone, which makes scenarios with many ECMP combine sources prepare and run 15 to 30 times faster; the changelog is dated today and the shipped skills move to their 0.26.0 version (networmix/skills 473c45b). Co-Authored-By: Claude Opus 5.5 --- .claude/skills | 2 +- CHANGELOG.md | 6 +++++- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/skills b/.claude/skills index 019d97e..473c45b 160000 --- a/.claude/skills +++ b/.claude/skills @@ -1 +1 @@ -Subproject commit 019d97e03d45b1547945c3f0a6fc08a80ec5d13b +Subproject commit 473c45b01a190e9fec2dbbf885caaa816e7a01af diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f61287..3721ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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] +## [0.26.0] - 2026-10-29 + +### Fixed + +- Preparation and runs no longer repeat exact-fraction precision checks for every failure case and MSD probe: scenarios with many ECMP `combine` sources prepare and run 15 to 30 times faster than in 0.25.0, and placement uses its `workers` again. ### Changed diff --git a/pyproject.toml b/pyproject.toml index 3201059..5e16bff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" # --------------------------------------------------------------------- [project] name = "ngraph" -version = "0.25.0" +version = "0.26.0" description = "A tool and a library for network modeling and analysis." readme = "README.md" authors = [{ name = "Andrey Golovanov" }]