Files
gps-denied-onboard/e2e/runner/helpers/storage_budget_evaluator.py
T
Oleksandr Bezdieniezhnykh 6e4a575221 [AZ-440] [AZ-441] [AZ-442] [AZ-443] NFT-LIM-01/02/03+05/04 blackbox scenarios
Batch 88 — adds four resource-limit blackbox scenarios + pure-logic
helpers + unit tests:

- NFT-LIM-01 Jetson memory (AC-NEW-13): tier2_only; Plan A/B budgets;
  AC-4 OOM-event scan; 30 s warm-up window; VmRSS + tegrastats streams.
- NFT-LIM-02 FDR size (AC-7.3): 30 min → 8 h linear extrapolation
  against 50 GiB; ±60 s replay-window slack for AC-1.
- NFT-LIM-03+05 storage (AC-7.4 + AC-NEW-12 + RESTRICT-STORAGE):
  aggregate ≤ 100 GiB across tile-cache + tile-cache-write +
  fdr-output; thumbnail-log < 1 GiB strict 8 h-extrapolated.
- NFT-LIM-04 thermal (AC-NEW-5 PARTIAL): tier2_only; CPU/SoC p99
  ≤ T_throttle − 5 °C; throttle-event scan; PARTIAL annotation written
  to traceability-status.json. Thresholds fixture lives at
  e2e/fixtures/jetson/thermal-thresholds.json (moved from the
  task spec's suggested tests/fixtures/ path so the file stays
  inside the blackbox_tests Owns: e2e/** envelope).

All four helpers are public-boundary-only (no src/gps_denied_onboard
imports). Scenarios skip cleanly in the Tier-1 docker harness pending
AZ-595 (SITL replay builder) for the four shared fixture inputs and
AZ-444 (Tier-2 Jetson runner) for the tier2_only scenarios.

Code review: PASS_WITH_WARNINGS (0/0/2/1). Both Mediums are
carried-over write_csv_evidence + _resolve_fixture_path duplication,
deferred to AZ-446 (batch 89). Low is the self-resolved AZ-443 fixture
ownership drift documented in the review.

Tests: 1223 e2e/_unit_tests passing (+1 vs. batch 87 from the new
directory-layout entry); 24 resource_limit scenarios collect and skip
cleanly under runner/pytest.ini.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:01:55 +03:00

203 lines
6.7 KiB
Python

"""Aggregate storage + thumbnail-log budget evaluator for NFT-LIM-03/05
(AZ-442 / AC-7.4 + AC-NEW-12 + RESTRICT-STORAGE).
The two scenarios share the same 30 min Derkachi replay and the same
per-minute ``du -sh`` sampling. NFT-LIM-03 caps the *aggregate* of
three volumes at ``100 GiB`` (end-of-run snapshot); NFT-LIM-05
extrapolates the thumbnail-log subdirectory linearly to 8 h and caps
it at ``1 GiB``.
The runner projects each per-minute sample into a
``VolumeSnapshot`` carrying the four monitored sizes at one timestamp.
This module evaluates the AC-1 (aggregate) + AC-2 (8 h thumbnail-log
extrapolation) verdicts from a ``Sequence[VolumeSnapshot]``.
Public-boundary discipline: does NOT import any
``src/gps_denied_onboard`` symbol — inputs are pre-projected typed
samples.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
GIB_BYTES = 1024**3
REPLAY_WINDOW_MINUTES = 30
EXTRAPOLATION_WINDOW_MINUTES = 8 * 60
AGGREGATE_BUDGET_BYTES = 100 * GIB_BYTES # AC-1 — ≤ 100 GiB
THUMBNAIL_LOG_BUDGET_BYTES = 1 * GIB_BYTES # AC-2 — < 1 GiB 8 h-extrapolated
@dataclass(frozen=True)
class VolumeSnapshot:
"""One per-minute ``du -sh`` snapshot for the four monitored volumes."""
monotonic_ms: int
tile_cache_bytes: int
tile_cache_write_bytes: int
fdr_output_bytes: int
thumbnail_log_bytes: int
@property
def aggregate_bytes(self) -> int:
return (
self.tile_cache_bytes
+ self.tile_cache_write_bytes
+ self.fdr_output_bytes
)
@dataclass(frozen=True)
class StorageBudgetReport:
"""Aggregate AC-1 + AC-2 verdict for one NFT-LIM-03+05 run."""
sample_count: int
aggregate_at_end_bytes: int | None
thumbnail_log_at_end_bytes: int | None
thumbnail_log_extrapolated_8h_bytes: int | None
aggregate_budget_bytes: int
thumbnail_log_budget_bytes: int
@property
def passes_aggregate(self) -> bool:
# AC-1 — end-of-run aggregate snapshot ≤ budget.
return (
self.aggregate_at_end_bytes is not None
and self.aggregate_at_end_bytes <= self.aggregate_budget_bytes
)
@property
def passes_thumbnail_log(self) -> bool:
# AC-2 — extrapolated 8 h thumbnail-log < budget. Strict ``<``
# because AC-2 says ``< 1 GB`` (not ``≤``).
return (
self.thumbnail_log_extrapolated_8h_bytes is not None
and self.thumbnail_log_extrapolated_8h_bytes
< self.thumbnail_log_budget_bytes
)
@property
def passes(self) -> bool:
return self.passes_aggregate and self.passes_thumbnail_log
def evaluate(
samples: Sequence[VolumeSnapshot],
*,
aggregate_budget_bytes: int = AGGREGATE_BUDGET_BYTES,
thumbnail_log_budget_bytes: int = THUMBNAIL_LOG_BUDGET_BYTES,
) -> StorageBudgetReport:
"""Compute AC-1 + AC-2 verdict from a snapshot stream."""
if aggregate_budget_bytes <= 0:
raise ValueError(
f"aggregate_budget_bytes must be > 0 (was {aggregate_budget_bytes!r})"
)
if thumbnail_log_budget_bytes <= 0:
raise ValueError(
f"thumbnail_log_budget_bytes must be > 0 "
f"(was {thumbnail_log_budget_bytes!r})"
)
if not samples:
return StorageBudgetReport(
sample_count=0,
aggregate_at_end_bytes=None,
thumbnail_log_at_end_bytes=None,
thumbnail_log_extrapolated_8h_bytes=None,
aggregate_budget_bytes=aggregate_budget_bytes,
thumbnail_log_budget_bytes=thumbnail_log_budget_bytes,
)
ordered = sorted(samples, key=lambda s: s.monotonic_ms)
last = ordered[-1]
extrapolated_thumb = int(
round(
(last.thumbnail_log_bytes / REPLAY_WINDOW_MINUTES)
* EXTRAPOLATION_WINDOW_MINUTES
)
)
return StorageBudgetReport(
sample_count=len(ordered),
aggregate_at_end_bytes=last.aggregate_bytes,
thumbnail_log_at_end_bytes=last.thumbnail_log_bytes,
thumbnail_log_extrapolated_8h_bytes=extrapolated_thumb,
aggregate_budget_bytes=aggregate_budget_bytes,
thumbnail_log_budget_bytes=thumbnail_log_budget_bytes,
)
def write_csv_evidence(out_path: Path, report: StorageBudgetReport) -> Path:
"""One-row evidence file naming the AC-1/AC-2 verdict + sizes."""
out_path.parent.mkdir(parents=True, exist_ok=True)
r = report
with out_path.open("w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(
[
"sample_count",
"aggregate_at_end_bytes",
"thumbnail_log_at_end_bytes",
"thumbnail_log_extrapolated_8h_bytes",
"aggregate_budget_bytes",
"thumbnail_log_budget_bytes",
"ac1_aggregate_passes",
"ac2_thumbnail_log_passes",
"passes",
]
)
writer.writerow(
[
r.sample_count,
"" if r.aggregate_at_end_bytes is None else r.aggregate_at_end_bytes,
""
if r.thumbnail_log_at_end_bytes is None
else r.thumbnail_log_at_end_bytes,
""
if r.thumbnail_log_extrapolated_8h_bytes is None
else r.thumbnail_log_extrapolated_8h_bytes,
r.aggregate_budget_bytes,
r.thumbnail_log_budget_bytes,
"true" if r.passes_aggregate else "false",
"true" if r.passes_thumbnail_log else "false",
"true" if r.passes else "false",
]
)
return out_path
def write_per_minute_csv(
out_path: Path, samples: Sequence[VolumeSnapshot]
) -> Path:
"""Per-sample CSV (one row per minute) for evidence trend lines."""
out_path.parent.mkdir(parents=True, exist_ok=True)
ordered = sorted(samples, key=lambda s: s.monotonic_ms)
with out_path.open("w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(
[
"index",
"monotonic_ms",
"tile_cache_bytes",
"tile_cache_write_bytes",
"fdr_output_bytes",
"thumbnail_log_bytes",
"aggregate_bytes",
]
)
for i, s in enumerate(ordered):
writer.writerow(
[
i,
s.monotonic_ms,
s.tile_cache_bytes,
s.tile_cache_write_bytes,
s.fdr_output_bytes,
s.thumbnail_log_bytes,
s.aggregate_bytes,
]
)
return out_path