span-contract 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: span-contract
3
+ Version: 1.0.1
4
+ Summary: An admission contract for AI training jobs that cross a data hall boundary
5
+ Author: Margaret Nanyonga
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dimaggi-ai/span-contract
8
+ Project-URL: Research, https://dimaggi-ai.github.io/research
9
+ Keywords: distributed training,admission control,data center interconnect,optical circuit switching,capacity planning
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Classifier: Topic :: System :: Distributed Computing
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest==8.3.4; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # The span contract: deciding whether an AI training job may cross a data hall
24
+
25
+ **When a job asks to run across more than one hall, who decides, and on what facts?**
26
+
27
+ Today, three systems decide, and none of them talks to the others. The scheduler
28
+ knows how many accelerators are free. The compiler knows what plan it emitted and
29
+ what topology it assumed. The circuit controller knows what glass is lit. Each
30
+ holds a different half-truth, and the job discovers which one was wrong at step
31
+ time — or worse, at checkpoint time, three hours in.
32
+
33
+ This repository is one object that makes them agree before the job starts:
34
+ twenty-one fields, six decisions, three conditions under which the contract
35
+ refuses rather than guesses, and a reference validator that turns the first into
36
+ the second and prints its reasons. It is a research artifact, not a product.
37
+
38
+ ---
39
+
40
+ ## What it is not
41
+
42
+ It is not a fabric, a scheduler, or a network model. It decides **admission** —
43
+ may this job cross — and holds no opinion on **capacity** — what the job would
44
+ retain if it did. That second question is answered by the latency-regime atlas in
45
+ [network-vs-more-gpus](https://github.com/dimaggi-ai/network-vs-more-gpus), and
46
+ this repository deliberately does not offer a second answer to it. The two are
47
+ designed to be read together: the atlas says a tensor-parallel cut retains 0.004
48
+ across any stitch at any distance, and the contract is what refuses to launch it.
49
+
50
+ ## The shape of it
51
+
52
+ ```python
53
+ from spancontract import SpanEnvelope, validate
54
+
55
+ verdict = validate(envelope)
56
+ verdict.decision # Decision.DENY
57
+ verdict.reasons() # ['[FC2] path measurement is 7200s old, past the 300s TTL']
58
+ verdict.not_checked # what the validator could not check, printed rather than omitted
59
+ ```
60
+
61
+ ```
62
+ $ spancontract validate examples/long-haul-training-too-far.json
63
+ decision: DENY
64
+ regime: region (2958 us RTT)
65
+
66
+ findings:
67
+ - [LR1] synchronous train at 2958 us RTT (region) exceeds the 2000 us limit;
68
+ the default policy is that training stays hall-local
69
+
70
+ not checked:
71
+ ? XP1 declared-vs-computed blast radius: no plant graph supplied, so the
72
+ envelope's blast_radius was taken on trust
73
+ ? XP2 topology currency: no live topology hash supplied, so the envelope's
74
+ topology_hash was taken on trust
75
+ ```
76
+
77
+ ## The three conditions that fail closed
78
+
79
+ Each describes a state in which the contract *does not know* something it needs,
80
+ and in each case not knowing is a refusal. A validator that read silence as
81
+ health would be worse than no validator, because it would be trusted.
82
+
83
+ | | Condition | Why it is a refusal and not a warning |
84
+ |---|---|---|
85
+ | **FC1** | The circuit API is dark | The state of the stitch is unknown. Unknown is not healthy. |
86
+ | **FC2** | The measured path is stale past its TTL | A declared topology is a claim. Only a fresh measurement is an observation. |
87
+ | **FC3** | The compile cache was keyed on a topology that no longer exists | The binary was built for a placement that is gone. |
88
+
89
+ ## The six decisions
90
+
91
+ `local` · `span` · `shrink` · `move` · `escalate` · `deny`, on a severity ladder.
92
+ Rules join with a maximum, so a rule can only push a verdict toward refusal and
93
+ never away from it — which is what makes the order the rules run in irrelevant to
94
+ the answer. The registry proves that by shuffling the rule list over 400 random
95
+ envelopes and requiring every verdict to be identical.
96
+
97
+ ## What the validator refuses to pretend
98
+
99
+ `make validate` prints the registry, and then prints the list of what it
100
+ **declined** to check. That second list is the more useful one. It says, among
101
+ other things, that no published figure fixes any threshold in this repository,
102
+ that no verdict here has been checked against a job that actually ran, and that
103
+ the emulated circuit models amplifier gain without the optical signal-to-noise
104
+ cost that comes with it, so it can demonstrate a refusal but cannot support a
105
+ claim of health.
106
+
107
+ There is exactly **one calibrated point** in the registry. That is the honest
108
+ count, not a gap. See [`docs/the-contract.md`](docs/the-contract.md).
109
+
110
+ ## Install and run
111
+
112
+ ```bash
113
+ git clone https://github.com/dimaggi-ai/span-contract
114
+ cd span-contract
115
+ make venv
116
+ make smoke-test # tests, mutation tests, registry, examples — under a minute
117
+ ```
118
+
119
+ Or from PyPI:
120
+
121
+ ```bash
122
+ pip install span-contract
123
+ spancontract example > envelope.json
124
+ spancontract validate envelope.json
125
+ ```
126
+
127
+ | Target | What it does |
128
+ |---|---|
129
+ | `make test` | 91 tests, including 12 mutation tests that delete machinery and require the registry to go red |
130
+ | `make validate` | the validation registry, and the nine things it declines to check |
131
+ | `make examples` | eleven example envelopes, each checked against its documented verdict |
132
+ | `make schema` | regenerate `schema/span_contract.schema.json` from the code |
133
+
134
+ ## Repository map
135
+
136
+ | Path | What is in it |
137
+ |---|---|
138
+ | `src/spancontract/envelope.py` | the twenty-one fields, inert by design |
139
+ | `src/spancontract/rules.py` | every rule and every threshold, none of them inline |
140
+ | `src/spancontract/validator.py` | the join, the plant cross-checks, the hash-chained record |
141
+ | `src/spancontract/plant.py` | halls, circuits, and what one failure actually costs |
142
+ | `src/spancontract/compile_cache.py` | keyed as specified; refuses entries from a dead topology |
143
+ | `src/spancontract/adapters/delay_node.py` | an emulated circuit, and the `tc` line that reproduces it |
144
+ | `validation/validate_contract.py` | the registry: one calibrated, seven emergent, ten sanity, nine declined |
145
+ | `tests/test_mutations.py` | delete a piece, name the points that must go red |
146
+ | `docs/the-contract.md` | the specification as implemented, including where it contradicts itself |
147
+ | `docs/integration.md` | the surface an existing policy engine would bind to |
148
+
149
+ ## The discrepancy that was carried rather than fixed
150
+
151
+ The specification lists six decisions, and then enumerates only five of them in
152
+ the `span_mode` field: `escalate` is missing. Both are implemented exactly as
153
+ written — a verdict can be `escalate`, and `span_mode` cannot — and a registry
154
+ point asserts the gap is still there, so that quietly closing it in a future
155
+ commit shows up as a failing check rather than a tidy-up. `DECISIONS.md` D3 has
156
+ the reasoning.
157
+
158
+ ## Series
159
+
160
+ Part of a program on the usable capacity of large accelerator fleets:
161
+ [dimaggi-ai.github.io/research](https://dimaggi-ai.github.io/research).
162
+
163
+ ## License
164
+
165
+ MIT. Copyright (c) 2026 Margaret Nanyonga.
@@ -0,0 +1,17 @@
1
+ span_contract-1.0.1.dist-info/licenses/LICENSE,sha256=N4CCXmxOGsKwf89eonmduSSy6i2ZX4mR7LxgmVfeal0,1074
2
+ spancontract/__init__.py,sha256=S_u-bvenYw1EJ8GC0xpUqspEtCVD7pxzTqtOxVrFzK8,2040
3
+ spancontract/cli.py,sha256=hVs0_Yu92xEQHR6LmUVxu1q3RssA-Zfx08FbzDzMavk,6191
4
+ spancontract/compile_cache.py,sha256=KHbKlbXZLACgiHr2y9eXLduNK8YvvXg_eGk-RzjPAXI,4107
5
+ spancontract/decisions.py,sha256=uFEkZi45z_1k-1CDQXpKd7Pjl3HHTndsQpLy7xFdgnw,2939
6
+ spancontract/envelope.py,sha256=ZYg6HqcA0MVKkWv039GnjzxK9JghDJLhRKIwy0kXihI,9385
7
+ spancontract/plant.py,sha256=oFRM5Do6zpD7qgKlV_Pl46qr-AQ0cb87mlmkZZR9Rc8,5674
8
+ spancontract/rules.py,sha256=8PbS3a6Qv8Hl8zXMq4k1k_C7w0DF9PU-n8BSV8fIMFU,15054
9
+ spancontract/schema.py,sha256=6K5HhxepYU0aOZs9zreFU-xUmQxosVVh6KuQgZMxPVE,5175
10
+ spancontract/validator.py,sha256=812kcT2s2GYaiulKJ3w07Hh1896XV5_eaGR9fe0NTYw,8188
11
+ spancontract/adapters/__init__.py,sha256=xQtzsVZ-TuNKwBvHVIl3kGJ9l325FGaqI9aMKP6FeCs,975
12
+ spancontract/adapters/delay_node.py,sha256=ETwHE7vd8DKn9zv4Bq1nNdbZe2pzgveNOMneCfhd_6A,9175
13
+ span_contract-1.0.1.dist-info/METADATA,sha256=MLx90R8rof_IyUk-ozn9b9-cZbU4UzbQvEeheYJnx1A,7380
14
+ span_contract-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ span_contract-1.0.1.dist-info/entry_points.txt,sha256=zKUYVWZ5I4HaFTYnknRDQSQmVN_K_sUU2b8MzqJT2Y8,55
16
+ span_contract-1.0.1.dist-info/top_level.txt,sha256=qkE8yOiiiBTBGWZDNG7tLdWtH_Exv90cshl8Yu5oc2o,13
17
+ span_contract-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spancontract = spancontract.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Margaret Nanyonga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ spancontract
@@ -0,0 +1,64 @@
1
+ """span-contract: one object a job carries when it wants to cross a hall.
2
+
3
+ A scale-across decision is made today by three systems that do not talk: a
4
+ scheduler that knows how many accelerators are free, a compiler that knows what
5
+ plan it emitted, and a circuit controller that knows what glass is lit. Each
6
+ holds a different half-truth about the topology, and the job finds out which
7
+ one was wrong at step time.
8
+
9
+ This package is the object that makes them agree before the job starts: a
10
+ twenty-one field envelope, six decisions, three conditions that fail closed,
11
+ and a reference validator that turns the first into the second and prints the
12
+ reasons. It decides admission. It does not decide capacity --- what a job
13
+ retains across a given cut is answered by the latency-regime atlas in the
14
+ ``network-vs-more-gpus`` repository, and this package deliberately holds no
15
+ second opinion on it.
16
+
17
+ >>> from spancontract import SpanEnvelope, validate
18
+ >>> verdict = validate(envelope) # doctest: +SKIP
19
+ >>> verdict.decision, verdict.reasons() # doctest: +SKIP
20
+ """
21
+
22
+ from .plant import SpanGraph, Stitch, blast_radius, load_bearing_stitches
23
+ from .compile_cache import CompileCache, compile_cache_key
24
+ from .decisions import SPAN_MODES, Decision, ScaleOut
25
+ from .envelope import (
26
+ AUTONOMY_LEVELS,
27
+ REGIME_BOUNDS,
28
+ SPEC_FIELDS,
29
+ SliceRect,
30
+ SpanEnvelope,
31
+ latency_regime,
32
+ )
33
+ from .rules import FAIL_CLOSED_RULE_IDS, RULES, Finding, Policy
34
+ from .validator import Plant, Verdict, audit_record, validate, verify_chain
35
+
36
+ __version__ = "1.0.1"
37
+
38
+ __all__ = [
39
+ "AUTONOMY_LEVELS",
40
+ "CompileCache",
41
+ "Decision",
42
+ "FAIL_CLOSED_RULE_IDS",
43
+ "Finding",
44
+ "Plant",
45
+ "Policy",
46
+ "REGIME_BOUNDS",
47
+ "RULES",
48
+ "SPAN_MODES",
49
+ "SPEC_FIELDS",
50
+ "ScaleOut",
51
+ "SliceRect",
52
+ "SpanEnvelope",
53
+ "SpanGraph",
54
+ "Stitch",
55
+ "Verdict",
56
+ "audit_record",
57
+ "blast_radius",
58
+ "compile_cache_key",
59
+ "latency_regime",
60
+ "load_bearing_stitches",
61
+ "validate",
62
+ "verify_chain",
63
+ "__version__",
64
+ ]
@@ -0,0 +1,29 @@
1
+ """Adapters between the contract and something that can answer for a circuit.
2
+
3
+ The contract needs one thing from the outside world: a measurement of the path,
4
+ with an age and an honest "I could not reach the controller". That is the whole
5
+ :class:`StitchController` surface. Everything else in this repository works off
6
+ the envelope.
7
+
8
+ Two adapters ship here. :mod:`spancontract.adapters.delay_node` is an emulated
9
+ circuit, which is what section 9's seventh sequence item asks for and what a
10
+ reader can run today. A real controller --- a lab OCS, a campus ROADM --- binds
11
+ to the same protocol; this repository does not ship one, because writing a
12
+ driver against a plant nobody has run it on would be fiction.
13
+ """
14
+
15
+ from .delay_node import (
16
+ DelayNode,
17
+ EmulatedController,
18
+ PathMeasurement,
19
+ StitchController,
20
+ apply_measurement,
21
+ )
22
+
23
+ __all__ = [
24
+ "DelayNode",
25
+ "EmulatedController",
26
+ "PathMeasurement",
27
+ "StitchController",
28
+ "apply_measurement",
29
+ ]
@@ -0,0 +1,217 @@
1
+ """An emulated stitch: a delay node standing in for a circuit.
2
+
3
+ Section 9's seventh sequence item is to prove the contract against an emulated
4
+ delay node before anyone points it at glass. This module is that node. It
5
+ answers probes the way a circuit controller would, with a round-trip time, an
6
+ insertion loss, an error rate, and --- the part that matters --- the ability to
7
+ be *dark*, so the fail-closed path is exercised by something other than a unit
8
+ test constructing the failure by hand.
9
+
10
+ What this is not: a network simulator. It does not model queueing, congestion,
11
+ or the interaction between a collective and a checkpoint on one circuit. It
12
+ returns the numbers a controller would return so that the contract's plumbing
13
+ can be exercised end to end. The capacity question --- what a job actually
14
+ retains across a given cut --- is answered by the model in the
15
+ ``network-vs-more-gpus`` repository, not here, and this module deliberately
16
+ does not offer a second opinion on it.
17
+
18
+ One limit is worth stating before anyone reads a green result as an
19
+ engineering answer. Amplification is modelled as gain and nothing else. Real
20
+ amplifiers cost optical signal-to-noise ratio, and OSNR is what actually sets
21
+ the error rate on a long span, so an amplified path here reports a comfortable
22
+ insertion loss and a bit error rate the caller made up. The emulator can show
23
+ that the contract *refuses* an unhealthy circuit; it cannot be used to argue a
24
+ particular circuit is healthy. ASSUMPTIONS.md A4 records this.
25
+
26
+ To run the same shape against real kernels rather than this object,
27
+ :func:`DelayNode.netem_command` prints the ``tc`` line that reproduces its
28
+ delay and loss on a Linux box between two halls. That is the bridge from this
29
+ file to a lab, and it is one command long.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ from dataclasses import dataclass, replace
36
+ from typing import Optional, Protocol, runtime_checkable
37
+
38
+ from ..envelope import SpanEnvelope, latency_regime
39
+
40
+ #: Speed of light in single-mode fibre, metres per second. Group index ~1.4682.
41
+ FIBRE_C_M_PER_S = 299_792_458.0 / 1.4682
42
+ #: Loss of a typical single-mode span at 1550 nm, dB per km. A planning figure,
43
+ #: not a measurement of any particular cable: real plant runs higher once
44
+ #: splices and connectors are counted, which is why the emulator lets a caller
45
+ #: add a fixed penalty rather than pretending this is the whole loss.
46
+ FIBRE_LOSS_DB_PER_KM = 0.20
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PathMeasurement:
51
+ """What a controller returns when asked about a circuit.
52
+
53
+ ``reachable=False`` is a real answer and a different thing from an
54
+ exception: the probe completed and the controller is dark. ``None`` for the
55
+ numbers in that case, because a dark controller reports nothing, and
56
+ carrying a stale number forward as if it were fresh is the failure this
57
+ whole contract exists to prevent.
58
+ """
59
+
60
+ stitch_id: str
61
+ reachable: bool
62
+ rtt_us: Optional[float] = None
63
+ insertion_loss_db: Optional[float] = None
64
+ bit_error_rate: Optional[float] = None
65
+ bandwidth_gbps: Optional[float] = None
66
+ age_s: float = 0.0
67
+
68
+ @property
69
+ def regime(self) -> Optional[str]:
70
+ return None if self.rtt_us is None else latency_regime(self.rtt_us)
71
+
72
+
73
+ @runtime_checkable
74
+ class StitchController(Protocol):
75
+ """The one thing the contract needs from a circuit controller."""
76
+
77
+ def probe(self, stitch_id: str) -> PathMeasurement:
78
+ """Return the current state of the path, or a dark answer."""
79
+ ...
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DelayNode:
84
+ """An emulated circuit between two halls.
85
+
86
+ Round-trip time is derived from the fibre length rather than set directly,
87
+ so a caller changing the distance cannot forget to change the latency --- a
88
+ mistake that makes an emulator agree with whatever it is testing.
89
+ """
90
+
91
+ stitch_id: str
92
+ distance_km: float
93
+ bandwidth_gbps: float = 800.0
94
+ #: Loss beyond the fibre itself: splices, patch panels, an OCS crossbar.
95
+ excess_loss_db: float = 2.0
96
+ #: Total amplifier gain on the path, in dB. Zero models an unamplified
97
+ #: span, which is why a long one here trips the loss budget: at 0.2 dB/km a
98
+ #: 120 km hop arrives 26 dB down, and a real one would carry amplifiers.
99
+ #: Set this to model an amplified path --- but see the class docstring for
100
+ #: what that does *not* buy you.
101
+ amplifier_gain_db: float = 0.0
102
+ bit_error_rate: float = 1e-12
103
+ #: Extra one-way latency from transponders and any regeneration, in
104
+ #: microseconds. Real metro paths are not pure glass.
105
+ equipment_latency_us: float = 10.0
106
+ #: Set to False to emulate a dark controller.
107
+ reachable: bool = True
108
+ #: How stale the controller's own last measurement is.
109
+ age_s: float = 0.0
110
+
111
+ def __post_init__(self) -> None:
112
+ if self.distance_km < 0:
113
+ raise ValueError("distance_km must be non-negative")
114
+ if self.bandwidth_gbps <= 0:
115
+ raise ValueError("bandwidth_gbps must be positive")
116
+ if not 0.0 <= self.bit_error_rate <= 1.0:
117
+ raise ValueError("bit_error_rate must be a probability in [0, 1]")
118
+ if self.amplifier_gain_db < 0:
119
+ raise ValueError("amplifier_gain_db must be non-negative")
120
+
121
+ @property
122
+ def rtt_us(self) -> float:
123
+ """Round trip: two passes through the glass, plus the equipment."""
124
+ one_way_s = (self.distance_km * 1_000.0) / FIBRE_C_M_PER_S
125
+ return 2.0 * (one_way_s * 1e6 + self.equipment_latency_us)
126
+
127
+ @property
128
+ def insertion_loss_db(self) -> float:
129
+ loss = self.distance_km * FIBRE_LOSS_DB_PER_KM + self.excess_loss_db
130
+ return max(0.0, loss - self.amplifier_gain_db)
131
+
132
+ def go_dark(self) -> "DelayNode":
133
+ """The same node with its controller unreachable."""
134
+ return replace(self, reachable=False)
135
+
136
+ def aged(self, seconds: float) -> "DelayNode":
137
+ """The same node whose last measurement is ``seconds`` old."""
138
+ return replace(self, age_s=seconds)
139
+
140
+ def measure(self) -> PathMeasurement:
141
+ if not self.reachable:
142
+ return PathMeasurement(self.stitch_id, reachable=False, age_s=self.age_s)
143
+ return PathMeasurement(
144
+ stitch_id=self.stitch_id,
145
+ reachable=True,
146
+ rtt_us=self.rtt_us,
147
+ insertion_loss_db=self.insertion_loss_db,
148
+ bit_error_rate=self.bit_error_rate,
149
+ bandwidth_gbps=self.bandwidth_gbps,
150
+ age_s=self.age_s,
151
+ )
152
+
153
+ def netem_command(self, interface: str = "eth0") -> str:
154
+ """The ``tc`` line reproducing this node's delay on a Linux box.
155
+
156
+ Emitted rather than executed. Running it needs root on a machine
157
+ between two halls, which is a decision for whoever owns that machine.
158
+ Loss is expressed as a percentage, so a bit error rate below 1e-8
159
+ rounds to zero here --- netem cannot express it, and the command says
160
+ so instead of silently dropping the term.
161
+ """
162
+ one_way_ms = self.rtt_us / 2_000.0
163
+ loss_pct = self.bit_error_rate * 100.0
164
+ parts = [
165
+ f"tc qdisc replace dev {interface} root netem",
166
+ f"delay {one_way_ms:.3f}ms",
167
+ f"rate {self.bandwidth_gbps:.0f}gbit",
168
+ ]
169
+ if loss_pct >= 1e-6:
170
+ parts.append(f"loss {loss_pct:.6f}%")
171
+ else:
172
+ parts.append(f"# loss {loss_pct:.3e}% below netem resolution, omitted")
173
+ return " ".join(parts)
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class EmulatedController:
178
+ """A :class:`StitchController` over a set of delay nodes."""
179
+
180
+ nodes: tuple
181
+
182
+ def node(self, stitch_id: str) -> DelayNode:
183
+ for n in self.nodes:
184
+ if n.stitch_id == stitch_id:
185
+ return n
186
+ raise KeyError(f"no emulated stitch {stitch_id!r}")
187
+
188
+ def probe(self, stitch_id: str) -> PathMeasurement:
189
+ return self.node(stitch_id).measure()
190
+
191
+
192
+ def apply_measurement(env: SpanEnvelope, m: PathMeasurement) -> SpanEnvelope:
193
+ """Fill an envelope's measured fields from a probe.
194
+
195
+ A dark probe leaves the declared numbers untouched and sets
196
+ ``stitch_api_reachable`` to False. It does *not* zero them or invent
197
+ pessimistic ones: the envelope should record what was declared and,
198
+ separately, that nobody could confirm it. Rule FC1 turns that into a
199
+ refusal, which keeps the reason for the refusal legible in the audit
200
+ record instead of hidden behind a substituted value.
201
+ """
202
+ if m.stitch_id != env.stitch_id:
203
+ raise ValueError(
204
+ f"measurement is for stitch {m.stitch_id!r}, envelope names {env.stitch_id!r}"
205
+ )
206
+ if not m.reachable:
207
+ return env.replace(stitch_api_reachable=False, measured_age_s=m.age_s)
208
+ changes = {"stitch_api_reachable": True, "measured_age_s": m.age_s}
209
+ if m.rtt_us is not None:
210
+ changes["span_rtt_us"] = m.rtt_us
211
+ if m.insertion_loss_db is not None:
212
+ changes["measured_il_db"] = m.insertion_loss_db
213
+ if m.bit_error_rate is not None:
214
+ changes["measured_ber"] = m.bit_error_rate
215
+ if m.bandwidth_gbps is not None:
216
+ changes["span_bw_gbps"] = m.bandwidth_gbps
217
+ return env.replace(**changes)