diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf53041..1cd21ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,15 @@ jobs: curl -sf http://localhost:8000/workloads | grep -q 'workload_id' curl -sf http://localhost:8000/findings | grep -q 'refuted' curl -sf http://localhost:8000/ | grep -q 'DELPHI' + # The container must not run as root, and the SPA catch-all must not serve + # anything outside dist. Percent-encoded traversal previously read arbitrary + # container files; Render's edge rejected it, `docker run` did not. + test "$(docker exec delphi id -u)" != "0" + for probe in '/..%2f..%2frequirements-serve.txt' '/%2e%2e/%2e%2e/%2e%2e/etc/passwd'; do + body=$(curl -s --path-as-is "http://localhost:8000$probe") + echo "$body" | grep -q 'root:' && { echo "TRAVERSAL: $probe"; exit 1; } + echo "$body" | grep -q 'Runtime-only dependencies' && { echo "TRAVERSAL: $probe"; exit 1; } + done curl -sf -X POST http://localhost:8000/diagnostic/score \ -H 'Content-Type: application/json' \ -d "$(python3 -c 'import json,math;print(json.dumps({"values":[60+25*math.sin(i/45.8) for i in range(2000)],"step_seconds":300}))')" \ diff --git a/Dockerfile.web b/Dockerfile.web index 303eac9..3cb0306 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -46,6 +46,12 @@ ENV PYTHONPATH=/app/src \ DELPHI_SNAPSHOT_MODE=demo \ PORT=8000 +# Drop root. Nothing here needs it: the app reads a baked snapshot and writes nothing. A +# read-only API running as uid 0 turns any file-disclosure bug into a read of the whole +# container, which is exactly what the SPA traversal bug would have done. +RUN useradd --create-home --uid 10001 delphi && chown -R delphi:delphi /app +USER delphi + EXPOSE 8000 # Render injects $PORT; default to 8000 for local runs of the exact deploy image. diff --git a/docs/EVAL.md b/docs/EVAL.md index eccad52..44a3383 100644 --- a/docs/EVAL.md +++ b/docs/EVAL.md @@ -1196,3 +1196,107 @@ would mean something. forecastability literature. - **The one measure that showed signal does not survive multiple-comparison correction**, and is reported as a lead rather than promoted into the product. + +--- + +# Full-codebase audit (2026-08-09) + +The 2026-08-03 audit covered M0–M3 and the 2026-08-08 one covered the control and product +layers. This pass covered what neither did: the queueing core, the adaptive controllers, the +Pareto machinery, the price client, and the deployed container. Four defects, one of them a +live security hole. + +## 1. The budget pacer was steering on a frozen capacity trace + +`adaptive.py` carried its **own copy** of the actuation-delay rule, so C5/C6 could reconstruct +what their past requests would have served and pace against it. When the clock-restart bug +was fixed in `apply_actuation_delay` on 2026-08-08, that copy was left behind — still +restarting the clock, still freezing. + +Measured on a plan that moves every step, which is exactly what C5/C6 emit: the mirror +reported a flat 13 replicas for 40 steps while the simulator provisioned 13 → 24. **36 of 40 +steps disagreed**, so the PI loop spent every run correcting against violations that never +happened. + +Fixed by extracting `ActuationTracker`, with `apply_actuation_delay` implemented on top of it +so the batch and incremental forms *cannot* diverge. A test asserts they agree over 200 +random plans, and another fails if the lag rule is re-implemented anywhere outside +`simulator.py` — this being the second time a copy of it drifted. + +**Effect on published results: small and no conclusion moves.** Re-running the frontier +changes only C5/C6 rows: cost 66.6 → 66.5, churn 180 → 176, violation rates shifting by +around 0.001–0.008 in both directions. Q4 is bit-for-bit unchanged at 9 better / 3 tie / 6 +worse of 18, and the "wins below 19:1, loses above" pattern holds. The GPU frontier verdict +is unchanged at 0 of 8. + +## 2. Path traversal in the deployed dashboard + +The SPA catch-all joined the request path onto the dist directory and served whatever it +found. Percent-encoded traversal survives URL normalisation, reaches the handler intact, and +read arbitrary files off the container: + +``` +/..%2f..%2frequirements-serve.txt -> served the file +/%2e%2e/%2e%2e/%2e%2e/etc/hostname -> served the file +``` + +Verified against the real deploy image under uvicorn, not merely in a test client. **The +container also ran as root**, so this was an arbitrary read of everything in it. No +credentials exist to steal — the project is keyless by design — but the source, the +dependency manifest and every system file were readable. + +Render's edge happened to reject the encoded forms with a 400, so the live site was not +exploitable. That is an accident of the CDN rather than a control, and anyone following the +README's `docker run` had no such cover. + +Fixed by resolving the candidate and requiring it to stay inside dist — which also closes +symlink escapes and absolute-path injection — and by dropping to an unprivileged user in the +image. Both are now asserted in the deploy-image CI lane against the running container, so a +regression fails the build rather than the internet. + +## 3. Hypervolume was measuring the wrong staircase + +`dominated_hypervolume` swept from cost zero and credited each strip with the *dearer* +point's violation rate, when over that interval only the cheaper point is affordable. A +single point at cost 5 with a 0.2 violation rate against a reference corner at cost 10 +returned 8.0 where the true dominated rectangle is 5 x 0.8 = **4.0**. + +This is not merely an inflation: it can reverse a ranking. A frontier reaching a 0.05 +violation rate at cost 9 scored *above* one sitting at 0.45 for cost 5, where the corrected +areas are 1.75 and 3.25 respectively. + +The existing test compared two frontiers where the error cancelled, so it passed throughout. +It now checks areas computed by hand. **No published result used this function** — it is +reported nowhere in this document — so nothing downstream moves. + +## 4. The two fidelities were documented as the same system + +`_synthesize_arrivals` claimed its service-time construction kept the utilisation and queue +models "describing the same physical system". They do not: `utilisation_target` derates +capacity in the utilisation model but is not applied in the queue, so at a target of 0.8 the +queue runs at rho = 0.8 where the utilisation model reports a step exactly at its limit. + +Defensible for two models answering different questions, and harmless in practice — every +published result uses the utilisation model, and the Erlang-C validation sets the target to +1.0 where the discrepancy vanishes — but the docstring asserted something false about the +code. Corrected to state the difference and why it does not contaminate anything. + +## Verified sound + +The ACI update rule reproduces the published BACC form exactly, including the sign of the +correction under miscoverage. `pareto_front` is a correct two-objective sweep. +`cost_at_matched_violation` reports unreachable targets as unreachable rather than +substituting the nearest point. `parse_price_items` skips rows with a missing or non-positive +price rather than defaulting them, which would otherwise drive the newsvendor ratio to buy +unbounded capacity. `cheapest_hourly` refuses to convert monthly reservations into hourly +prices. The OData filter is now escaped — not a live injection path, since the values come +from config, but an apostrophe would have silently returned the wrong SKU's price. + +## Added to the negatives ledger + +- **A duplicated implementation of the actuation lag drifted from its original**, for the + second time, and fed a control loop a capacity trace that never moved. +- **The deployed container was vulnerable to path traversal and ran as root.** It was + unexploitable in production only because of a CDN behaviour nobody had designed for. +- **A Pareto summary statistic could rank two frontiers backwards**, and its test passed + because the error cancelled between the two curves being compared. diff --git a/src/delphi/api/app.py b/src/delphi/api/app.py index 4ac169d..5411f30 100644 --- a/src/delphi/api/app.py +++ b/src/delphi/api/app.py @@ -281,10 +281,28 @@ def evidence(limit: Annotated[int, Query(ge=1, le=200)] = 50) -> dict[str, objec if _DIST.is_dir(): app.mount("/assets", StaticFiles(directory=_DIST / "assets"), name="assets") + _DIST_ROOT = _DIST.resolve() + @app.get("/{path:path}", include_in_schema=False) def spa(path: str) -> FileResponse: - """Serve the SPA shell for any unmatched path.""" - candidate = _DIST / path - if path and candidate.is_file(): + """Serve the SPA shell for any unmatched path. + + **The candidate is resolved and confined to the dist directory.** Without that, + ``_DIST / path`` walks straight out of it: percent-encoded traversal such as + ``/..%2f..%2frequirements-serve.txt`` or ``/%2e%2e/%2e%2e/etc/hostname`` survives + URL normalisation, reaches this handler intact, and served arbitrary files off the + container — as root, in the deploy image. Render's edge happened to reject those + requests with a 400, but an accident at the CDN is not a security control, and + anyone running the documented ``docker run`` locally had no such cover. + + ``resolve()`` also collapses symlinks, so a link inside ``dist`` cannot be used to + step outside it either. An absolute ``path`` would make ``/`` discard the root + entirely; the containment check catches that case too. + """ + index = _DIST_ROOT / "index.html" + if not path: + return FileResponse(index) + candidate = (_DIST_ROOT / path).resolve() + if candidate.is_file() and candidate.is_relative_to(_DIST_ROOT): return FileResponse(candidate) - return FileResponse(_DIST / "index.html") + return FileResponse(index) diff --git a/src/delphi/control/adaptive.py b/src/delphi/control/adaptive.py index e0d14f0..cbd1d12 100644 --- a/src/delphi/control/adaptive.py +++ b/src/delphi/control/adaptive.py @@ -22,7 +22,7 @@ from delphi.control.controllers import ControlContext, PercentileRecommender, _replicas_for from delphi.control.newsvendor import CostRatio, interpolate_quantile -from delphi.control.simulator import clamp_plan +from delphi.control.simulator import ActuationTracker, clamp_plan from delphi.forecast.calibration import AdaptiveConformalCalibrator from delphi.forecast.contracts import QuantileForecaster @@ -127,10 +127,9 @@ def _clamped(replicas: int) -> int: # raw forecast value emitted for each step, so its residual can be scored once the # true demand for that step becomes observable raw_for_step: dict[int, float] = {} - # incremental mirror of the simulator's actuation delay, over our own plan - current = int(requested[0]) - pending_target: int | None = None - pending_at = 0 + # incremental mirror of the simulator's actuation delay, over our own plan. Shares the + # simulator's implementation rather than restating it, so the two cannot drift. + tracker = ActuationTracker(profile, initial=int(requested[0])) served: dict[int, int] = {} settled = context.start_step @@ -182,16 +181,7 @@ def _clamped(replicas: int) -> int: raw_for_step[target_step] = raw # advance our mirror of the platform's actuation lag one step - asked = int(requested[target_step]) - in_flight = pending_target if pending_target is not None else current - if asked != in_flight: - lag = profile.startup_steps if asked > current else profile.teardown_steps - pending_target = asked - pending_at = target_step + max(lag, 0) - if pending_target is not None and target_step >= pending_at: - current = pending_target - pending_target = None - served[target_step] = current + served[target_step] = tracker.advance(target_step, int(requested[target_step])) step += block diff --git a/src/delphi/control/simulator.py b/src/delphi/control/simulator.py index c236709..53efb8a 100644 --- a/src/delphi/control/simulator.py +++ b/src/delphi/control/simulator.py @@ -146,6 +146,52 @@ def summary(self) -> dict[str, float]: } +class ActuationTracker: + """Incremental form of the actuation delay: one step in, one serving count out. + + This exists so there is exactly **one** implementation of the lag. A controller that + paces itself against its own past decisions has to reconstruct what was actually + serving, and the obvious way to do that is to re-derive the rule — which is what C5/C6 + did, and which meant that fixing the clock-restart bug in `apply_actuation_delay` left a + stale copy in `adaptive.py` silently feeding the PI loop a capacity trace that froze at + the initial replica count. A shared object cannot drift from itself. + """ + + def __init__(self, profile: CapacityProfile, *, initial: int) -> None: + self._profile = profile + self._current = int(initial) + self._pending: int | None = None + self._pending_at = 0 + + @property + def current(self) -> int: + """What is serving right now, after everything already landed.""" + return self._current + + def advance(self, step: int, requested: int) -> int: + """Register the request for ``step`` and return what is serving at ``step``. + + Superseding retargets the change in flight and keeps its original landing step: + pods already starting do not begin again because the desired count moved. + """ + target = int(requested) + if self._pending is None: + if target != self._current: + lag = ( + self._profile.startup_steps + if target > self._current + else self._profile.teardown_steps + ) + self._pending = target + self._pending_at = step + max(lag, 0) + elif target != self._pending: + self._pending = target + if self._pending is not None and step >= self._pending_at: + self._current = self._pending + self._pending = None + return self._current + + def apply_actuation_delay(requested: IntArray, profile: CapacityProfile) -> IntArray: """Turn a requested trajectory into the capacity that is actually serving. @@ -164,26 +210,10 @@ def apply_actuation_delay(requested: IntArray, profile: CapacityProfile) -> IntA unrequestable state that silently penalises exactly the smooth, forecast-driven controllers this project exists to evaluate. """ - steps = len(requested) - provisioned = np.empty(steps, dtype=np.int64) - current = int(requested[0]) - pending_target: int | None = None - pending_at = 0 - - for step in range(steps): - target = int(requested[step]) - if pending_target is None: - if target != current: - lag = profile.startup_steps if target > current else profile.teardown_steps - pending_target = target - pending_at = step + max(lag, 0) - elif target != pending_target: - # Retarget the in-flight change, keeping its original landing step. - pending_target = target - if pending_target is not None and step >= pending_at: - current = pending_target - pending_target = None - provisioned[step] = current + tracker = ActuationTracker(profile, initial=int(requested[0])) + provisioned = np.empty(len(requested), dtype=np.int64) + for step in range(len(requested)): + provisioned[step] = tracker.advance(step, int(requested[step])) return provisioned @@ -216,8 +246,19 @@ def _synthesize_arrivals( Arrivals are placed uniformly at random inside their step, which reproduces a Poisson process conditioned on the per-step count. Service times are exponential with a mean - implied by ``capacity_per_replica`` so that one replica serves exactly that many units - per step on average — keeping the two fidelities describing the same physical system. + implied by ``capacity_per_replica``, so one replica serves exactly that many units per + step on average. + + **The two fidelities are not the same physical system, and this docstring used to claim + they were.** ``utilisation_target`` derates capacity in the utilisation model — a + replica there absorbs ``capacity_per_replica * utilisation_target`` per step — but the + queue model applies no such derate, because a queue expresses congestion through waiting + time rather than through a headroom ceiling. At a target of 0.8 the queue therefore runs + at rho = 0.8 where the utilisation model reports a step exactly at its limit. That is + defensible for two models answering different questions, but it means their absolute + numbers are not comparable and must never share a results table. Every published result + in `docs/EVAL.md` uses the utilisation model; the queue exists to validate the simulator + against Erlang-C, where ``utilisation_target`` is set to 1.0 and the discrepancy is nil. """ counts = np.rint(np.maximum(demand, 0.0)).astype(np.int64) total = int(counts.sum()) diff --git a/src/delphi/data/prices.py b/src/delphi/data/prices.py index 9eddd16..0b9b819 100644 --- a/src/delphi/data/prices.py +++ b/src/delphi/data/prices.py @@ -101,11 +101,25 @@ def parse_price_items(payload: dict[str, Any], *, retrieved_at: datetime) -> lis return prices +def _odata_literal(value: str) -> str: + """Quote a string for an OData filter, escaping embedded single quotes by doubling. + + These values come from config rather than from a request, so this is not a live + injection path — but an unescaped apostrophe would silently malform the query and + return the wrong SKU's price, which then propagates into the newsvendor ratio as a + plausible-looking number. Cheaper to escape than to debug. + """ + return "'" + value.replace("'", "''") + "'" + + def build_filter(*, region: str, service_name: str, sku_contains: str | None = None) -> str: """Compose the OData ``$filter`` the API expects.""" - clauses = [f"armRegionName eq '{region}'", f"serviceName eq '{service_name}'"] + clauses = [ + f"armRegionName eq {_odata_literal(region)}", + f"serviceName eq {_odata_literal(service_name)}", + ] if sku_contains: - clauses.append(f"contains(skuName, '{sku_contains}')") + clauses.append(f"contains(skuName, {_odata_literal(sku_contains)})") return " and ".join(clauses) diff --git a/src/delphi/evaluation/frontier.py b/src/delphi/evaluation/frontier.py index 739573c..7a4a344 100644 --- a/src/delphi/evaluation/frontier.py +++ b/src/delphi/evaluation/frontier.py @@ -13,6 +13,7 @@ from collections.abc import Sequence from dataclasses import dataclass +from itertools import pairwise import numpy as np import numpy.typing as npt @@ -122,19 +123,25 @@ def dominated_hypervolume( A scalar summary for when one is genuinely needed. Larger is better. It is reported only alongside its reference point, because a hypervolume without its reference is meaningless and trivially manipulable. + + The staircase runs *forward* from each point: over the cost interval between one + frontier point and the next, the best violation rate available is the **cheaper** + point's, because the dearer one cannot be afforded yet. An earlier version credited each + strip with the dearer point's rate and started the sweep at cost zero, which double + counted a single-point frontier and could reverse the ranking of two curves. """ - front = pareto_front(points) + front = [ + point + for point in pareto_front(points) + if point.cost < cost_reference and point.violation_rate < violation_reference + ] if not front: return 0.0 area = 0.0 - previous_cost = 0.0 - for point in front: - if point.cost >= cost_reference or point.violation_rate >= violation_reference: - continue - width = point.cost - previous_cost - area += width * (violation_reference - point.violation_rate) - previous_cost = point.cost - area += (cost_reference - previous_cost) * (violation_reference - front[-1].violation_rate) + for current, following in pairwise(front): + area += (following.cost - current.cost) * (violation_reference - current.violation_rate) + last = front[-1] + area += (cost_reference - last.cost) * (violation_reference - last.violation_rate) return float(max(area, 0.0)) diff --git a/tests/test_api.py b/tests/test_api.py index 4e5539a..d8b92e8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -248,3 +248,53 @@ def test_demand_response_is_bounded(client: TestClient, tmp_path: Path) -> None: path.write_text(original) else: path.unlink() + + +def test_spa_route_cannot_be_walked_out_of_the_dist_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Percent-encoded traversal escaped `dist` and served arbitrary container files. + + `/..%2f..%2frequirements-serve.txt` and `/%2e%2e/%2e%2e/etc/hostname` both survived URL + normalisation, reached the handler, and were served — as root, in the deploy image. + Render's edge rejected them with a 400, but that is an accident of the CDN, not a + control, and `docker run` locally had no such cover. + """ + import importlib + + from delphi import config as config_module + + dist = tmp_path / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("<title>DELPHI") + (dist / "assets" / "app.js").write_text("// bundle") + (tmp_path / "SECRET.txt").write_text("SENSITIVE-CONTENTS") + + monkeypatch.setenv("DELPHI_DASHBOARD_DIST", str(dist)) + monkeypatch.setenv("DELPHI_SNAPSHOT_PATH", str(tmp_path / "snapshot.json")) + config_module.get_settings.cache_clear() + reloaded = importlib.reload(app_module) + try: + client = TestClient(reloaded.app) + for probe in ( + "/../SECRET.txt", + "/..%2fSECRET.txt", + "/%2e%2e/SECRET.txt", + "/..%2f..%2fSECRET.txt", + "/%2e%2e%2fSECRET.txt", + ): + body = client.get(probe).text + assert "SENSITIVE-CONTENTS" not in body, f"{probe} escaped the dist directory" + assert "DELPHI" in body, f"{probe} should fall through to the SPA shell" + + # Under /assets Starlette's StaticFiles guards its own mount and answers 404 + # rather than falling through — also safe, just a different shape of refusal. + assets_probe = client.get("/assets/..%2f..%2fSECRET.txt") + assert "SENSITIVE-CONTENTS" not in assets_probe.text + assert assets_probe.status_code == 404 + + # A genuine asset inside dist must still be served. + assert "// bundle" in client.get("/assets/app.js").text + finally: + config_module.get_settings.cache_clear() + importlib.reload(app_module) diff --git a/tests/test_frontier.py b/tests/test_frontier.py index d7b0604..7918d79 100644 --- a/tests/test_frontier.py +++ b/tests/test_frontier.py @@ -96,6 +96,50 @@ def test_hypervolume_of_an_empty_frontier_is_zero() -> None: assert dominated_hypervolume([], cost_reference=10.0) == 0.0 +def test_hypervolume_matches_the_area_computed_by_hand() -> None: + """A relative check passes even when the area is double what it should be. + + One point at cost 5 with a 0.2 violation rate, against a reference corner at cost 10, + dominates exactly the rectangle from cost 5 to 10 and violation 0.2 to 1.0 — an area of + 5 x 0.8 = 4.0. The previous implementation swept from cost zero, where nothing is + affordable, and returned 8.0. + """ + assert dominated_hypervolume([_point(5.0, 0.2)], cost_reference=10.0) == pytest.approx(4.0) + + staircase = [_point(2.0, 0.5), _point(5.0, 0.2), _point(8.0, 0.1)] + expected = (5 - 2) * (1 - 0.5) + (8 - 5) * (1 - 0.2) + (10 - 8) * (1 - 0.1) + assert dominated_hypervolume(staircase, cost_reference=10.0) == pytest.approx(expected) + + +def test_hypervolume_ranks_two_curves_the_way_the_area_does() -> None: + """The off-by-one strip could reverse a ranking, not merely inflate both sides.""" + reaches_low_but_dear = [_point(1.0, 0.9), _point(9.0, 0.05)] + cheap_and_middling = [_point(4.0, 0.5), _point(5.0, 0.45)] + assert dominated_hypervolume(cheap_and_middling, cost_reference=10.0) > dominated_hypervolume( + reaches_low_but_dear, cost_reference=10.0 + ) + + +def test_hypervolume_ignores_points_outside_the_reference_box() -> None: + """Only points strictly inside the reference corner contribute. + + Note what does *not* count as outside: a cheap point with a dreadful violation rate is + still non-dominated and still dominates a thin strip of its own. Excluding it would + understate the frontier, so the box test is against the reference corner alone. + """ + inside = [_point(5.0, 0.2)] + too_dear = _point(50.0, 0.01) + no_better_than_reference = _point(1.0, 1.0) + assert dominated_hypervolume( + [*inside, too_dear, no_better_than_reference], cost_reference=10.0 + ) == pytest.approx(dominated_hypervolume(inside, cost_reference=10.0)) + + # A cheap-but-bad point is inside the box and legitimately adds its own strip. + assert dominated_hypervolume([*inside, _point(1.0, 0.99)], cost_reference=10.0) == ( + pytest.approx(4.0 + (5 - 1) * (1 - 0.99)) + ) + + def test_parity_spec_states_the_budget_every_arm_was_held_to() -> None: spec = ParitySpec( configurations_per_controller=7, diff --git a/tests/test_simulator.py b/tests/test_simulator.py index fc12209..76780bc 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -1,9 +1,12 @@ """Degenerate, determinism, and actuation-delay checks for the replay simulator.""" +from pathlib import Path + import numpy as np import pytest from delphi.control.simulator import ( + ActuationTracker, CapacityProfile, CostModel, apply_actuation_delay, @@ -71,6 +74,34 @@ def test_a_continuously_changing_request_still_lands() -> None: assert apply_actuation_delay(settling, profile).tolist()[-1] == 6 +def test_tracker_reproduces_the_batch_actuation_delay() -> None: + """The incremental and batch forms must agree on every plan, including erratic ones.""" + profile = _profile(startup_seconds=180.0, teardown_seconds=60.0) + rng = np.random.default_rng(20260809) + for _ in range(200): + plan = np.clip(np.cumsum(rng.integers(-3, 4, size=60)) + 20, 1, None).astype(np.int64) + tracker = ActuationTracker(profile, initial=int(plan[0])) + incremental = [tracker.advance(step, int(plan[step])) for step in range(len(plan))] + assert incremental == apply_actuation_delay(plan, profile).tolist() + + +def test_only_the_simulator_implements_the_actuation_lag() -> None: + """No second copy of the lag rule anywhere in the package. + + C5/C6 previously re-derived it to pace themselves against their own past decisions. + When the clock-restart bug was fixed in `apply_actuation_delay`, that copy was left + stale, and the budget pacer spent every run correcting against a capacity trace frozen + at its initial replica count. The suite could not see it. This can. + """ + root = Path(__file__).resolve().parents[1] / "src" / "delphi" + offenders = [ + path.relative_to(root) + for path in root.rglob("*.py") + if path.name != "simulator.py" and "pending_at" in path.read_text() + ] + assert not offenders, f"actuation lag re-implemented outside simulator.py: {offenders}" + + def test_zero_actuation_delay_makes_the_plan_immediate() -> None: profile = _profile(startup_seconds=0.0, teardown_seconds=0.0) requested = np.asarray([1, 7, 2], dtype=np.int64)