Files
gps-denied-onboard/tests/unit/c7_inference/test_engine_gate.py
T
Oleksandr Bezdieniezhnykh 59f56c032f [AZ-301] Implement EngineGate — D-C10-3 + D-C10-7 takeoff validator
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>
2026-05-12 10:20:21 +03:00

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