mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 23:01:13 +00:00
6e4a575221
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>
203 lines
6.7 KiB
Python
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
|