diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 43f33a3..3faef32 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -61,3 +61,4 @@ jobs:
results/ci*/**/*.svg
results/ci*/**/*.md
results/ci*/**/*.txt
+ !**/input.bin
diff --git a/.gitignore b/.gitignore
index 2dc63fa..4bebab5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,8 @@
/server
/src/client
/src/server
-/src/*.o
+*.o
+*.bin
# Python environments and caches
.venv/
@@ -14,12 +15,11 @@ __pycache__/
.coverage
htmlcov/
-# Local experiment outputs; retain the two verified bundles and history archive
+# Local experiment outputs; retain the two verified bundles
/results/*
!/results/README.md
!/results/rubric-final/
!/results/rubric-final-native/
-!/results/archive/
*.part.??????
# OS/editor scratch files
diff --git a/Makefile b/Makefile
index b052bd0..c6a765c 100644
--- a/Makefile
+++ b/Makefile
@@ -16,3 +16,4 @@ test: all
python3 tests/test_controller.py
python3 tests/test_metrics.py
python3 tests/test_local.py
+ python3 tests/test_visualize.py
diff --git a/README.md b/README.md
index acdecb3..2a19f4c 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,28 @@
# Reliable UDP file transfer with SDN failover
+## Live telemetry console
+
+Run the console from the repository root. It builds the C binaries and opens the browser.
+
+```sh
+python3 scripts/run.py viz
+```
+
+Choose transport flags and click **Run Live C Transfer**. The console captures actual
+24-byte headers through a UDP relay, streams sender/receiver JSONL records, shows
+the sender ring snapshots, and reports independent SHA-256 verification. The relay
+adds 15 ms in each direction to make the transitions visible. **Drop next DATA**
+discards one observed DATA datagram.
+
+Load a trial from the selector or enter a directory under results/ and select
+**Tail active trial** for a running Mininet experiment. Replay preserves recorded
+monotonic timing with play, pause, single-step and speed controls. Older recordings
+supply only the fields they retained. The localhost run supplies transport data,
+while Mininet recordings supply controller events.
+
+
Transfer a binary file over UDP while an SDN controller routes around a failed
-or degraded network path. The C application repairs missing packets; the OS-Ken
-controller restores reachability through a four-switch Mininet diamond.
+or degraded network path. The C11 transport implements Selective Repeat, SACK, adaptive RTO and congestion control. The Python OpenFlow 1.3 controller schedules adaptive probes, reroutes around failures and supports weighted multipath DATA.
## Try a file transfer on localhost
@@ -72,8 +92,8 @@ docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab python
| `mininet/` | Diamond topology and netem impairments |
| `scripts/` | Setup, demo, evaluation, local benchmark and plots |
| `tests/` | Protocol, transport, controller and integration tests |
-| `results/` | Final Linux/local evidence and labelled historical archive |
-| `docs/` | [Architecture](docs/ARCHITECTURE.md), [code map](docs/CODE_MAP.md), [design decisions](docs/DESIGN_DECISIONS.md), [validation](docs/VALIDATION.md), [report](docs/REPORT.md), [viva](docs/VIVA.md), [rubric](docs/RUBRIC_MAPPING.md) |
+| `results/` | Final Linux/local evidence |
+| `docs/` | [Architecture](docs/ARCHITECTURE.md), [Report](docs/REPORT.md), [Demo](docs/DEMO.md), [Viva](docs/VIVA.md) |
This is a topology-specific lab implementation, not a production transport.
It provides no authentication, encryption, persistent resume or TCP fairness.
@@ -85,4 +105,4 @@ It provides no authentication, encryption, persistent resume or TCP fairness.
The retained Linux trial shows the sequence of controller and application events.
Across the measured evaluation, 132 transfers matched their hashes and 48 static
-failures were expected. [Measurements and scope](docs/KEY_RESULTS.md).
+failures were expected. [Measurements and scope](docs/REPORT.md).
diff --git a/controller/failover.py b/controller/failover.py
index 16b53d9..b7136d0 100644
--- a/controller/failover.py
+++ b/controller/failover.py
@@ -162,6 +162,9 @@ def _monitor(self):
)
self.next_probe_ns[key] = now + interval_ns
+ self._event("probe_schedule", path=path, direction=direction,
+ interval_seconds=interval_ns / 1_000_000_000)
+
# Wake at the next probe deadline or pending-probe timeout.
# Cap sleep at 250 ms so health and controller state are refreshed.
@@ -454,6 +457,11 @@ def _reconcile(self, cause, force=False):
)
rules = forwarding_rules(path)
+ self._event("flow_rules", path=path,
+ rules={str(dpid): [dict(in_port=r.in_port, eth_type=r.eth_type,
+ out_port=r.out_port) for r in entries]
+ for dpid, entries in rules.items()})
+
# Install the selected route and remove stale multipath rules.
for dpid, dp in sorted(self.datapaths.items()):
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 87c8420..531f049 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -150,7 +150,7 @@ particular ACK. Final output SHA-256 establishes integrity independently.
No route or no later useful ACK is censored/N/A, never zero. Repeated-fault
attribution ends at the next intervention; a failure on the inactive branch is
labeled separately. The joined SVG and recovery JSON preserve all three timestamps.
-See PROGRESS_LOG.md, DEMO.md and scripts/evaluate.py.
+See [Demo](DEMO.md) for commands.
## Optional multipath DATA and adaptive probes
@@ -169,5 +169,56 @@ Probe deadlines are independent per branch/direction. Successful healthy probes
increase spacing up to 1.5 seconds; loss halves it to a 100 ms minimum. Degraded
paths are capped at 250 ms. The tracker still expires outstanding probes at
500 ms; the 1.5 s spacing leaves that deadline inside the 2 s health freshness.
-These changes need fresh Linux measurements; historical fixed-interval
-results do not measure their overhead or recovery behavior.
+The [report](REPORT.md) separates targeted Linux measurements of adaptive probes
+from the repeated matrix collected with fixed probe spacing.
+
+## Implementation map
+
+| Location | Entry point | Responsibility |
+| --- | --- | --- |
+| `src/packet.h` | constants, packet types | 24-byte wire header, 1024-byte chunks, 32-slot bound |
+| `src/client.c` | `main` → `exchange` → `send_file` | Metadata/negotiation, reliable DATA, explicit FIN |
+| `src/client.c` | `send_file` send loop | Sequence span and CWND jointly bound outstanding sends |
+| `src/client.c` | `send_file` ACK block | Validate SACK before state changes; mark coverage and collect clean RTT samples |
+| `src/client.c` | `gap_reports` / `fast_sent` | Three distinct later ACKs trigger one fast retry; timeout remains the fallback |
+| `src/server.c` | `data_ack`, `main` | Buffer reordered DATA, drain the contiguous prefix, send ACK/bitmap |
+| `src/server.c` | FIN handling | Verify size/CRC, close and rename temporary output; re-ACK duplicate FIN |
+| `src/utils.c` | `packet_encode`, `packet_decode` | Explicit network-order serialization and CRC/length validation |
+| `src/utils.c` | `receive_packet`, `send_packet` | UDP socket operations and bounded receive wait |
+| `controller/routing.py` | `PathPolicy.choose` | Exclude hard failures; prefer materially better fresh health |
+| `controller/probes.py` | `ProbeTracker` | Validate independent branch probes, expire losses, smooth directional samples |
+| `controller/failover.py` | `_monitor`, `port_status`, `_reconcile`, `barrier_reply` | Probe scheduling, hard-failure events, rule installation and readiness |
+| `scripts/commands/run_experiments.py` | `run_trial`, `recovery_metrics` | Inject faults, verify endpoint/hash outcomes and join recovery events |
+| `scripts/commands/evaluate.py` | `linux_plan`, `main` | Run tests and repeated matrix; save manifest and plots |
+
+
+## Design trade-offs
+
+| Choice | Reason | Accepted cost / limit |
+| --- | --- | --- |
+| C/POSIX UDP and one event loop | Socket mechanics, ACKs and retries are explainable | Single upload per receiver; no shared service |
+| Selective Repeat, 32 slots | Buffer later packets; retry only missing coverage; bounded memory | Head holes limit sequence span; high bandwidth-delay paths can underfill |
+| 32-bit bitmap plus cumulative base | Repeated receiver state repairs lost ACK information in eight payload bytes | One bitmap revision, explicit opt-in START types, no automatic downgrade |
+| Adaptive RTO + Karn + backoff | Avoid repeated premature retries on slow paths; reject ambiguous RTTs | Initial 200 ms may still be too short; 50–2000 ms lab bounds |
+| Three distinct later ACKs | Fast gap recovery without a new DATA format | Reordering may trigger a needless retry; no full TCP recovery |
+| Capped slow start / additive increase | Basic sender response to loss | Short uncontended flows get slower; fairness is unproven |
+| Same monotonic clock | Join application behavior to link-down and rule readiness | Only comparable on the same kernel |
+| Bidirectional branch probes | Measure the idle backup and soft degradation without changing UDP | Probe loss is noisy; control-channel latency contributes; small extra traffic |
+| EWMA, freshness, score threshold, hold-down | Avoid routing on stale/small transient differences | Soft detection deliberately slower than hard failure |
+| Hard failures override health | A live but unhealthy route is preferable to a broken one | If neither branch works, retries cannot manufacture connectivity |
+| Independent SHA-256 and atomic rename | Strong experimental correctness evidence; failed transfer preserves destination | CRC-32 itself is not authentication; rename is not power-loss durability |
+| CC off in the main matrix | Isolate routing, RTO and ACK format effects | CWND behavior is verified and separately ablated, not a fairness claim |
+
+## Telemetry console
+
+The viz command serves a loopback-only HTTP interface using the Python standard
+library. A UDP relay captures actual serialized headers and adds a fixed 15 ms
+delay per direction. An opt-in RUDP_VIZ_RING trace records the sender's real
+window slots and gap counters. Normal protocol execution keeps that trace disabled.
+Independent SHA-256 compares source and finalized output after both endpoints exit.
+
+The SSE journal lives in a temporary file. Browser reconnects resume using event
+IDs, while slow readers can catch up independently of UDP. A session owns its
+subprocesses and cleans up on stop. Recorded trials merge sender, receiver and
+controller JSONL events by monotonic timestamp. Header captures and ring snapshots
+require instrumentation present at recording time.
diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md
deleted file mode 100644
index e84c617..0000000
--- a/docs/CODE_MAP.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Code map for review and viva
-
-Read ARCHITECTURE.md for state/packet details and DESIGN_DECISIONS.md for why.
-
-| Location | Entry point | Responsibility |
-| --- | --- | --- |
-| `src/packet.h` | constants, packet types | 24-byte wire header, 1024-byte chunks, 32-slot bound |
-| `src/client.c` | `main` → `exchange` → `send_file` | Metadata/negotiation, reliable DATA, explicit FIN |
-| `src/client.c` | `send_file` send loop | Sequence span and CWND jointly bound outstanding sends |
-| `src/client.c` | `send_file` ACK block | Validate SACK before state changes; mark coverage and collect clean RTT samples |
-| `src/client.c` | `gap_reports` / `fast_sent` | Three distinct later ACKs trigger one fast retry; timeout remains the fallback |
-| `src/server.c` | `data_ack`, `main` | Buffer reordered DATA, drain the contiguous prefix, send ACK/bitmap |
-| `src/server.c` | FIN handling | Verify size/CRC, close and rename temporary output; re-ACK duplicate FIN |
-| `src/utils.c` | `packet_encode`, `packet_decode` | Explicit network-order serialization and CRC/length validation |
-| `src/utils.c` | `receive_packet`, `send_packet` | UDP socket operations and bounded receive wait |
-| `controller/routing.py` | `PathPolicy.choose` | Exclude hard failures; prefer materially better fresh health |
-| `controller/probes.py` | `ProbeTracker` | Validate independent branch probes, expire losses, smooth directional samples |
-| `controller/failover.py` | `_monitor`, `port_status`, `_reconcile`, `barrier_reply` | Probe scheduling, hard-failure events, rule installation and readiness |
-| `scripts/commands/run_experiments.py` | `run_trial`, `recovery_metrics` | Inject faults, verify endpoint/hash outcomes and join recovery events |
-| `scripts/evaluate.py` | `linux_plan`, `main` | Run tests and repeated matrix; save manifest and plots |
-
-## Follow one transfer
-
-1. START advertises file length and CRC and negotiates ACK format.
-2. Sender fills available ring slots. A slot is retained until ACKed coverage lets
- the contiguous base move past it; modulo indexing reuses bounded memory.
-3. Receiver accepts only the active peer/session, buffers in-window chunks and
- writes the contiguous prefix once. Duplicate DATA does not rewrite bytes.
-4. Missing DATA is retried on timeout or distinct later-ACK gap evidence. Karn's
- rule excludes ambiguous retransmitted RTT samples. Optional CWND reacts to loss.
-5. Independently, SDN excludes a failed path or changes path on probe health.
- Rule barriers mark route readiness; later contiguous ACK advancement marks
- application recovery. Both events are required for the measured recovery.
-6. FIN completes only after size/CRC validation and successful output finalization.
- The harness compares independent SHA-256 digests before deleting payloads.
-
-## Script entry points
-
-Use `python3 scripts/run.py {demo,evaluate,benchmark,experiment,plot,timeline} --help`.
-Implementations live in `scripts/commands/` and shell helpers in `scripts/docker/`.
-Existing demo.py, evaluate.py and setup_env.sh calls remain supported.
-
-- `setup_env.sh`: native Ubuntu 22.04 packages, Python environment and OVS setup.
-- `container_entrypoint.sh`: private container OVS database/daemon startup.
-- `demo.py`: one short scenario; `--help` lists static, reverse and soft variants.
-- `evaluate.py`: at least three repeats; `--local-only` and `--plan-only` remain available.
-- `benchmark_reliability.py`: localhost impairment proxy and transport/CC ablations.
-- `run_experiments.py`: explicit Linux scenario/transport matrix controls.
-- `plot_metrics.py`, `plot_timeline.py`: summaries and joined event plots.
-- `start_controller.sh`, `start_topology.sh`: manual lab operation.
-
-For each public script use `--help` where supported. Do not edit retained evidence
-or reuse a nonempty output directory. Runtime traces are observability required
-for verification, not leftover debug output.
diff --git a/docs/DEMO.md b/docs/DEMO.md
index 913596b..5fd9f79 100644
--- a/docs/DEMO.md
+++ b/docs/DEMO.md
@@ -1,96 +1,66 @@
# Live demonstration
-The validated route is Ubuntu 22.04 Docker with userspace OVS (Docker command below).
-Native setup targets Ubuntu 22.04; newer Python versions may not support the pinned
-Eventlet dependency. Use a prepared **Linux** host with Mininet, OVS and OS-Ken. From the repository:
+## Live telemetry console
+
+Run the console from the repository root. It builds the C binaries and opens the browser.
```sh
-sudo sh scripts/setup_env.sh # setup once; needs package access
-make test # preflight before the presentation
-sudo .venv/bin/python scripts/run.py demo --mode failover --output results/live-failover
+python3 scripts/run.py viz
```
-The command owns topology/controller/endpoint startup and cleanup. It:
-1. Starts OS-Ken in failover mode, connects the diamond, waits for route_ready
- and verifies host connectivity.
-2. Starts an 8 MiB C transfer and waits for actual contiguous ACK progress.
-3. After 0.5 s of progress, takes the active branch's s1 link down permanently.
-4. Prints LINK DOWN, ROUTE READY on the alternate, completion, recovery latency
- and `sha256_match=True`. No application restart and no primary-link restoration.
-
-Say: “The controller restores reachability; Selective Repeat recovers missing
-chunks. These are separate events within the same upload.”
+Choose transport flags and click **Run Live C Transfer**. The console captures actual
+24-byte headers through a UDP relay, streams sender/receiver JSONL records, shows
+the sender ring snapshots, and reports independent SHA-256 verification. The relay
+adds 15 ms in each direction to make the transitions visible. **Drop next DATA**
+discards one observed DATA datagram.
-Open the trial's `plots/timeline.svg`: red link_down, green route_ready, purple
-first useful post-route ACK. Open root `metrics.csv` for source/output SHA-256,
-recovery measurements and success. The console prints the result directory;
-each trial retains `controller.jsonl`, `progress-0.jsonl`, `receiver-0.jsonl`,
-endpoint logs, `outages-0.json`, and initial/final flow and queue dumps.
-`success=True`, `sha256_match=True`, and `expectation_met=True` are all required.
+Load a trial from the selector or enter a directory under results/ and select
+**Tail active trial** for a running Mininet experiment. Replay preserves recorded
+monotonic timing with play, pause, single-step and speed controls. Older recordings
+supply only the fields they retained. The localhost run supplies transport data,
+while Mininet recordings supply controller events.
-## Static contrast and robustness (ready, not all needed live)
-```sh
-sudo .venv/bin/python scripts/run.py demo --mode static --output results/live-static
-sudo .venv/bin/python scripts/run.py demo --initial-path lower --output results/live-lower
-sudo .venv/bin/python scripts/run.py demo --scenario robust --output results/live-robust
-sudo .venv/bin/python scripts/run.py demo --scenario soft-degradation --mib 16 --output results/live-soft
-```
+Run the localhost examples from the repository root in a Linux shell. Each receiver exits after one upload.
-Static contrast keeps the broken branch: no alternate route_ready, client fails,
-receiver times out, no finalized output. `expectation_met=True` means the expected
-failure was observed, not that the transfer succeeded. Adaptive retry exhaustion
-can take about a minute; have this run recorded before presenting if time is tight.
-Lower-initial proves lower→upper recovery. Robust combines loss, host reordering
-and repeated short primary flaps. Soft degradation adds loss without taking a
-link down; probe scores must cause a health_update route change.
-
-Use fresh output directories. Keep the input large enough for injection; a trial
-that finishes too early is rejected. If failure timing or resources invalidate a
-run, show the error and rerun; never call an uninjected run successful failover.
-
-Docker alternative after starting Docker Desktop/Engine:
+## Transfer under loss
```sh
-docker build -t rudp-lab .
-docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab python scripts/run.py demo --mode failover --datapath user --output results/live-failover
+make
+dd if=/dev/urandom of=sample.bin bs=1024 count=256
+./server 9000 out.bin & receiver=$!
+sleep 0.2
+./client --host 127.0.0.1 --port 9000 --file sample.bin --sack --adaptive-rto --cc --drop-rate 0.10 && wait "$receiver" && cmp sample.bin out.bin
+sha256sum sample.bin out.bin
```
-## Retained evaluation
+Show the matching digests and the sender’s retry count. Simulated loss applies to outgoing DATA attempts.
-The completed three-repeat Linux Docker/userspace OVS evaluation met 180/180
-expectations. See [Key Results](KEY_RESULTS.md) and [validation](VALIDATION.md).
-Do not run the full matrix during a ten-minute presentation.
-
-For a new full run use a fresh output directory:
+## Two simultaneous uploads
```sh
-docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab python scripts/run.py evaluate --output "results/evaluation-$(date +%Y%m%d-%H%M%S)"
+./server 9000 out0.bin & r0=$!
+./server 9001 out1.bin & r1=$!
+sleep 0.2
+./client 127.0.0.1 9000 sample.bin & c0=$!
+./client 127.0.0.1 9001 sample.bin & c1=$!
+wait "$c0" && wait "$c1" && wait "$r0" && wait "$r1" && cmp sample.bin out0.bin && cmp sample.bin out1.bin
+sha256sum sample.bin out0.bin out1.bin
```
-## Ten-minute assessment sequence
-
-1. Explain the two independent responsibilities: UDP reliability and SDN routing.
-2. Show the diamond in ARCHITECTURE.md, then run one failover demo.
-3. Point out initial route, injected link-down, alternate route readiness,
- hash-matched completion, and the joined timeline.
-4. Show KEY_RESULTS.md for static contrast and repeated measurements.
-5. Use CODE_MAP.md to locate the requested implementation; VIVA.md gives the
- design rationale. Keep a retained final timeline available if live setup fails.
+Each process owns one port and one destination file.
-The console's `recovery=` is injection-to-progress, not link-down-to-progress.
-The CSV and joined events distinguish both. Never label a startup failure or
-uninjected transfer as successful failover.
+## OpenFlow 1.3 failover and static contrast
-## Two simultaneous uploads
-
-Each flow uses a separate receiver process and UDP port on h2; the receiver is
-one upload per process, rather than a persistent multi-session service. Both
-senders run on h1. This demonstrates concurrent uploads, not separate sender hosts.
+Build the image once. Use fresh output directories for each rehearsal.
```sh
-sudo .venv/bin/python scripts/run.py experiment --scenarios link-failure --modes failover --rtos adaptive --sacks bitmap --ccs off --flows 2 --repeats 1 --output results/live-concurrent
+docker build -t rudp-lab .
+run_dir="results/demo-$(date +%Y%m%d-%H%M%S)"
+for mode in failover static; do
+ docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab python3 scripts/run.py demo --mode "$mode" --output "$run_dir/$mode" || break
+done
+find "$run_dir" -name timeline.svg
```
-Check both flow rows for success, matching hashes and recovery. Rehearse this
-before assessment; use retained output during the ten-minute presentation.
+Open the failover trial’s timeline.svg in a browser. Point to link down, alternate route ready and the first ACK that advances the contiguous file prefix. The output hash must match. In static mode the route stays on the failed branch and the transfer fails as expected. Prepare the static run before the presentation to avoid waiting for retry exhaustion.
diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md
deleted file mode 100644
index 065a277..0000000
--- a/docs/DESIGN_DECISIONS.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# Design decisions
-
-| Choice | Benefit for the assessed project | Accepted cost / limit |
-| --- | --- | --- |
-| C/POSIX UDP and one event loop | Socket mechanics, ACKs and retries are explainable | Single upload per receiver; no shared service |
-| Selective Repeat, 32 slots | Buffer later packets; retry only missing coverage; bounded memory | Head holes limit sequence span; high bandwidth-delay paths can underfill |
-| 32-bit bitmap plus cumulative base | Repeated receiver state repairs lost ACK information in eight payload bytes | One bitmap revision, explicit opt-in START types, no automatic downgrade |
-| Adaptive RTO + Karn + backoff | Avoid repeated premature retries on slow paths; reject ambiguous RTTs | Initial 200 ms may still be too short; 50–2000 ms lab bounds |
-| Three distinct later ACKs | Fast gap recovery without a new DATA format | Reordering may trigger a needless retry; no full TCP recovery |
-| Capped slow start / additive increase | Basic sender response to loss | Short uncontended flows get slower; fairness is unproven |
-| Same monotonic clock | Join application behavior to link-down and rule readiness | Only comparable on the same kernel |
-| Bidirectional branch probes | Measure the idle backup and soft degradation without changing UDP | Probe loss is noisy; control-channel latency contributes; small extra traffic |
-| EWMA, freshness, score threshold, hold-down | Avoid routing on stale/small transient differences | Soft detection deliberately slower than hard failure |
-| Hard failures override health | A live but unhealthy route is preferable to a broken one | If neither branch works, retries cannot manufacture connectivity |
-| Independent SHA-256 and atomic rename | Strong experimental correctness evidence; failed transfer preserves destination | CRC-32 itself is not authentication; rename is not power-loss durability |
-| CC off in the main matrix | Isolate routing, RTO and ACK format effects | CWND behavior is verified and separately ablated, not a fairness claim |
-
-No full TCP clone, encryption/authentication, FTP compatibility, persistent resume,
-web frontend or unrelated feature was added. TCP is the right production default;
-this lab exposes reliable datagram state and its interaction with SDN forwarding.
-The fixed timer, plain ACKs and static route are meaningful controlled baselines,
-not deliberately broken implementations.
-
-Wire and estimator details: ARCHITECTURE.md. Implementation navigation: CODE_MAP.md. Claims and their artifacts: REPORT.md and VALIDATION.md.
-
-Probe timing: a 250 ms send interval tracks both branches with about 16 frames/s;
-a 500 ms deadline tolerates delayed replies. Four fresh samples reduce premature
-health decisions; the 2 s hold-down limits soft-route oscillation. These are lab
-tradeoffs, not universally optimal settings. Recovery requires contiguous ACK
-advancement after route barriers so duplicate ACKs do not count as progress.
diff --git a/docs/KEY_RESULTS.md b/docs/KEY_RESULTS.md
deleted file mode 100644
index 99fb266..0000000
--- a/docs/KEY_RESULTS.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Key Results — 60-second assessment view
-
-## Final transport: measured on macOS, three repeats per configuration
-
-**48 automated tests passed. All 60/60 final localhost transfers completed with
-matching recorded input/output SHA-256.** Each transfer is 64 KiB; the proxy delays
-DATA/ACK directions equally and uses deterministic faults, not a bottleneck queue.
-
-| Controlled comparison (CC off) | Before retries | After retries | Result |
-| --- | ---: | ---: | --- |
-| 260 ms nominal RTT, no loss: fixed → adaptive RTO, plain ACK | 64 | 32 | 50% fewer unnecessary retries |
-| 10 ms nominal RTT, DATA 10 and ACKs 5–7 initially lost: plain → bitmap, fixed RTO | 4 | 1 | 75% fewer retries |
-| 260 ms nominal RTT, same faults: fixed/plain → adaptive/bitmap | 72 | 33 | 54.2% fewer retries |
-
-These counts were identical across all three repeats in each listed cell.
-Goodput changed little: the high-delay fault case was 0.7093 versus 0.7090 Mb/s.
-The gain here is lower extra traffic, not a claimed speedup. Fast gap recovery is
-held on in both ACK modes. The first window still pays for an initial RTO that is
-too short. At 10 ms RTT without faults, enabling CWND reduced mean goodput from
-15.144 to 6.752 Mb/s on this short uncontended transfer; no fairness benefit is
-claimed from this dataset.
-
-Evidence: [test log](../results/rubric-final-native/tests.log),
-[execution manifest](../results/rubric-final-native/evaluation.json),
-[raw measurements and hashes](../results/rubric-final-native/transport/metrics.csv),
-[full cell summaries](../results/rubric-final-native/transport/plots/summary.md).
-
-Plots: [retry counts](../results/rubric-final-native/transport/plots/retransmits.svg),
-[retry fraction](../results/rubric-final-native/transport/plots/retransmit_fraction.svg),
-[goodput](../results/rubric-final-native/transport/plots/goodput_mbps.svg),
-[adaptive RTO/CWND trace](../results/rubric-final-native/transport/trace/timeline.svg).
-
-## Final Linux Docker evaluation: completed, three repeats
-
-[Manifest](../results/rubric-final/evaluation.json) and
-[test log](../results/rubric-final/tests.log): **48 tests passed; 180/180 trial
-expectations met, 132 successful transfers with matching recorded input/output
-SHA-256, 48 expected permanent-failure static-routing failures.**
-
-- Matrix: 168 trials; 24 normal and 48 loss successes, 48 upper→lower failover
- successes, 48 expected static failures.
-- Lower→upper: 3/3 successful; robustness: 6/6 under 2% loss, 20% reordering
- and 0.5/2 s flaps; soft degradation: 3/3 successful with probe health-driven
- upper→lower changes and no link-down event.
-- Across 57 recovered trial rows, ordered link_down→route_ready→first contiguous
- ACK advancement is confirmed. Link-down→ACK: median **85.554 ms**, mean
- **116.743 ms**, range **12.276–401.377 ms**. Injection→ACK is distinct:
- median 88.493 ms, mean 119.945 ms, range 14.076–405.743 ms.
-- Keep 48 censored static recovery rows and 75 not-applicable rows as null.
- Per-intervention records separately retain 58 recovered, 48 censored and
- 218 inactive-path outages; these are not the trial-level latency sample.
-
-Environment: Ubuntu 26.04.1 ARM64 VM host; Ubuntu 22.04 Docker image,
-Python 3.10.12, OVS 2.17.12, **userspace datapath**. Kernel datapath validation
-is not claimed. Payloads were deleted by the runner; SHA-256 equality is verified
-from retained digest records and endpoint completion, not a fresh payload rehash.
-
-[Matrix](../results/rubric-final/matrix/metrics.csv),
-[lower recovery](../results/rubric-final/lower-recovery/metrics.csv),
-[robustness](../results/rubric-final/robustness/metrics.csv), and
-[full verification and environment limits](VALIDATION.md#final-linux-docker-validation--verified-31-october-2026).
diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md
deleted file mode 100644
index b7a1cfa..0000000
--- a/docs/PROGRESS_LOG.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Progress and protocol event logs
-
-Each endpoint can set `RUDP_PROGRESS_LOG` to its own fresh path. Creation is
-exclusive; records flush immediately. Logging failures fail the process. Do not
-reuse a path across processes. Monotonic nanoseconds are comparable across Linux
-Mininet namespaces on the same kernel, not across physical machines.
-
-`ack_progress` retains `base`, `bytes`, `monotonic_ns` and includes `transfer_id` for joining.
-Other records contain `event`, `monotonic_ns`, hex `transfer_id`, `sequence`,
-`base`, numeric `value`, and `reason`:
-
-| Event | Value / interpretation |
-| --- | --- |
-| send | Slot RTO in ms; original DATA |
-| retransmit | Slot timeout in ms; reason timeout or fast_gap |
-| ack_received | Bitmap numeric value, or zero for individual ACK |
-| window_advance | Current cwnd; contiguous prefix advanced |
-| rto_update | Global RTO ms; rtt_sample or timeout_backoff |
-| srtt_update | Smoothed RTT ms |
-| cwnd_change | Congestion window in packets; initial/new_ack/timeout/fast_gap |
-| loss_detected | Expired timer ms or distinct later-ACK count |
-| out_of_order / gap_detected | Receiver arrival above missing base |
-| hole_filled | Receiver base arrival unlocks buffered suffix |
-
-`ack_received` logs valid ACK arrivals, including duplicates; only new coverage
-trains congestion growth and gap evidence. Receiver logs belong to its own file.
-The sender's gap reasoning can therefore be checked against actual receiver state.
-
-Join with controller link_down, path_health, route_installing and route_ready
-records using `scripts/commands/plot_timeline.py`. Red dashed markers are link-down;
-green dashed markers are barrier-confirmed readiness. A later ACK is progress
-observed after switching, not proof of which physical path carried that ACK.
-Use final hash verification and retain raw logs alongside plots.
-
-Controller `probe_result` records path, direction, success, elapsed delay_ms and
-monotonic_ns. `path_health` includes fresh branch scores and directional sample
-counts; scores combine EWMA loss and latency and are not pure loss rates.
-`route_decision` records previous/selected path, cause and scores; suppressed
-soft moves report hold_down_or_barrier. `route_installing` and `route_ready`
-carry generation and path. Invalid sender reports emit ack_rejected/invalid_sack.
-
-Recovery JSON includes failure, observed-link-down, route-ready and useful-ACK
-nanoseconds plus the component intervals and recovered/censored/inactive status.
-The primary reported latency is observed link_down to useful ACK; injection-to-ACK
-is a separate metric. Strictly later prefix progress is required after route_ready.
diff --git a/docs/Project_Guide_and_Checklist.pdf b/docs/Project_Guide_and_Checklist.pdf
deleted file mode 100644
index d3e7f24..0000000
Binary files a/docs/Project_Guide_and_Checklist.pdf and /dev/null differ
diff --git a/docs/REPORT.md b/docs/REPORT.md
index 77906d5..874d803 100644
--- a/docs/REPORT.md
+++ b/docs/REPORT.md
@@ -1,44 +1,10 @@
-# Reliable UDP transfer with SDN recovery — final report
+# Experimental results
-## Objective and assessed behavior
+## Merged multipath and adaptive probe implementation
-Transfer binary files correctly through loss, corruption, duplication and
-reordering, while SDN restores a broken path during the same upload. The C
-application provides Selective Repeat reliability; OS-Ken controls the Mininet
-diamond. Correctness requires explicit completion, size/CRC agreement and final
-independent SHA-256 equality. Silence is failure, never successful completion.
+The implementation merged in PR #15 passed 67 tests on Linux ARM64. Four targeted Mininet transfers with multipath enabled finished with matching SHA-256 hashes. Hard failure recovered in 15.301 ms from upper to lower and 42.178 ms from lower to upper, measured from link down to the first useful ACK after route readiness. Soft degradation switched paths. In the healthy trial the ingress switch counted 3,636 upper and 3,633 lower marked packets. These are single trials. The following repeated experiments measured the earlier controller.
-## Implementation
-
-The final system has a version-1 24-byte header and an explicitly negotiated
-bitmap ACK extension. Adaptive DATA RTO uses SRTT, variation, Karn sampling and
-bounded backoff; distinct later ACK coverage supports fast retransmit. A capped
-congestion window is independent of the 32-slot receiver window. Legacy ACK and
-fixed-RTO modes remain selectable. Wire validation precedes state mutation.
-
-Static routing keeps the initial branch. Failover routing immediately excludes
-hard failures. Bidirectional Ethernet probes independently exercise both live
-branches, including an idle backup. Fresh EWMA loss/elapsed-latency scores,
-hysteresis and a route hold-down govern soft moves; hard failure bypasses the
-hold-down. Barrier-confirmed route_ready and continued ACK progress remain
-separate observations. Details and limits are in ARCHITECTURE.md.
-
-## Method and metrics
-
-The final Linux evaluation command is:
-
-```sh
-sudo .venv/bin/python scripts/evaluate.py --output results/rubric-final
-```
-
-It runs tests first, then at least three independent repetitions of routing ×
-RTO × ACK format with 0/2% configured per-egress loss and failure times 0.5/1.5 s
-after observed ACK progress. It additionally tests lower→upper recovery,
-loss/reordering/short primary flaps and soft loss without link-down. CC is held off
-in the main comparison to avoid confounding those axes. Source fingerprints,
-environment, raw events, flow/queue dumps, all trial outcomes and hashes are saved.
-Linux netem random draws and virtualization scheduling vary; the input generator
-is seeded, which does not make the network stochastic process deterministic.
+## Measurement definitions
| Metric | Exact definition |
| --- | --- |
@@ -51,46 +17,61 @@ is seeded, which does not make the network stochastic process deterministic.
| Route installation | Failure-command start → barrier-confirmed alternate route_ready |
| Censored recovery | No qualifying route or later useful progress before observation ends/next intervention; reported N/A |
-Reports retain failed rows and distinguish expected static failure from successful
-transfer. Means/min–max use successful flows; numeric sample counts are explicit.
-Multiple concurrent flows share one network trial and are not independent repeats.
-Barrier completion is not globally atomic switching or proof of an ACK's physical
-route. The final hash is independent evidence of application delivery.
-
-## Executed findings and evidence
-
-The final local evaluation passed 48 automated tests and 60/60 independently
-hash-verified transfers. Adaptive RTO halved retries (64→32) at 260 ms nominal RTT
-without loss. Bitmap feedback reduced retries 4→1 in the low-delay DATA/ACK-loss
-case. Together they reduced high-delay fault retries 72→33. These are three-repeat
-64 KiB localhost proxy comparisons, not Mininet throughput benchmarks. Goodput in
-the high-delay fault case changed little (0.7093→0.7090 Mb/s). Capped slow start
-lowered short uncontended-flow goodput; a fairness/queueing benefit was not tested.
-
-Artifacts: [test log](../results/rubric-final-native/tests.log),
-[hash-bearing metrics](../results/rubric-final-native/transport/metrics.csv),
-[environment](../results/rubric-final-native/transport/environment.json),
-[summaries/plots](../results/rubric-final-native/transport/plots/summary.md).
-See KEY_RESULTS.md for the short table and direct plot links.
-
-## Final Linux findings
-
-The Ubuntu 22.04 container on the Ubuntu 26.04 ARM64 VM completed 180/180
-trial expectations across three repeats: 132 hash-matched successes and 48
-expected static failures. Upper→lower failover passed 48/48, lower→upper 3/3,
-robustness 6/6 and probe soft degradation 3/3. The 57 recovered trial rows have
-median link-down→useful-ACK latency 85.554 ms, mean 116.743 ms, and range
-12.276–401.377 ms. Censored outcomes remain null and are excluded from latency
-statistics. These are userspace OVS measurements, not physical-switch or kernel
-OVS results. [Validation](VALIDATION.md) defines the samples and evidence limits;
-[metrics](../results/rubric-final/matrix/metrics.csv) retain individual outcomes.
-
-## Deliberate limits
-
-This is a topology-specific lab uploader, not TCP or an FTP-compatible service.
-It omits authentication, encryption, persistent resume, arbitrary topology
-discovery, mature fairness/pacing and crash durability. Probes include control
-channel delay and sample loss noisily. Window 32 caps memory and throughput.
-Long outages exceed inactivity/retry budgets. Virtualized userspace OVS timings
-must not be generalized to physical switches without measurement. No frontend
-or unrelated portfolio work was needed for the rubric.
+## Local transport measurements: measured on macOS, three repeats per configuration
+
+**48 automated tests passed. All 60/60 final localhost transfers completed with
+matching recorded input/output SHA-256.** Each transfer is 64 KiB; the proxy delays
+DATA/ACK directions equally and uses deterministic faults, not a bottleneck queue.
+
+| Controlled comparison (CC off) | Before retries | After retries | Result |
+| --- | ---: | ---: | --- |
+| 260 ms nominal RTT, no loss: fixed → adaptive RTO, plain ACK | 64 | 32 | 50% fewer unnecessary retries |
+| 10 ms nominal RTT, DATA 10 and ACKs 5–7 initially lost: plain → bitmap, fixed RTO | 4 | 1 | 75% fewer retries |
+| 260 ms nominal RTT, same faults: fixed/plain → adaptive/bitmap | 72 | 33 | 54.2% fewer retries |
+
+These counts were identical across all three repeats in each listed cell.
+Goodput changed little: the high-delay fault case was 0.7093 versus 0.7090 Mb/s.
+The gain here is lower extra traffic, not a claimed speedup. Fast gap recovery is
+held on in both ACK modes. The first window still pays for an initial RTO that is
+too short. At 10 ms RTT without faults, enabling CWND reduced mean goodput from
+15.144 to 6.752 Mb/s on this short uncontended transfer; no fairness benefit is
+claimed from this dataset.
+
+Evidence: [test log](../results/rubric-final-native/tests.log),
+[execution manifest](../results/rubric-final-native/evaluation.json),
+[raw measurements and hashes](../results/rubric-final-native/transport/metrics.csv),
+[full cell summaries](../results/rubric-final-native/transport/plots/summary.md).
+
+Plots: [retry counts](../results/rubric-final-native/transport/plots/retransmits.svg),
+[retry fraction](../results/rubric-final-native/transport/plots/retransmit_fraction.svg),
+[goodput](../results/rubric-final-native/transport/plots/goodput_mbps.svg),
+[adaptive RTO/CWND trace](../results/rubric-final-native/transport/trace/timeline.svg).
+
+## Repeated Linux evaluation
+
+[Manifest](../results/rubric-final/evaluation.json) and
+[test log](../results/rubric-final/tests.log): **48 tests passed; 180/180 trial
+expectations met, 132 successful transfers with matching recorded input/output
+SHA-256, 48 expected permanent-failure static-routing failures.**
+
+- Matrix: 168 trials; 24 normal and 48 loss successes, 48 upper→lower failover
+ successes, 48 expected static failures.
+- Lower→upper: 3/3 successful; robustness: 6/6 under 2% loss, 20% reordering
+ and 0.5/2 s flaps; soft degradation: 3/3 successful with probe health-driven
+ upper→lower changes and no link-down event.
+- Across 57 recovered trial rows, ordered link_down→route_ready→first contiguous
+ ACK advancement is confirmed. Link-down→ACK: median **85.554 ms**, mean
+ **116.743 ms**, range **12.276–401.377 ms**. Injection→ACK is distinct:
+ median 88.493 ms, mean 119.945 ms, range 14.076–405.743 ms.
+- Keep 48 censored static recovery rows and 75 not-applicable rows as null.
+ Per-intervention records separately retain 58 recovered, 48 censored and
+ 218 inactive-path outages; these are not the trial-level latency sample.
+
+Environment: Ubuntu 26.04.1 ARM64 VM host; Ubuntu 22.04 Docker image,
+Python 3.10.12, OVS 2.17.12, **userspace datapath**. Kernel datapath validation
+is not claimed. Payloads were deleted by the runner; SHA-256 equality is verified
+from retained digest records and endpoint completion, not a fresh payload rehash.
+
+[Matrix](../results/rubric-final/matrix/metrics.csv),
+[lower recovery](../results/rubric-final/lower-recovery/metrics.csv),
+[robustness](../results/rubric-final/robustness/metrics.csv).
diff --git a/docs/RUBRIC_MAPPING.md b/docs/RUBRIC_MAPPING.md
deleted file mode 100644
index b7512a4..0000000
--- a/docs/RUBRIC_MAPPING.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Official 40-mark rubric: implementation and evidence map
-
-This is an evidence checklist, not a predicted score. The final Linux Docker/userspace OVS evaluation is complete;
-see VALIDATION.md for runtime and evidence boundaries.
-
-| Bucket | Marks | Final implementation | Artifact / assessment action |
-| --- | ---: | --- | --- |
-| D1 Problem Understanding & Architecture | 2 | Responsibility split, packet/state diagram, fixed diamond ports | ARCHITECTURE.md and VIVA.md |
-| D1 TCP/UDP Socket Implementation | 4 | C/POSIX UDP, SR, CRC, explicit completion, validated SACK | src/client.c, server.c, utils.c; final native tests.log |
-| D1 Mininet + SDN Implementation | 4 | OpenFlow 1.3 diamond, ARP/IPv4 forwarding, controller barriers | Final matrix per-trial network-initial/final.txt |
-| D1 Socket–SDN Integration | 3 | Same transfer across route change; shared monotonic clock | Final per-trial timelines, recovery events and hash-bearing metrics |
-| D1 Initial Testing & Demo | 2 | make test; one-command live preflight | 48 passing tests; completed Linux failover trials; rehearse demo.py |
-| D2 Complete Application Functionality | 4 | Binary/empty/boundary files, selective retries, atomic replacement | final local tests.log and 60/60 hash-bearing benchmark rows |
-| D2 Dynamic SDN Functionality | 6 | Static vs failover; hard failure; bidirectional probes; soft-score hysteresis | 48 upper→lower and 3 lower→upper successes; 3 soft-degradation successes |
-| D2 Challenging Scenario / Robustness | 4 | Loss, duplication, corruption, reordering, bounded timeouts, flaps | Local fault tests and 6 successful Linux loss/reordering/flap trials |
-| D2 Performance Evaluation & Comparison | 5 | Three-repeat routing/RTO/ACK/loss/fault-time matrix; hashes; all failures retained | 60 local transfers and 180 Linux expectations across three repeats |
-| D2 Documentation, Final Demo & Viva | 6 | README, report, decisions, validation, short answers and one-page demo | Docs match current code; live execution must still be rehearsed on assessment host |
-
-## Required hand-in evidence
-
-The retained `results/rubric-final/` bundle is the completed evaluation.
-Use a fresh output directory for any rerun.
-Keep that entire directory: commands, environment/source fingerprint, complete
-unit log, every trial row and hash, endpoint/controller/probe/progress logs,
-interventions/outages, flow/queue dumps and plots. Static permanent-link failure
-is an expected failed transfer, never a successful upload. Missing recovery is
-censored/N/A. See DEMO.md for the reliable short live sequence and contrast.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
deleted file mode 100644
index 4931047..0000000
--- a/docs/VALIDATION.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# Final rubric validation — 31 October 2026
-
-## Executed final-source local checks
-
-The one-command evaluation completed with status `passed`:
-
-```sh
-python3 scripts/evaluate.py --output results/rubric-final-native --local-only
-```
-
-- **48 tests passed**: 8 protocol, 5 adversarial transport, 4 probe tracking,
- 3 recording-adapter OpenFlow, 12 routing, 11 metrics, 5 local integration tests.
-- Coverage includes legacy/bitmap negotiation, unknown header versions, malformed
- and impossible SACKs, duplicate-ACK evidence, fixed/adaptive mode, Karn exclusion,
- 50–2000 ms timer bounds, CWND cap, corruption, loss, reordering, atomic replacement,
- bounded failure, recovery on either branch, repeated failures and censored recovery.
-- **60/60 three-repeat localhost benchmark transfers** succeeded with recorded
- matching input/output SHA-256. Raw traces and all configuration fields are saved.
-- Warning-enabled C11 build, Python syntax checks and diff whitespace checks pass.
-
-Artifacts: [manifest](../results/rubric-final-native/evaluation.json),
-[complete test log](../results/rubric-final-native/tests.log),
-[benchmark log](../results/rubric-final-native/benchmark.log),
-[metrics/hashes](../results/rubric-final-native/transport/metrics.csv),
-[environment/source fingerprint](../results/rubric-final-native/transport/environment.json),
-[summary](../results/rubric-final-native/transport/plots/summary.md).
-The native host is macOS. The OpenFlow adapter tests exercise the real controller
-handlers against recorded messages; they are not a running OS-Ken switch test.
-
-## Final Linux Docker validation — verified 31 October 2026
-
-The retained [evaluation manifest](../results/rubric-final/evaluation.json) reports
-`passed`, three repeats, starting at 13:28:20 UTC. All 60 configuration cells
-contain repeats 1, 2 and 3. [Tests](../results/rubric-final/tests.log) passed
-48 tests (8 protocol, 5 transport, 4 probes, 3 OpenFlow, 12 routing, 11 metrics,
-5 local integration). Runner logs contain 168, 3 and 9 trials respectively,
-all with `expected=True`, and no controller traceback.
-
-| Bundle | Trials | Hash-matched successes | Expected static failures |
-| --- | ---: | ---: | ---: |
-| [Matrix](../results/rubric-final/matrix/metrics.csv) | 168 | 120 | 48 |
-| [Lower recovery](../results/rubric-final/lower-recovery/metrics.csv) | 3 | 3 | 0 |
-| [Robustness](../results/rubric-final/robustness/metrics.csv) | 9 | 9 | 0 |
-| Total | 180 | 132 | 48 |
-
-All 180 `expectation_met` fields are true. Aggregate JSON, CSV and per-trial
-JSON agree. Every successful row has equal input/output SHA-256, successful
-endpoint exit codes and endpoint completion logs with the expected byte count.
-Payloads were not retained (`keep_files=false`); hash verification is the runner's
-recorded input/output digest comparison, not a new rehash of deleted payloads.
-The 8 MiB input hash is
-`956de95f8fbf243127b4242052fd396ece0969f9fb79ab84dd2d08e2dac4fd8c`;
-the 16 MiB robustness/soft-degradation input hash is
-`211cba1b86522400507e498fde24d8c7879c07222ef4a5743bc44c958830050f`.
-
-Permanent failure: static fails as expected in 48/48 cases; upper→lower failover
-succeeds in 48/48, lower→upper in 3/3. Normal succeeds in 24/24; loss cases
-(0% and 2% controls) in 48/48. Robustness succeeds in 6/6 with 2% loss,
-20% reordering and 0.5/2 s flap periods; retained intervention counts are
-117, 117, 118 and 32, 31, 31. Soft degradation succeeds in 3/3: injecting
-35% upper-branch loss produces upper→lower `health_update` decisions and
-barrier-confirmed `route_ready`, without any `link_down` event. Controller
-logs retain bidirectional probe samples and health scores.
-
-### Recovery measurements
-
-Every recovered trial was cross-checked against controller JSONL, progress JSONL,
-endpoint logs and its joined `plots/timeline.svg`. The useful ACK is the first
-`ack_progress` after `route_ready` whose contiguous base exceeds every base at or
-before readiness. All 57 recovered trial rows have ordered injection ≤ link_down
-≤ route_ready < useful ACK timestamps; all reported differences match the events.
-
-| Trial-level measurement (57 recovered rows) | Minimum | Median | Mean | Maximum |
-| --- | ---: | ---: | ---: | ---: |
-| link_down → useful ACK (ms) | 12.276 | 85.554 | 116.743 | 401.377 |
-| route_ready → useful ACK (ms) | 10.695 | 83.919 | 114.834 | 399.202 |
-| Injection request → useful ACK (ms) | 14.076 | 88.493 | 119.945 | 405.743 |
-
-Upper permanent-failure recovery (48 rows): link_down→ACK mean 132.651 ms,
-median 147.342 ms, range 38.384–401.377 ms. Lower recovery (3 rows): mean
-42.846 ms, median 42.968 ms, range 42.265–43.304 ms. Robust trials (6 rows):
-mean 26.422 ms, median 17.061 ms, range 12.276–76.378 ms.
-These are trial-level samples, not a pooled count of every flap.
-Per-intervention `outages-0.json` records retain 58 recovered outages, 48
-`censored_no_route` and 218 inactive-path flaps. The recovered outage timestamps
-were also checked against the traces. Trial recovery status totals are 57
-`recovered`, 48 `censored_no_route`, 75 `not_applicable` (including soft degradation).
-Missing and censored values remain null; they are not zero or included in latency
-statistics. Overall successful-transfer goodput ranges 2.999–9.313 Mb/s
-(mean 7.385, median 9.060); configurations differ, so this is descriptive only.
-
-### Environment and evidence boundaries
-
-VM host: Ubuntu 26.04.1 LTS, ARM64/aarch64, kernel 7.0.0-42-generic.
-The successful run used the project's Ubuntu 22.04 Docker image (see
-[Dockerfile](../Dockerfile)); retained environment manifests record Python 3.10.12,
-glibc 2.35, aarch64 and OVS 2.17.12. All three command manifests explicitly use
-`--datapath user`: this validates userspace OVS, not the kernel datapath.
-The container shares the VM kernel; it is not a native Ubuntu 22.04 host run.
-Live Docker metadata inspection required terminal sudo authentication and was
-unavailable during this audit; container release provenance is the Dockerfile,
-user-reported Docker execution and the recorded runtime versions.
-
-The separate Ubuntu 22.04 preflight (not included in this checkout)
-passed 1/1 with matching SHA-256 and upper→lower recovery of 42.392 ms from
-link_down (44.324 ms from injection). Its logs and timeline were inspected but
-are excluded from final evaluation counts and the commit. The failed Python 3.14
-preflight remains failed evidence and is not used to claim success.
-
-Retained final evidence includes [runner 0](../results/rubric-final/runner-0.log),
-[runner 1](../results/rubric-final/runner-1.log),
-[runner 2](../results/rubric-final/runner-2.log), and each bundle's environment,
-CSV/JSON metrics, summary plots, controller/progress/receiver events, endpoint
-logs, network dumps, interventions, outages and per-trial joined timelines.
-
-
-Historical material is preserved in [the archive](../results/archive/README.md).
-
-## Source identity after repository cleanup
-
-These measurements are retained from commit
-`79f097dea2ae8f61e70dd4558816531dfbbb97a5`. This newer client/codec interface has separate localhost checks. The recorded Linux
-measurements describe the earlier commit and its original source fingerprints.
-
-
-## Validate the final submission revision
-
-After merging, freeze the code and rebuild the Linux image from that checkout.
-Run the preflight and evaluation into fresh directories. Record the code commit
-alongside the output so later documentation commits cannot obscure its identity.
-
-```sh
-git rev-parse HEAD > results/submission-source.txt
-docker build -t rudp-lab:submission .
-sudo docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab:submission python scripts/run.py demo --mode failover --datapath user --output results/submission-preflight
-sudo docker run --rm --privileged -v "$PWD/results:/project/results" rudp-lab:submission python scripts/run.py evaluate --output results/submission-evaluation
-```
-
-The standard evaluation does not enable the optional multipath sender. Its
-successful completion alone cannot establish simultaneous use of both branches.
-Multipath needs a Linux trial with marked DATA, per-branch packet counters,
-matching hashes and hard/soft failure interventions. Localhost and recording
-OpenFlow tests cover implementation behavior, not that Mininet evidence gap.
-
-For an optional performance comparison, existing experiment switches support
-`--file-size`, `--delay`, `--flows` and `--ccs off on`. Change one factor at a time,
-use three repeats and fresh directories, and compare per-flow goodput and total
-goodput. A fairness claim requires competition measurements; it cannot be inferred
-from a clean single-flow transfer. No new performance measurements are claimed here.
diff --git a/docs/VIVA.md b/docs/VIVA.md
index 5c344b5..6b1049d 100644
--- a/docs/VIVA.md
+++ b/docs/VIVA.md
@@ -28,6 +28,8 @@
| How do you know the file is complete? | FIN is accepted only after all advertised bytes are written and whole-file CRC agrees. Close succeeds, then the temporary file is renamed. Silence is failure, never success. |
| Why can adaptive RTO or CWND reduce speed? | A conservative timer can delay recovery, and slow start costs short-flow throughput. We report the measured tradeoff instead of assuming every mechanism makes every run faster. |
| What did you deliberately omit? | TCP byte-stream semantics, mature fairness/pacing, ECN, encryption, arbitrary topology discovery, resume and a frontend. They do not earn the targeted reliability/SDN evidence within this lab. |
-| Which results validate the final SDN probes? | The final Linux bundle: 180/180 expectations, including 3/3 soft-degradation and 6/6 robustness trials. It used Ubuntu 22.04 Docker/userspace OVS; see VALIDATION.md. |
+| How does multipath work? | The client maps DATA sequence numbers to weighted DSCP marks. The controller uses multipath.py to decide which branches may carry those marks. Retries retain their mark. |
+| Why adapt probe timing? | probe_schedule.py spaces out successful probes on healthy branches and probes faster after loss. This measures the idle backup while limiting overhead. |
+| What does test_transfer_with_spaces_in_filenames verify? | tests/test_local.py starts the real sender and receiver with paths containing spaces, checks both exit codes and compares the received bytes. |
-Live commands: DEMO.md. Artifact-backed numbers: KEY_RESULTS.md and REPORT.md.
+[Measurements](REPORT.md) · [Live commands](DEMO.md)
diff --git a/results/README.md b/results/README.md
index ec97b07..1b3d245 100644
--- a/results/README.md
+++ b/results/README.md
@@ -4,11 +4,10 @@
| --- | --- |
| [rubric-final](rubric-final/evaluation.json) | Final Linux Docker/userspace OVS: 180 expectations, 132 hash-matched successes, 48 expected static failures; three repeats |
| [rubric-final-native](rubric-final-native/evaluation.json) | Final macOS transport evaluation: 48 tests and 60 hash-matched transfers; includes CC comparisons |
-| [archive](archive/README.md) | Superseded experiments, old example and execution plans; provenance only |
Raw final logs are intentionally retained unchanged for audit. Their millions of
JSONL events are generated data, not implementation code. Start with
-[Key Results](../docs/KEY_RESULTS.md) and the aggregate CSVs, then open per-trial
+[Report](../docs/REPORT.md) and the aggregate CSVs, then open per-trial
timelines/events when needed. Do not rerun into these directories.
These bundles identify the measured source/runtime. Later documentation and
diff --git a/results/archive/README.md b/results/archive/README.md
deleted file mode 100644
index d2e6d63..0000000
--- a/results/archive/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Historical material
-
-`historical-runs.tar.gz` preserves the superseded runs, execution plans, old
-implementation example and completion notes with their original relative paths.
-They are provenance only; use the two final bundles for current claims.
-
-Inspect without extraction: `tar -tzf results/archive/historical-runs.tar.gz`.
-Extract into a separate directory, never over the current checkout.
-
-Archived paths:
-
-- `results/concurrent-2026-10-29`
-- `results/evaluation-2026-10-29`
-- `results/reliability-verified`
-- `results/rubric-integration-history`
-- `results/rubric-linux-plan`
-- `results/validation`
-- `results/logs`
-- `results/plots`
-- `docs/UPGRADES.md`
-- `docs/RUBRIC_FINISH.md`
-- `examples`
diff --git a/results/archive/historical-runs.tar.gz b/results/archive/historical-runs.tar.gz
deleted file mode 100644
index 501ae0a..0000000
Binary files a/results/archive/historical-runs.tar.gz and /dev/null differ
diff --git a/scripts/commands/evaluate.py b/scripts/commands/evaluate.py
index ab207c1..a3c77af 100644
--- a/scripts/commands/evaluate.py
+++ b/scripts/commands/evaluate.py
@@ -54,7 +54,7 @@ def main():
parser.add_argument('--plan-only', action='store_true')
args = parser.parse_args()
if args.repeats < 3:
- parser.error('final evaluation requires at least three repetitions; use demo.py for a quick check')
+ parser.error('final evaluation requires at least three repetitions; use python3 scripts/run.py demo for a quick check')
output = args.output.resolve()
if not args.local_only and not args.plan_only and (sys.platform != 'linux' or os.geteuid() != 0):
parser.error('Linux/root required; use --plan-only here, or --local-only for transport evidence')
@@ -68,7 +68,7 @@ def save():
save()
if args.plan_only:
(output/'README.md').write_text('# Pending Linux evaluation\n\nThis directory contains a command plan, not measured results.\n'
- 'On a prepared Linux host run `sudo .venv/bin/python scripts/evaluate.py --output results/rubric-final`.\n'
+ 'On a prepared Linux host run `sudo .venv/bin/python scripts/run.py evaluate --output results/rubric-final`.\n'
'Expected: tests.log, evaluation.json, matrix/, lower-recovery/, robustness/, each with CSV/JSON, '
'plots/summary.md, controller/endpoint/progress logs, hashes and per-trial joined timelines.\n')
print(output/'evaluation.json')
diff --git a/scripts/commands/visualize.py b/scripts/commands/visualize.py
new file mode 100644
index 0000000..e4d5e84
--- /dev/null
+++ b/scripts/commands/visualize.py
@@ -0,0 +1,251 @@
+"""Loopback HTTP controls and a resumable SSE stream for one lab session."""
+import argparse
+import json
+import math
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import webbrowser
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlsplit
+
+ROOT = Path(__file__).resolve().parents[2]
+ASSETS = ROOT / "scripts/visualizer"
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from viz_live import run_live
+from viz_replay import Playback, list_trials, resolve_trial, run_replay
+
+
+class Busy(Exception):
+ pass
+
+
+class Session:
+ """A disk-backed journal lets slow browsers catch up without stalling UDP."""
+ def __init__(self, results):
+ self.results = results
+ self.changed = threading.Condition()
+ self.journal = tempfile.TemporaryFile()
+ self.generation = 0
+ self.size = 0
+ self.mode = None
+ self.worker = None
+ self.cancel = threading.Event()
+ self.loss = threading.Event()
+ self.playback = None
+
+ def emit(self, record):
+ line = json.dumps(record, separators=(',', ':'), allow_nan=False).encode() + b'\n'
+ with self.changed:
+ self.journal.seek(self.size)
+ self.size += self.journal.write(line)
+ self.changed.notify_all()
+
+ def active(self):
+ return self.worker is not None and self.worker.is_alive()
+
+ def start(self, mode, options, trial=None):
+ with self.changed:
+ if self.active():
+ raise Busy("Stop the current session before starting another.")
+ self.generation += 1
+ self.journal.seek(0)
+ self.journal.truncate()
+ self.size = 0
+ self.mode = mode
+ self.cancel = threading.Event()
+ self.loss = threading.Event()
+ self.playback = Playback(speed=options.get('speed', 1)) if trial else None
+ self.emit(dict(event='session_start', source='session', mode=mode, **options))
+
+ def run():
+ try:
+ if mode == 'live':
+ run_live(options, self.emit, self.cancel, self.loss)
+ else:
+ run_replay(trial, mode == 'follow', self.emit, self.cancel, self.playback)
+ outcome = dict(ok=not self.cancel.is_set())
+ except Exception as exc:
+ outcome = dict(ok=False, error=str(exc))
+ self.emit(dict(event='session_end', source='session',
+ cancelled=self.cancel.is_set(), **outcome))
+
+ self.worker = threading.Thread(target=run, name='telemetry-' + mode, daemon=True)
+ self.worker.start()
+ return {'generation': self.generation, 'mode': mode}
+
+ def batch(self, generation, offset):
+ with self.changed:
+ if generation == self.generation and offset == self.size:
+ self.changed.wait(timeout=8)
+ if generation != self.generation or offset > self.size:
+ offset = 0
+ self.journal.seek(offset)
+ frames = []
+ for line in self.journal.readlines(64 * 1024):
+ offset += len(line)
+ frames.extend((f'id: {self.generation}:{offset}\ndata: '.encode(), line, b'\n'))
+ return self.generation, offset, b''.join(frames) or b': heartbeat\n\n'
+
+
+def live_options(body):
+ options = {}
+ for name in ('sack', 'adaptive_rto', 'cc'):
+ value = body.get(name, True)
+ if type(value) is not bool:
+ raise ValueError(f'{name} must be a boolean')
+ options[name] = value
+ rate = body.get('drop_rate', 0.1)
+ if type(rate) not in (float, int) or not math.isfinite(rate) or not 0 <= rate <= 1:
+ raise ValueError('drop_rate must be between 0 and 1')
+ return dict(options, drop_rate=rate)
+
+
+class Handler(BaseHTTPRequestHandler):
+ protocol_version = 'HTTP/1.1'
+
+ def log_message(self, *args):
+ pass
+
+ def reply(self, status, data, mime='application/json'):
+ payload = data if isinstance(data, bytes) else json.dumps(data).encode()
+ self.send_response(status)
+ self.send_header('Content-Type', mime)
+ self.send_header('Content-Length', str(len(payload)))
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('X-Content-Type-Options', 'nosniff')
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def allowed(self):
+ port = self.server.server_port
+ hosts = {f'127.0.0.1:{port}', f'localhost:{port}'}
+ if port == 80:
+ hosts.update(('127.0.0.1', 'localhost'))
+ host = self.headers.get('Host', '')
+ origin = self.headers.get('Origin')
+ if host in hosts and (origin is None or origin == 'http://' + host):
+ return True
+ self.close_connection = True
+ self.reply(403, {'error': 'Only local, same-origin requests are accepted.'})
+ return False
+
+ def do_GET(self):
+ if not self.allowed():
+ return
+ session = self.server.session
+ path = urlsplit(self.path).path
+ files = {'/': ('index.html', 'text/html; charset=utf-8'),
+ '/app.js': ('app.js', 'application/javascript'),
+ '/style.css': ('style.css', 'text/css')}
+ if path in files:
+ name, mime = files[path]
+ self.reply(200, (ASSETS/name).read_bytes(), mime)
+ elif path == '/api/trials':
+ self.reply(200, {'trials': ['results/' + str(t) for t in list_trials(session.results)]})
+ elif path == '/api/stream':
+ self.stream(session)
+ else:
+ self.reply(404, {'error': 'Unknown resource'})
+
+ def stream(self, session):
+ try:
+ generation, offset = map(int, self.headers.get('Last-Event-ID', '0:0').split(':'))
+ if min(generation, offset) < 0:
+ raise ValueError()
+ except ValueError:
+ self.reply(400, {'error': 'Invalid stream cursor'})
+ return
+ self.send_response(200)
+ self.send_header('Content-Type', 'text/event-stream')
+ self.send_header('Cache-Control', 'no-store')
+ self.end_headers()
+ self.connection.settimeout(15)
+ try:
+ self.wfile.write(b': connected\n\n')
+ while True:
+ generation, offset, data = session.batch(generation, offset)
+ self.wfile.write(data)
+ except (ConnectionError, TimeoutError, ValueError):
+ return
+
+ def do_POST(self):
+ if not self.allowed():
+ return
+ try:
+ length = int(self.headers.get('Content-Length', '0'))
+ if self.headers.get_content_type() != 'application/json' or not 0 < length <= 8192:
+ self.close_connection = True
+ raise ValueError('Expected a small JSON request body')
+ body = json.loads(self.rfile.read(length))
+ if not isinstance(body, dict):
+ raise ValueError('Expected a JSON object')
+ result = self.control(urlsplit(self.path).path, body)
+ except Busy as exc:
+ self.reply(409, {'error': str(exc)})
+ except (ValueError, TypeError, FileNotFoundError) as exc:
+ self.reply(400, {'error': str(exc)})
+ else:
+ self.reply(200, result)
+
+ def control(self, path, body):
+ session = self.server.session
+ if path == '/api/start':
+ return session.start('live', live_options(body))
+ if path == '/api/replay-trial':
+ requested = body.get('trial')
+ follow = body.get('follow', False)
+ speed = body.get('speed', 1)
+ if not isinstance(requested, str) or not requested.strip() or type(follow) is not bool:
+ raise ValueError('Expected a trial path and boolean follow option')
+ if type(speed) not in (float, int) or speed not in (.25, .5, 1):
+ raise ValueError('Select 0.25x, 0.5x or 1x')
+ trial = resolve_trial(session.results, requested, follow=follow)
+ trial.resolve().relative_to(session.results.resolve())
+ return session.start('follow' if follow else 'replay',
+ dict(trial='results/' + trial.relative_to(session.results).as_posix(),
+ speed=speed), trial)
+ with session.changed:
+ if not session.active():
+ raise Busy('No active session')
+ if path == '/api/stop':
+ session.cancel.set()
+ elif path == '/api/inject-loss' and session.mode == 'live':
+ session.loss.set()
+ elif path == '/api/playback' and session.playback:
+ session.playback.control(body.get('action'), body.get('speed'))
+ else:
+ raise ValueError('Control unavailable in this mode')
+ return {'ok': True}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--port', type=int, default=8080)
+ parser.add_argument('--no-browser', action='store_true', help='Print the URL without opening a browser')
+ args = parser.parse_args()
+ if not 0 <= args.port <= 65535:
+ parser.error('port must be between 0 and 65535')
+ subprocess.run(['make'], cwd=ROOT, check=True)
+ with ThreadingHTTPServer(('127.0.0.1', args.port), Handler) as server:
+ server.session = Session(ROOT/'results')
+ url = f'http://127.0.0.1:{server.server_port}/'
+ print(f'Telemetry console at {url}', flush=True)
+ if not args.no_browser:
+ webbrowser.open(url)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.session.cancel.set()
+ if server.session.worker:
+ server.session.worker.join(timeout=10)
+ server.session.journal.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/commands/viz_live.py b/scripts/commands/viz_live.py
new file mode 100644
index 0000000..644eccf
--- /dev/null
+++ b/scripts/commands/viz_live.py
@@ -0,0 +1,134 @@
+"""Real C transfer execution and a delay/loss UDP observation relay."""
+import hashlib
+import heapq
+import json
+import os
+from pathlib import Path
+import select
+import socket
+import struct
+import subprocess
+import tempfile
+import time
+import zlib
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run_live(options, emit, stop_event, loss_control):
+ def publish(event, **fields):
+ emit(dict(event=event, source='relay', monotonic_ns=time.monotonic_ns(), **fields))
+ with tempfile.TemporaryDirectory(prefix='rudp-viz-') as folder:
+ folder = Path(folder)
+ source, output = folder/'input.bin', folder/'output.bin'
+ source.write_bytes(os.urandom(256*1024))
+ reserve = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ reserve.bind(('127.0.0.1', 0))
+ port = reserve.getsockname()[1]
+ reserve.close()
+ relay = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ relay.bind(('127.0.0.1', 0))
+ logs, handles, processes = {}, [], []
+ try:
+ for role in ('receiver', 'sender'):
+ path = folder/(role+'.jsonl')
+ logs[role] = None
+ handles.append((folder/(role+'.log')).open('w+'))
+ env = dict(os.environ, RUDP_PROGRESS_LOG=str(path), RUDP_VIZ_RING="1")
+ if role == 'receiver':
+ command = [str(ROOT/'server'), str(port), str(output)]
+ else:
+ command = [str(ROOT/'client'), '--host', '127.0.0.1', '--port',
+ str(relay.getsockname()[1]), '--file', str(source),
+ '--sack' if options['sack'] else '--no-sack',
+ '--adaptive-rto' if options['adaptive_rto'] else '--fixed-rto',
+ '--cc' if options['cc'] else '--no-cc',
+ '--drop-rate', str(options['drop_rate'])]
+ processes.append(subprocess.Popen(command, env=env, stdout=handles[-1], stderr=subprocess.STDOUT))
+ if role == 'receiver':
+ deadline = time.monotonic()+3
+ while 'Listening' not in (folder/'receiver.log').read_text():
+ if processes[0].poll() is not None or time.monotonic()>deadline:
+ raise RuntimeError('Receiver startup failed')
+ time.sleep(.01)
+ sender = None
+ pending, serial = [], 0
+ deadline = time.monotonic()+100
+ while not stop_event.is_set():
+ now = time.monotonic()
+ for role, handle in logs.items():
+ if handle is None:
+ path = folder/(role+'.jsonl')
+ if not path.exists():
+ continue
+ handle = logs[role] = path.open()
+ position = handle.tell()
+ while line := handle.readline():
+ if not line.endswith('\n'):
+ handle.seek(position)
+ break
+ record = json.loads(line)
+ record['source'] = role
+ emit(record)
+ position = handle.tell()
+ while pending and pending[0][0] <= now:
+ _, _, wire, peer = heapq.heappop(pending)
+ relay.sendto(wire, peer)
+ ready, _, _ = select.select([relay], [], [], .005)
+ if ready:
+ wire, peer = relay.recvfrom(65535)
+ reverse = peer[1] == port
+ if not reverse:
+ sender = peer
+ destination = sender if reverse else ('127.0.0.1', port)
+ if len(wire) >= 24:
+ magic, version, kind, length, transfer, sequence, crc = struct.unpack('!IBBHQII', wire[:24])
+ flags = {2:4, 6:4, 8:1, 9:5, 10:2}.get(kind, 0)
+ fields = dict(header_hex=wire[:24].hex(), magic=f'{magic:08x}', version=version, wire_type=kind, flags=flags,
+ transfer_id=f'{transfer:016x}', sequence=sequence,
+ length=length, crc32=f'{crc:08x}',
+ direction='ack' if reverse else 'data')
+ if kind == 10 and len(wire) >= 32:
+ fields['sack_base'], fields['sack_mask'] = struct.unpack('!II', wire[24:32])
+ publish('frame', **fields)
+ if kind == 3 and loss_control.is_set():
+ loss_control.clear()
+ publish('dropped_gap', sequence=sequence, reason='operator_injection')
+ continue
+ if destination:
+ serial += 1
+ heapq.heappush(pending, (now+.015, serial, wire, destination))
+ if processes[1].poll() is not None and processes[0].poll() is not None:
+ break
+ if now > deadline:
+ raise RuntimeError('Transfer timed out')
+ if stop_event.is_set():
+ return
+ for role, handle in logs.items():
+ if handle is None:
+ continue
+ for line in handle:
+ if line.endswith('\n'):
+ emit(dict(json.loads(line), source=role))
+ codes = [p.wait(timeout=1) for p in processes]
+ original = source.read_bytes()
+ received = output.read_bytes() if output.exists() else b''
+ match = output.exists() and hashlib.sha256(original).digest() == hashlib.sha256(received).digest()
+ publish('verification', sha256_match=match, source_sha256=hashlib.sha256(original).hexdigest(),
+ output_sha256=hashlib.sha256(received).hexdigest() if output.exists() else None,
+ crc32=f'{zlib.crc32(original):08x}', sender_exit=codes[1], receiver_exit=codes[0])
+ if codes != [0, 0] or not match:
+ raise RuntimeError('Transfer failed: ' + (folder/'sender.log').read_text() + (folder/'receiver.log').read_text())
+ finally:
+ for process in processes:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ for handle in [*logs.values(), *handles]:
+ if handle is not None:
+ handle.close()
+ relay.close()
diff --git a/scripts/commands/viz_replay.py b/scripts/commands/viz_replay.py
new file mode 100644
index 0000000..755d82d
--- /dev/null
+++ b/scripts/commands/viz_replay.py
@@ -0,0 +1,116 @@
+"""Monotonic-clock playback of recorded or growing lab JSONL files."""
+import json
+from pathlib import Path
+import threading
+import time
+
+
+class Playback:
+ def __init__(self, speed=1, paused=False):
+ self.speed, self.paused, self.steps = speed, paused, 0
+ self.lock = threading.Lock()
+
+ def control(self, action, speed=None):
+ with self.lock:
+ if action == 'speed':
+ if speed not in (.25, .5, 1):
+ raise ValueError('Invalid speed')
+ self.speed = speed
+ elif action in ('play', 'pause'):
+ self.paused = action == 'pause'
+ elif action == 'step':
+ self.paused = True
+ self.steps += 1
+ else:
+ raise ValueError('Unknown playback action')
+
+ def wait(self, seconds, stop):
+ while not stop.is_set():
+ with self.lock:
+ if self.steps:
+ self.steps -= 1
+ return True
+ paused, speed = self.paused, self.speed
+ if not paused:
+ if seconds <= 0:
+ return True
+ seconds -= .01 * speed
+ stop.wait(.01)
+ return False
+
+
+def list_trials(root):
+ return sorted({p.parent.relative_to(root).as_posix()
+ for p in root.rglob('controller.jsonl')})
+
+
+def resolve_trial(root, requested, follow=False):
+ root = root.resolve()
+ requested = requested.removeprefix('results/')
+ path = (root/requested).resolve()
+ path.relative_to(root)
+ if not path.is_dir():
+ raise ValueError('Trial directory does not exist')
+ if (path/'controller.jsonl').exists():
+ return path
+ trials = list(path.rglob('controller.jsonl'))
+ if not trials:
+ if follow:
+ return path
+ raise ValueError('No controller events in this directory')
+ trials.sort(key=lambda p: ('link-failure-failover' not in str(p), str(p)))
+ return trials[0].parent
+
+
+def run_replay(trial, follow, emit, stop_event, playback):
+ offsets = {}
+ previous = None
+ link_down = route_ready = None
+ while not stop_event.is_set():
+ if follow and not (trial/'controller.jsonl').exists():
+ candidates = sorted(trial.rglob('controller.jsonl'))
+ if candidates:
+ trial = candidates[0].parent
+ records = []
+ for path in sorted(trial.glob('*.jsonl')):
+ role = 'controller' if path.name == 'controller.jsonl' else 'receiver' if path.name.startswith('receiver') else 'sender'
+ with path.open() as stream:
+ stream.seek(offsets.get(path, 0))
+ while True:
+ position = stream.tell()
+ line = stream.readline()
+ if not line or not line.endswith('\n'):
+ offsets[path] = position
+ break
+ record = json.loads(line)
+ if 'monotonic_ns' in record:
+ records.append(dict(record, source=role))
+ records.sort(key=lambda r: r['monotonic_ns'])
+ for record in records:
+ stamp = record['monotonic_ns']
+ gap = max(0, (stamp-previous)/1e9) if previous is not None else 0
+ if not playback.wait(0 if follow else gap, stop_event):
+ return
+ emit(record)
+ if record.get('event') == 'link_down':
+ link_down, route_ready = stamp, None
+ elif record.get('event') == 'route_ready' and link_down is not None and stamp > link_down:
+ route_ready = stamp
+ elif (record.get('event') == 'ack_progress' and route_ready is not None
+ and stamp > route_ready):
+ emit(dict(event='recovery', source='replay', monotonic_ns=stamp,
+ link_down_to_ack_ms=(stamp-link_down)/1e6,
+ route_to_ack_ms=(stamp-route_ready)/1e6))
+ link_down = route_ready = None
+ previous = stamp
+ if not follow:
+ for name in ('network-initial.txt', 'network-final.txt'):
+ snapshot = trial/name
+ if snapshot.exists():
+ emit(dict(event='flow_snapshot', source='controller',
+ snapshot=name, rules=snapshot.read_text()))
+ metrics = trial/'metrics.json'
+ if metrics.exists():
+ emit(dict(event='recorded_metrics', source='replay', metrics=json.loads(metrics.read_text())))
+ return
+ stop_event.wait(.1)
diff --git a/scripts/demo.py b/scripts/demo.py
deleted file mode 120000
index 20448db..0000000
--- a/scripts/demo.py
+++ /dev/null
@@ -1 +0,0 @@
-commands/demo.py
\ No newline at end of file
diff --git a/scripts/evaluate.py b/scripts/evaluate.py
deleted file mode 120000
index 0a14857..0000000
--- a/scripts/evaluate.py
+++ /dev/null
@@ -1 +0,0 @@
-commands/evaluate.py
\ No newline at end of file
diff --git a/scripts/run.py b/scripts/run.py
index 9c44aac..0ecc53b 100644
--- a/scripts/run.py
+++ b/scripts/run.py
@@ -7,7 +7,7 @@
COMMANDS = {
"demo": "demo", "evaluate": "evaluate", "benchmark": "benchmark_reliability",
- "experiment": "run_experiments", "plot": "plot_metrics", "timeline": "plot_timeline",
+ "viz": "visualize", "experiment": "run_experiments", "plot": "plot_metrics", "timeline": "plot_timeline",
}
def main():
diff --git a/scripts/visualizer/app.js b/scripts/visualizer/app.js
new file mode 100644
index 0000000..b0f9228
--- /dev/null
+++ b/scripts/visualizer/app.js
@@ -0,0 +1,256 @@
+"use strict";
+const $ = (id) => document.getElementById(id);
+let origin = null,
+ rows = [],
+ frozen = false,
+ ring = [],
+ series = [],
+ rto = null,
+ cwnd = null,
+ srtt = null,
+ startBytes = null,
+ topology = {},
+ goodput = "—";
+async function post(path, body = {}) {
+ try {
+ const r = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await r.json();
+ if (!r.ok) throw Error(data.error);
+ $("notice").textContent = "Control accepted";
+ } catch (e) {
+ $("notice").textContent = e.message;
+ }
+}
+function reset() {
+ origin = null;
+ rows = [];
+ series = [];
+ ring = [];
+ topology = {};
+ rto = cwnd = srtt = startBytes = null;
+ goodput = "—";
+ $("events").replaceChildren();
+ $("header").replaceChildren();
+ $("frame").textContent = "No captured header in this session";
+ $("mask").textContent = "SACK mask —";
+ $("verify").textContent = "Verification pending";
+ $("sdn").textContent = "Waiting for controller events";
+ for (const p of ["upper", "lower"]) $(p).setAttribute("stroke", "#586069");
+ renderRing();
+ draw();
+}
+$("run").onclick = () =>
+ post("/api/start", {
+ sack: $("sack").checked,
+ adaptive_rto: $("adaptive").checked,
+ cc: $("cc").checked,
+ drop_rate: Number($("loss").value),
+ });
+$("inject").onclick = () => post("/api/inject-loss");
+$("stop").onclick = () => post("/api/stop");
+$("load").onclick = () =>
+ post("/api/replay-trial", {
+ trial: $("trialPath").value || $("trials").value,
+ follow: $("follow").checked,
+ speed: Number($("speed").value),
+ });
+for (const name of ["play", "pause", "step"])
+ $(name).onclick = () => post("/api/playback", { action: name });
+$("speed").onchange = () =>
+ post("/api/playback", { action: "speed", speed: Number($("speed").value) });
+$("freeze").onclick = () => {
+ frozen = !frozen;
+ $("freeze").textContent = frozen ? "Resume table" : "Pause table";
+ if (!frozen) renderRows();
+};
+$("filter").oninput = renderRows;
+fetch("/api/trials")
+ .then((r) => r.json())
+ .then((d) =>
+ $("trials").replaceChildren(...d.trials.map((t) => new Option(t, t))),
+ )
+ .catch((e) => ($("notice").textContent = e.message));
+function renderRing() {
+ $("ring").replaceChildren(
+ ...Array.from({ length: 32 }, (_, i) => {
+ const s = ring[i],
+ d = document.createElement("div");
+ d.className = "slot " + (s?.state || "");
+ d.textContent = s
+ ? s.sequence + "\n" + s.state + "\ngap " + s.gap_reports + "/3"
+ : "—";
+ return d;
+ }),
+ );
+}
+renderRing();
+function renderRows() {
+ const f = $("filter").value.toLowerCase();
+ $("events").replaceChildren(
+ ...rows
+ .filter((e) =>
+ ((e.source || "") + " " + e.event).toLowerCase().includes(f),
+ )
+ .slice(-200)
+ .reverse()
+ .map((e) => {
+ const tr = document.createElement("tr");
+ const t =
+ e.monotonic_ns && origin !== null
+ ? ((e.monotonic_ns - origin) / 1e9).toFixed(3)
+ : "—";
+ for (const value of [t, e.source, e.event, JSON.stringify(e)]) {
+ const td = document.createElement("td");
+ td.textContent = value ?? "—";
+ tr.append(td);
+ }
+ return tr;
+ }),
+ );
+}
+function header(e) {
+ $("header").replaceChildren(
+ ...Array.from({ length: 24 }, (_, i) => {
+ const d = document.createElement("div");
+ d.className = "byte";
+ const n = document.createElement("small");
+ n.textContent = i;
+ d.append(
+ n,
+ document.createTextNode(e.header_hex.slice(i * 2, i * 2 + 2)),
+ );
+ return d;
+ }),
+ );
+ $("frame").textContent = JSON.stringify(
+ {
+ magic: e.magic,
+ version: e.version,
+ wire_type: e.wire_type,
+ flags: e.flags,
+ flag_names: [
+ e.flags & 1 ? "SACK_CAPABLE" : null,
+ e.flags & 2 ? "SACK_BITMAP_PRESENT" : null,
+ e.flags & 4 ? "CONTROL_REPLY" : null,
+ ].filter(Boolean),
+ transfer_id: e.transfer_id,
+ sequence: e.sequence,
+ length: e.length,
+ crc32: e.crc32,
+ },
+ null,
+ 2,
+ );
+ if (e.sack_mask !== undefined)
+ $("mask").textContent =
+ "SACK mask 0x" +
+ e.sack_mask.toString(16).padStart(8, "0") +
+ " · receiver base " +
+ e.sack_base;
+}
+function draw() {
+ const c = $("chart"),
+ g = c.getContext("2d");
+ g.clearRect(0, 0, c.width, c.height);
+ g.strokeStyle = "#30363d";
+ g.beginPath();
+ for (let i = 0; i <= 4; i++) {
+ g.moveTo(0, i * 40 + 10);
+ g.lineTo(c.width, i * 40 + 10);
+ }
+ g.stroke();
+ for (const [key, max, color] of [
+ ["rto", 2000, "#58a6ff"],
+ ["cwnd", 32, "#3fb950"],
+ ]) {
+ g.strokeStyle = color;
+ g.beginPath();
+ let begun = false;
+ const lo = series[0]?.time || 0,
+ span = Math.max(1, (series.at(-1)?.time || 0) - lo);
+ for (const p of series) {
+ if (p[key] === null) continue;
+ const x = ((p.time - lo) / span) * c.width,
+ y = 170 - (p[key] / max) * 160;
+ begun ? g.lineTo(x, y) : g.moveTo(x, y);
+ begun = true;
+ }
+ g.stroke();
+ }
+}
+let dirty = false;
+setInterval(() => {
+ if (dirty) {
+ if (!frozen) renderRows();
+ draw();
+ dirty = false;
+ }
+}, 150);
+new EventSource("/api/stream").onmessage = (message) => {
+ const e = JSON.parse(message.data);
+ if (e.event === "session_start") {
+ reset();
+ $("status").textContent = e.mode.toUpperCase();
+ }
+ if (e.monotonic_ns && origin === null) origin = e.monotonic_ns;
+ rows.push(e);
+ if (rows.length > 1000) rows.shift();
+ if (e.event === "frame") header(e);
+ if (e.event === "ring_snapshot") {
+ ring = e.slots;
+ renderRing();
+ }
+ if (e.event === "dropped_gap") {
+ const s = ring.find((x) => x.sequence === e.sequence);
+ if (s) s.state = "DROPPED_GAP";
+ renderRing();
+ }
+ if (e.event === "rto_update") rto = e.value;
+ if (e.event === "srtt_update") srtt = e.value;
+ if (e.event === "cwnd_change") cwnd = e.value;
+ if (["rto_update", "cwnd_change"].includes(e.event)) {
+ series.push({ time: (e.monotonic_ns - origin) / 1e9, rto, cwnd });
+ if (series.length > 2000) series.shift();
+ }
+ if (e.event === "ack_progress") {
+ if (!startBytes) startBytes = e;
+ const elapsed = (e.monotonic_ns - startBytes.monotonic_ns) / 1e9;
+ if (elapsed > 0)
+ goodput =
+ (((e.bytes - startBytes.bytes) * 8) / elapsed / 1e6).toFixed(3) +
+ " Mb/s";
+ }
+ $("metrics").textContent =
+ "SRTT " +
+ (srtt?.toFixed(1) ?? "—") +
+ " ms · RTO " +
+ (rto?.toFixed(1) ?? "—") +
+ " ms · cwnd " +
+ (cwnd?.toFixed(1) ?? "—") +
+ " · ACK goodput " +
+ goodput;
+ if (e.source === "controller") {
+ topology[e.event] = e;
+ $("sdn").textContent = JSON.stringify(topology, null, 2);
+ if (e.event === "route_ready")
+ for (const path of ["upper", "lower"])
+ $(path).setAttribute("stroke", e.path === path ? "#3fb950" : "#586069");
+ }
+ if (["verification", "recorded_metrics", "recovery"].includes(e.event))
+ $("verify").textContent = JSON.stringify(e, null, 2);
+ if (e.event === "session_end") {
+ $("status").textContent = e.ok
+ ? "COMPLETE"
+ : e.cancelled
+ ? "STOPPED"
+ : "FAILED";
+ if (e.error) $("notice").textContent = e.error;
+ }
+ dirty = true;
+};
+
+// Slots below the cumulative base have left the active ring.
diff --git a/scripts/visualizer/index.html b/scripts/visualizer/index.html
new file mode 100644
index 0000000..5a7f058
--- /dev/null
+++ b/scripts/visualizer/index.html
@@ -0,0 +1,128 @@
+
+
+