mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:11:12 +00:00
59f56c032f
AZ-301 takeoff-side validator every InferenceRuntime strategy calls
before deserialize_engine. Five-step deterministic refusal pipeline,
in order:
1. filename schema parse -> EngineSchemaMismatchError(reason=...)
2. schema tuple match -> EngineSchemaMismatchError(expected,got)
3. sidecar present -> EngineSidecarMissingError
4. sidecar trust -> EngineHashMismatchError(stage=sidecar)
5. manifest match -> EngineHashMismatchError(stage=manifest)
Refusal order is part of the public contract (AC-7 verifies a
fixture that is BOTH schema-mismatched AND missing-sidecar refuses
at step 1).
Production code (new):
- components/c7_inference/engine_gate.py -- EngineGate, HostTuple,
read_host_tuple (Jetson: pynvml + /etc/nv_tegra_release +
tensorrt.__version__; raises RuntimeError on Tier-1)
- components/c7_inference/manifest.py -- DeploymentManifest,
ManifestReader, ManifestReaderProtocol. Risk-2 enforced at the
type level: __getitem__ raises EngineHashMismatchError on
missing key, NEVER KeyError, so the gate cannot silently pass
- components/c7_inference/__init__.py -- re-exports the new
public surface
Tests (new): tests/unit/c7_inference/test_engine_gate.py covers
AC-1..AC-7 + NFR-reliability-no-write + manifest reader + refusal
log emission. 14 tests unconditional + AC-8 Tier-2 skip (needs
real NVML + L4T release file + tensorrt binding).
Three task-spec -> as-built deltas documented in
_docs/02_tasks/done/AZ-301_c7_engine_gate.md Implementation Notes:
1. HostTuple lives in engine_gate.py (the only consumer);
re-exported from package __init__.py.
2. read_host_tuple takes precision as a keyword argument — three
of four fields come from the host, precision is engine-build
metadata supplied by the caller.
3. AC-8 is Tier-2-only; AC-1..AC-7 + NFR-reliability + extras
run on every CI host.
Risk-2 (manifest reader silently treats missing entry as pass):
DeploymentManifest.__getitem__ raises EngineHashMismatchError with
"missing manifest entry for {path}" — covered by
test_manifest_missing_entry_raises_hash_mismatch.
NFR-perf-validate (p99 <= 50 ms): tier-2 only — a real 500 MB
engine streaming sha256 cannot be benchmarked on Tier-1 fixtures.
AZ-302 (ThermalStatePublisher) + AZ-304 (C6 Postgres schema)
deferred to batches 26 / 27 to keep the 1-task batch cadence and
isolate their respective env / testcontainer surface areas.
Suite: 1134 passed / 11 skipped. No regressions outside the new
files.
Co-authored-by: Cursor <cursoragent@cursor.com>
316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""AZ-301 — ``EngineGate`` acceptance tests.
|
|
|
|
AC-1..AC-7 + NFR-reliability-no-write cover the five-step deterministic
|
|
refusal pipeline plus the happy path. AC-8 (``read_host_tuple`` on a
|
|
real Jetson) is Tier-2-only and lives in the integration suite — it
|
|
needs an actual NVML-readable GPU + ``/etc/nv_tegra_release``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.inference import EngineCacheEntry, PrecisionMode
|
|
from gps_denied_onboard.components.c7_inference import (
|
|
DeploymentManifest,
|
|
EngineGate,
|
|
EngineHashMismatchError,
|
|
EngineSchemaMismatchError,
|
|
EngineSidecarMissingError,
|
|
HostTuple,
|
|
ManifestReader,
|
|
)
|
|
|
|
_TIER2_HOST = HostTuple(sm=87, jp="6.2", trt="10.3", precision=PrecisionMode.FP16)
|
|
|
|
|
|
def _write_engine(tmp_path: Path, name: str, payload: bytes = b"fake-engine") -> Path:
|
|
engine_path = tmp_path / name
|
|
engine_path.write_bytes(payload)
|
|
return engine_path
|
|
|
|
|
|
def _write_sidecar(engine_path: Path, hex_digest: str | None = None) -> str:
|
|
if hex_digest is None:
|
|
hex_digest = hashlib.sha256(engine_path.read_bytes()).hexdigest()
|
|
sidecar = Path(str(engine_path) + ".sha256")
|
|
sidecar.write_text(hex_digest, encoding="utf-8")
|
|
return hex_digest
|
|
|
|
|
|
def _entry_for(engine_path: Path) -> EngineCacheEntry:
|
|
return EngineCacheEntry(
|
|
engine_path=engine_path,
|
|
sha256_hex=hashlib.sha256(engine_path.read_bytes()).hexdigest(),
|
|
sm=_TIER2_HOST.sm,
|
|
jp=_TIER2_HOST.jp,
|
|
trt=_TIER2_HOST.trt,
|
|
precision=PrecisionMode.FP16,
|
|
extras={},
|
|
)
|
|
|
|
|
|
def _manifest_with(engine_path: Path, sha_hex: str) -> DeploymentManifest:
|
|
relative = engine_path.relative_to(engine_path.parent)
|
|
return DeploymentManifest(
|
|
root=engine_path.parent,
|
|
entries={relative.as_posix(): sha_hex},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: filename-schema parse failure refused at parse time.
|
|
|
|
|
|
def test_ac1_parse_failure_refused_at_parse_time(tmp_path: Path) -> None:
|
|
# Arrange
|
|
bogus = _write_engine(tmp_path, "bogus_name.engine")
|
|
entry = _entry_for(bogus)
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineSchemaMismatchError, match="AZ-281 schema"):
|
|
gate.validate(entry, _TIER2_HOST, _manifest_with(bogus, "0" * 64))
|
|
assert not Path(str(bogus) + ".sha256").exists(), (
|
|
"Gate must NOT have created a sidecar during refusal"
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: filename-schema tuple mismatch refused at parse time.
|
|
|
|
|
|
def test_ac2_schema_tuple_mismatch(tmp_path: Path) -> None:
|
|
# Arrange — engine compiled for sm=86 but host is sm=87.
|
|
engine = _write_engine(tmp_path, "ultravpr__sm86_jp6.2_trt10.3_fp16.engine")
|
|
entry = _entry_for(engine)
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineSchemaMismatchError) as exc_info:
|
|
gate.validate(entry, _TIER2_HOST, _manifest_with(engine, "0" * 64))
|
|
msg = str(exc_info.value)
|
|
assert "sm=86" in msg
|
|
assert "sm=87" in msg
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: missing sidecar refused before manifest lookup.
|
|
|
|
|
|
def test_ac3_missing_sidecar_refused_before_manifest(tmp_path: Path) -> None:
|
|
# Arrange
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
entry = _entry_for(engine)
|
|
manifest_calls: list[Path] = []
|
|
|
|
class _TrackingManifest:
|
|
def read(self) -> DeploymentManifest:
|
|
manifest_calls.append(engine)
|
|
return _manifest_with(engine, "0" * 64)
|
|
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineSidecarMissingError, match=r"\.sha256"):
|
|
gate.validate(entry, _TIER2_HOST, _TrackingManifest())
|
|
# Manifest may be read at step 0 (validate prologue) but the missing-sidecar
|
|
# check refuses BEFORE manifest lookup; no manifest *__getitem__* call.
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: sidecar trust failure.
|
|
|
|
|
|
def test_ac4_sidecar_hash_mismatches_file(tmp_path: Path) -> None:
|
|
# Arrange
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
_write_sidecar(engine, hex_digest="d" * 64) # wrong hash
|
|
entry = _entry_for(engine)
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineHashMismatchError, match="sidecar"):
|
|
gate.validate(entry, _TIER2_HOST, _manifest_with(engine, "d" * 64))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: manifest mismatch.
|
|
|
|
|
|
def test_ac5_manifest_hash_mismatches_sidecar(tmp_path: Path) -> None:
|
|
# Arrange
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
true_hash = _write_sidecar(engine)
|
|
bogus_manifest = _manifest_with(engine, "f" * 64)
|
|
entry = _entry_for(engine)
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineHashMismatchError, match="manifest hash"):
|
|
gate.validate(entry, _TIER2_HOST, bogus_manifest)
|
|
assert "f" * 64 in str(true_hash) or true_hash != "f" * 64
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: full-success path returns silently and logs INFO.
|
|
|
|
|
|
def test_ac6_full_success_returns_silently_and_logs_pass(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
# Arrange
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
sha = _write_sidecar(engine)
|
|
manifest = _manifest_with(engine, sha)
|
|
entry = _entry_for(engine)
|
|
gate = EngineGate()
|
|
# Act
|
|
with caplog.at_level(logging.INFO, logger="c7_inference.gate"):
|
|
result = gate.validate(entry, _TIER2_HOST, manifest)
|
|
# Assert
|
|
assert result is None
|
|
pass_records = [
|
|
r for r in caplog.records if getattr(r, "kind", None) == "c7.gate.pass"
|
|
]
|
|
assert len(pass_records) == 1, (
|
|
f"expected exactly one c7.gate.pass record; got {len(pass_records)} "
|
|
f"of {[r.message for r in caplog.records]}"
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: refusal order is deterministic — schema wins over sidecar-missing.
|
|
|
|
|
|
def test_ac7_schema_error_wins_over_sidecar_missing(tmp_path: Path) -> None:
|
|
# Arrange — both broken: schema mismatch AND no sidecar on disk.
|
|
engine = _write_engine(tmp_path, "bogus.engine")
|
|
entry = _entry_for(engine)
|
|
gate = EngineGate()
|
|
# Act / Assert
|
|
with pytest.raises(EngineSchemaMismatchError):
|
|
gate.validate(entry, _TIER2_HOST, _manifest_with(engine, "0" * 64))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Manifest reader coverage.
|
|
|
|
|
|
def test_manifest_reader_round_trip(tmp_path: Path) -> None:
|
|
manifest_path = tmp_path / "manifest.json"
|
|
payload = {
|
|
"root": str(tmp_path),
|
|
"entries": {
|
|
"ultravpr__sm87_jp6.2_trt10.3_fp16.engine": "a" * 64,
|
|
"lightglue__sm87_jp6.2_trt10.3_int8.engine": "b" * 64,
|
|
},
|
|
}
|
|
manifest_path.write_text(json.dumps(payload), encoding="utf-8")
|
|
manifest = ManifestReader(manifest_path).read()
|
|
assert manifest.root == tmp_path
|
|
assert manifest["ultravpr__sm87_jp6.2_trt10.3_fp16.engine"] == "a" * 64
|
|
assert "lightglue__sm87_jp6.2_trt10.3_int8.engine" in manifest
|
|
|
|
|
|
def test_manifest_missing_entry_raises_hash_mismatch() -> None:
|
|
"""Risk 2 — missing manifest entry MUST raise EngineHashMismatchError, NOT KeyError."""
|
|
manifest = DeploymentManifest(root=Path("/var/lib/x"), entries={})
|
|
with pytest.raises(EngineHashMismatchError, match="missing manifest entry"):
|
|
_ = manifest["unknown.engine"]
|
|
|
|
|
|
def test_manifest_reader_rejects_malformed_json(tmp_path: Path) -> None:
|
|
bad = tmp_path / "manifest.json"
|
|
bad.write_text("not json {", encoding="utf-8")
|
|
with pytest.raises(EngineHashMismatchError, match="not valid JSON"):
|
|
ManifestReader(bad).read()
|
|
|
|
|
|
def test_manifest_reader_rejects_missing_entries_key(tmp_path: Path) -> None:
|
|
bad = tmp_path / "manifest.json"
|
|
bad.write_text(json.dumps({"root": "/x"}), encoding="utf-8")
|
|
with pytest.raises(EngineHashMismatchError, match="entries"):
|
|
ManifestReader(bad).read()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Engine-outside-manifest-root path.
|
|
|
|
|
|
def test_engine_outside_manifest_root_refused(tmp_path: Path) -> None:
|
|
# Arrange — engine lives under tmp_path but manifest root is a sibling dir.
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
sha = _write_sidecar(engine)
|
|
sibling = tmp_path / "elsewhere"
|
|
sibling.mkdir()
|
|
manifest = DeploymentManifest(
|
|
root=sibling,
|
|
entries={"ultravpr__sm87_jp6.2_trt10.3_fp16.engine": sha},
|
|
)
|
|
gate = EngineGate()
|
|
with pytest.raises(EngineHashMismatchError, match="not under manifest root"):
|
|
gate.validate(_entry_for(engine), _TIER2_HOST, manifest)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# NFR-reliability — no writes by the gate.
|
|
|
|
|
|
def test_nfr_reliability_no_writes(tmp_path: Path) -> None:
|
|
"""The gate is read-only — it must not touch the engine, sidecar, or manifest."""
|
|
engine = _write_engine(tmp_path, "ultravpr__sm87_jp6.2_trt10.3_fp16.engine")
|
|
sha = _write_sidecar(engine)
|
|
sidecar = Path(str(engine) + ".sha256")
|
|
snapshot = {
|
|
"engine_mtime": engine.stat().st_mtime,
|
|
"engine_bytes": engine.read_bytes(),
|
|
"sidecar_mtime": sidecar.stat().st_mtime,
|
|
"sidecar_text": sidecar.read_text(encoding="utf-8"),
|
|
}
|
|
manifest = _manifest_with(engine, sha)
|
|
EngineGate().validate(_entry_for(engine), _TIER2_HOST, manifest)
|
|
assert engine.stat().st_mtime == snapshot["engine_mtime"]
|
|
assert engine.read_bytes() == snapshot["engine_bytes"]
|
|
assert sidecar.stat().st_mtime == snapshot["sidecar_mtime"]
|
|
assert sidecar.read_text(encoding="utf-8") == snapshot["sidecar_text"]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Refusal log on error path.
|
|
|
|
|
|
def test_refusal_emits_error_log(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
engine = _write_engine(tmp_path, "bogus.engine")
|
|
gate = EngineGate()
|
|
with caplog.at_level(logging.ERROR, logger="c7_inference.gate"):
|
|
with pytest.raises(EngineSchemaMismatchError):
|
|
gate.validate(_entry_for(engine), _TIER2_HOST, _manifest_with(engine, "0" * 64))
|
|
refusals = [
|
|
r for r in caplog.records if getattr(r, "kind", None) == "c7.gate.refuse"
|
|
]
|
|
assert len(refusals) == 1
|
|
assert refusals[0].kv["step"] == "schema_parse" # type: ignore[attr-defined]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8 host-tuple read (Tier-2 hardware only).
|
|
|
|
|
|
@pytest.mark.tier2
|
|
@pytest.mark.skipif(
|
|
os.environ.get("GPS_DENIED_TIER") != "2",
|
|
reason="read_host_tuple needs real Jetson hardware + NVML + L4T release file",
|
|
)
|
|
def test_ac8_read_host_tuple_on_jetson() -> None:
|
|
from gps_denied_onboard.components.c7_inference.engine_gate import read_host_tuple
|
|
|
|
host = read_host_tuple(precision=PrecisionMode.FP16)
|
|
assert host.sm == 87
|
|
assert host.jp == "6.2"
|
|
assert host.trt == "10.3"
|
|
assert host.precision is PrecisionMode.FP16
|