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.
- span_contract-1.0.1.dist-info/METADATA +165 -0
- span_contract-1.0.1.dist-info/RECORD +17 -0
- span_contract-1.0.1.dist-info/WHEEL +5 -0
- span_contract-1.0.1.dist-info/entry_points.txt +2 -0
- span_contract-1.0.1.dist-info/licenses/LICENSE +21 -0
- span_contract-1.0.1.dist-info/top_level.txt +1 -0
- spancontract/__init__.py +64 -0
- spancontract/adapters/__init__.py +29 -0
- spancontract/adapters/delay_node.py +217 -0
- spancontract/cli.py +167 -0
- spancontract/compile_cache.py +103 -0
- spancontract/decisions.py +87 -0
- spancontract/envelope.py +252 -0
- spancontract/plant.py +146 -0
- spancontract/rules.py +364 -0
- spancontract/schema.py +120 -0
- spancontract/validator.py +218 -0
|
@@ -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,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
|
spancontract/__init__.py
ADDED
|
@@ -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)
|