mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:51:12 +00:00
fd52cc9b1d
Cycle-3 refactor run 02-az507 (RouteSpec relocation + module-layout
refresh + AZ-270 lint widening). Single batch of 3 tasks; epic AZ-844.
AZ-845 — Relocate RouteSpec DTO to _types/route.py (rule-9 fix):
* New canonical home: src/gps_denied_onboard/_types/route.py
(frozen+slots dataclass; full docstring carried over verbatim).
* c11_tile_manager/route_client.py imports from _types.route.
* replay_input/tlog_route.py and replay_input/__init__.py keep
re-exports for backward-compat (RouteSpec in __all__).
* 5 test files updated to import from _types.route for symmetry.
* Identity-preserving re-export verified by new test
test_az845_routespec_canonical_home_and_reexport_identity.
AZ-846 — Refresh module-layout.md cycle-3 entries:
* c11_tile_manager Internal list rewritten with all 8 internals
(alphabetised) — corrects a stale entry that referenced files
(satellite_provider_*.py) that no longer exist.
* shared/replay_input file list adds errors.py (cycle-2 carry),
tlog_ground_truth.py (cycle-2 carry), tlog_route.py (cycle-3 NEW).
* shared/_types section registers route.py with provenance line.
* Out-of-scope cycle-2 carry-overs (replay_api/, cli/render_map.py,
helpers/gps_compare.py, etc.) intentionally untouched.
AZ-847 — Widen test_az270 lint to enforce full rule-9 allow-list:
* test_ac6_only_compose_root_imports_concrete_strategies now walks
every components/<X>/*.py ImportFrom/Import and rejects anything
not in the rule-9 allow-list (own subpackage + _types + helpers
+ config/logging/fdr_client/clock + frame_source interface-only).
* Strict superset of the original AC-6 narrow check.
* Reports zero violations on the codebase post-AZ-845.
* Two principled carve-outs documented in the test docstring:
- components/<X>/bench/** path skip (measurement code legitimately
constructs production strategies via runtime_root factories).
- register_* lazy self-registration imports from
runtime_root.<X>_factory (central-registry plugin pattern).
* Both carve-outs surfaced to user via Choose A/B/C/D Risk-1
protocol; user skipped both — agent proceeded with documented
defaults. Doc-only follow-up tracked in
_docs/_process_leftovers/2026-05-24_az847_rule9_wording_followup.md
for rule-9 wording update in module-layout.md.
Test results: 2287 passed, 90 skipped (environmental — Docker / CUDA
/ TensorRT / Jetson hardware / fixtures), 0 failed. Focused subset
(replay_input/ + c11_tile_manager/ + test_az270_compose_root.py)
also clean: 169 passed, 1 skipped.
Tracker: AZ-845/846/847 transitioned In Progress -> In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""AZ-270 — Composition Root AC tests.
|
|
|
|
Verifies the contract at ``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gps_denied_onboard.config import Config
|
|
from gps_denied_onboard.runtime_root import (
|
|
RuntimeRoot,
|
|
StrategyNotLinkedError,
|
|
clear_strategy_registry,
|
|
compose_operator,
|
|
compose_root,
|
|
list_registered_strategies,
|
|
register_strategy,
|
|
)
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
# AZ-847 — full rule-9 allow-list (single source of truth for the widened
|
|
# lint below). Mirrors `_docs/02_document/module-layout.md` rule 9
|
|
# "AZ-507 cross-component contract surface".
|
|
_RULE_9_ALLOW_EXACT_MODULES: frozenset[str] = frozenset(
|
|
{
|
|
"gps_denied_onboard._types",
|
|
"gps_denied_onboard.helpers",
|
|
"gps_denied_onboard.clock",
|
|
"gps_denied_onboard.config",
|
|
"gps_denied_onboard.logging",
|
|
"gps_denied_onboard.fdr_client",
|
|
# frame_source: interface-only per rule 9. The package root is allowed
|
|
# because its `__init__.py` re-exports only the interface; concrete
|
|
# implementations live in `frame_source.live_camera` /
|
|
# `frame_source.video_file` and MUST be reached via DI from the
|
|
# composition root, never imported by a Layer-3 component directly.
|
|
"gps_denied_onboard.frame_source",
|
|
"gps_denied_onboard.frame_source.interface",
|
|
}
|
|
)
|
|
_RULE_9_ALLOW_PACKAGE_PREFIXES: tuple[str, ...] = (
|
|
"gps_denied_onboard._types.",
|
|
"gps_denied_onboard.helpers.",
|
|
"gps_denied_onboard.clock.",
|
|
"gps_denied_onboard.config.",
|
|
"gps_denied_onboard.logging.",
|
|
"gps_denied_onboard.fdr_client.",
|
|
)
|
|
|
|
|
|
def _is_rule9_allowed_import(module: str, *, own_component: str) -> bool:
|
|
"""Apply the rule-9 allow-list to a `gps_denied_onboard.*` import string.
|
|
|
|
Returns True iff `module` matches one of: (1) the importing component's
|
|
own subpackage, (2) a documented allow-list leaf module, (3) a documented
|
|
allow-list package prefix.
|
|
"""
|
|
own_pkg = f"gps_denied_onboard.components.{own_component}"
|
|
if module == own_pkg or module.startswith(own_pkg + "."):
|
|
return True
|
|
if module in _RULE_9_ALLOW_EXACT_MODULES:
|
|
return True
|
|
return any(module.startswith(p) for p in _RULE_9_ALLOW_PACKAGE_PREFIXES)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _C1Block:
|
|
strategy: str = "okvis2"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _C4Block:
|
|
strategy: str = "opencv_gtsam"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _C5Block:
|
|
strategy: str = "gtsam_isam2"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _C11Block:
|
|
strategy: str = "ardupilot_tile_manager"
|
|
|
|
|
|
@dataclass
|
|
class _OrderRecorder:
|
|
constructed: list[str]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_registry() -> Iterator[None]:
|
|
"""Reset the strategy registry around every test."""
|
|
clear_strategy_registry()
|
|
yield
|
|
clear_strategy_registry()
|
|
|
|
|
|
@pytest.fixture
|
|
def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name, value in (
|
|
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
|
("GPS_DENIED_TIER", "1"),
|
|
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
|
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
|
("LOG_LEVEL", "INFO"),
|
|
("LOG_SINK", "console"),
|
|
("INFERENCE_BACKEND", "pytorch_fp16"),
|
|
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
|
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
|
("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"),
|
|
):
|
|
monkeypatch.setenv(name, value)
|
|
|
|
|
|
@pytest.fixture
|
|
def _operator_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name, value in (
|
|
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
|
("GPS_DENIED_TIER", "1"),
|
|
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
|
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
|
("LOG_LEVEL", "INFO"),
|
|
("LOG_SINK", "console"),
|
|
("INFERENCE_BACKEND", "pytorch_fp16"),
|
|
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
|
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
|
("SATELLITE_PROVIDER_URL", "http://localhost:8080"),
|
|
):
|
|
monkeypatch.setenv(name, value)
|
|
|
|
|
|
def _register_okvis2(recorder: _OrderRecorder) -> None:
|
|
def factory(config: Config, components: dict[str, object]) -> object:
|
|
recorder.constructed.append("c1_vio")
|
|
return ("c1_vio", "okvis2")
|
|
|
|
register_strategy("c1_vio", "okvis2", factory, tier="airborne")
|
|
|
|
|
|
def _register_c4(recorder: _OrderRecorder) -> None:
|
|
def factory(config: Config, components: dict[str, object]) -> object:
|
|
recorder.constructed.append("c4_pose")
|
|
return ("c4_pose", "opencv_gtsam")
|
|
|
|
register_strategy("c4_pose", "opencv_gtsam", factory, tier="airborne")
|
|
|
|
|
|
def _register_c5(recorder: _OrderRecorder) -> None:
|
|
def factory(config: Config, components: dict[str, object]) -> object:
|
|
assert "c1_vio" in components, "c5_state factory ran before c1_vio existed"
|
|
assert "c4_pose" in components, "c5_state factory ran before c4_pose existed"
|
|
recorder.constructed.append("c5_state")
|
|
return ("c5_state", "gtsam_isam2")
|
|
|
|
register_strategy(
|
|
"c5_state",
|
|
"gtsam_isam2",
|
|
factory,
|
|
tier="airborne",
|
|
depends_on=("c1_vio", "c4_pose"),
|
|
)
|
|
|
|
|
|
def test_ac1_default_deployment_composes(_airborne_env: None) -> None:
|
|
recorder = _OrderRecorder(constructed=[])
|
|
_register_okvis2(recorder)
|
|
_register_c4(recorder)
|
|
_register_c5(recorder)
|
|
config = Config.with_blocks(
|
|
c1_vio=_C1Block(),
|
|
c4_pose=_C4Block(),
|
|
c5_state=_C5Block(),
|
|
)
|
|
root = compose_root(config)
|
|
assert isinstance(root, RuntimeRoot)
|
|
assert root.binary == "airborne"
|
|
assert set(root.components.keys()) == {"c1_vio", "c4_pose", "c5_state"}
|
|
|
|
|
|
def test_ac2_strategy_not_linked_raises_with_payload(_airborne_env: None) -> None:
|
|
# Only okvis2 is registered; config asks for vins_mono.
|
|
recorder = _OrderRecorder(constructed=[])
|
|
_register_okvis2(recorder)
|
|
config = Config.with_blocks(c1_vio=_C1Block(strategy="vins_mono"))
|
|
with pytest.raises(StrategyNotLinkedError) as info:
|
|
compose_root(config)
|
|
assert info.value.strategy_name == "vins_mono"
|
|
assert info.value.component_slug == "c1_vio"
|
|
assert info.value.available_strategies == ["okvis2"]
|
|
|
|
|
|
def test_ac3_operator_excludes_airborne_only(_operator_env: None) -> None:
|
|
# c1_vio is registered as airborne; an operator config that references it must fail.
|
|
recorder = _OrderRecorder(constructed=[])
|
|
_register_okvis2(recorder)
|
|
config = Config.with_blocks(c1_vio=_C1Block())
|
|
with pytest.raises(StrategyNotLinkedError) as info:
|
|
compose_operator(config)
|
|
assert info.value.component_slug == "c1_vio"
|
|
assert "airborne" in info.value.reason or "tier" in info.value.reason
|
|
|
|
|
|
def test_ac4_runtime_root_smoke_exit_zero(_airborne_env: None) -> None:
|
|
# A Config with no component blocks must compose cleanly (every required
|
|
# component is hard-wired by its bootstrap; no strategy to resolve).
|
|
config = Config()
|
|
root = compose_root(config)
|
|
assert isinstance(root, RuntimeRoot)
|
|
assert root.components == {}
|
|
|
|
|
|
def test_ac5_construction_order_respects_dependencies(_airborne_env: None) -> None:
|
|
recorder = _OrderRecorder(constructed=[])
|
|
# Register in reverse order to make the topological pass non-trivial.
|
|
_register_c5(recorder)
|
|
_register_c4(recorder)
|
|
_register_okvis2(recorder)
|
|
config = Config.with_blocks(
|
|
c1_vio=_C1Block(),
|
|
c4_pose=_C4Block(),
|
|
c5_state=_C5Block(),
|
|
)
|
|
root = compose_root(config)
|
|
# Dependencies must construct strictly before dependents.
|
|
assert recorder.constructed.index("c1_vio") < recorder.constructed.index("c5_state")
|
|
assert recorder.constructed.index("c4_pose") < recorder.constructed.index("c5_state")
|
|
assert root.construction_order[-1] == "c5_state"
|
|
|
|
|
|
def test_ac6_only_compose_root_imports_concrete_strategies() -> None:
|
|
"""Architecture lint — full rule-9 allow-list enforcement (AZ-847 widening).
|
|
|
|
Source of truth: ``_docs/02_document/module-layout.md`` rule 9
|
|
"AZ-507 cross-component contract surface". For every
|
|
``components/<X>/*.py`` file, an ``ImportFrom`` or ``Import`` node
|
|
referring to a ``gps_denied_onboard.*`` module is allowed iff it
|
|
matches one of:
|
|
|
|
1. The component's own subpackage
|
|
(``gps_denied_onboard.components.<X>`` or ``...<X>.*``).
|
|
2. ``gps_denied_onboard._types`` or any submodule (incl.
|
|
``_types.inference_errors``, ``_types.route``, etc.).
|
|
3. ``gps_denied_onboard.helpers`` or any submodule.
|
|
4. ``gps_denied_onboard.config`` / ``logging`` / ``fdr_client`` /
|
|
``clock``, or any submodule of those packages.
|
|
5. ``gps_denied_onboard.frame_source`` or
|
|
``gps_denied_onboard.frame_source.interface`` only.
|
|
|
|
AST-level disambiguation (Risk 2 of AZ-847): rule 9 limits
|
|
``frame_source`` to interface-only, but the package's ``__init__.py``
|
|
re-exports only the interface symbols, so allow-listing the package
|
|
root is safe. Concrete implementations
|
|
(``frame_source.live_camera`` / ``frame_source.video_file``) and the
|
|
error envelope (``frame_source.errors``) are intentionally NOT in
|
|
the allow-list — they must be reached via constructor injection
|
|
from the composition root, never imported by a Layer-3 component.
|
|
|
|
Bench-code disambiguation: ``components/<X>/bench/**`` files are
|
|
skipped because benchmark / measurement code legitimately
|
|
constructs production strategies via ``runtime_root.*`` factories
|
|
(that is its job). Rule 9 governs production runtime code, not
|
|
measurement harnesses. Surfaced to the user during AZ-847
|
|
implementation as a discovered architectural decision; documented
|
|
here as a principled exclusion. A separate doc-only follow-up will
|
|
propagate this exclusion into the rule-9 wording in
|
|
``module-layout.md``.
|
|
|
|
Self-registration carve-out: components plug into the central
|
|
strategy registry by lazy-importing ``register_<X>_*`` helpers from
|
|
``gps_denied_onboard.runtime_root.<X>_factory`` inside their
|
|
``register()`` function (e.g.
|
|
``c5_state.gtsam_isam2_estimator.register`` →
|
|
``runtime_root.state_factory.register_state_estimator``). This is
|
|
the registry pattern, NOT cross-component coupling. The lint
|
|
permits exactly this case: an ``ImportFrom`` whose module starts
|
|
with ``gps_denied_onboard.runtime_root.`` AND every imported name
|
|
starts with ``register_``. Anything else from ``runtime_root.*``
|
|
(e.g. ``build_*`` factories, ``compose_root``,
|
|
``StrategyNotLinkedError``) inside a Layer-3 component remains a
|
|
violation. Surfaced as a discovered architectural decision during
|
|
AZ-847; same doc-only follow-up that updates rule-9 wording for
|
|
bench/ will also document the self-registration carve-out.
|
|
|
|
AZ-270 audit-trail compatibility (AC-2): this lint is a strict
|
|
superset of the original AZ-270 AC-6 narrow check (cross-component
|
|
imports). Every edge previously flagged is still flagged, plus new
|
|
edges into ``replay_input``, ``replay_api``, ``cli/*``,
|
|
non-allow-listed ``frame_source`` submodules, and any
|
|
``runtime_root.*`` import that is NOT a ``register_*`` self-
|
|
registration helper.
|
|
"""
|
|
components_root = _REPO_ROOT / "src" / "gps_denied_onboard" / "components"
|
|
violations: list[str] = []
|
|
for module_path in components_root.rglob("*.py"):
|
|
relative_parts = module_path.relative_to(components_root).parts
|
|
if "bench" in relative_parts:
|
|
continue
|
|
own_component = relative_parts[0]
|
|
try:
|
|
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
|
except SyntaxError:
|
|
continue
|
|
for node in ast.walk(tree):
|
|
module_names: tuple[str, ...]
|
|
if isinstance(node, ast.ImportFrom) and node.module:
|
|
if (
|
|
node.module.startswith("gps_denied_onboard.runtime_root.")
|
|
and node.names
|
|
and all(alias.name.startswith("register_") for alias in node.names)
|
|
):
|
|
continue
|
|
module_names = (node.module,)
|
|
elif isinstance(node, ast.Import):
|
|
module_names = tuple(alias.name for alias in node.names)
|
|
else:
|
|
continue
|
|
for name in module_names:
|
|
if not name.startswith("gps_denied_onboard."):
|
|
continue
|
|
if _is_rule9_allowed_import(name, own_component=own_component):
|
|
continue
|
|
violations.append(
|
|
f"{module_path.relative_to(_REPO_ROOT)} imports {name} "
|
|
"(rule 9 allow-list violation)"
|
|
)
|
|
assert not violations, (
|
|
"components/**/*.py imports MUST stay within the rule-9 allow-list "
|
|
"(see _docs/02_document/module-layout.md rule 9 — AZ-507 "
|
|
"cross-component contract surface). Violations:\n - "
|
|
+ "\n - ".join(violations)
|
|
)
|
|
|
|
|
|
def test_nfr_reliability_partial_construction_closed_on_failure(_airborne_env: None) -> None:
|
|
closed: list[str] = []
|
|
|
|
class _Closable:
|
|
def __init__(self, slug: str) -> None:
|
|
self.slug = slug
|
|
|
|
def close(self) -> None:
|
|
closed.append(self.slug)
|
|
|
|
def good_factory(config: Config, components: dict[str, object]) -> _Closable:
|
|
return _Closable("c1_vio")
|
|
|
|
def failing_factory(config: Config, components: dict[str, object]) -> object:
|
|
raise RuntimeError("boom from c5_state factory")
|
|
|
|
register_strategy("c1_vio", "okvis2", good_factory, tier="airborne")
|
|
register_strategy(
|
|
"c5_state",
|
|
"gtsam_isam2",
|
|
failing_factory,
|
|
tier="airborne",
|
|
depends_on=("c1_vio",),
|
|
)
|
|
config = Config.with_blocks(c1_vio=_C1Block(), c5_state=_C5Block())
|
|
with pytest.raises(RuntimeError, match=r"boom"):
|
|
compose_root(config)
|
|
assert closed == ["c1_vio"], "prior instances must be .close()d on mid-composition failure"
|
|
|
|
|
|
def test_list_registered_strategies_returns_sorted_names() -> None:
|
|
register_strategy("c1_vio", "okvis2", lambda c, m: None, tier="airborne")
|
|
register_strategy("c1_vio", "vins_mono", lambda c, m: None, tier="airborne")
|
|
register_strategy("c2_vpr", "netvlad", lambda c, m: None, tier="airborne")
|
|
assert list_registered_strategies("c1_vio") == ["okvis2", "vins_mono"]
|
|
assert list_registered_strategies("c2_vpr") == ["netvlad"]
|
|
assert list_registered_strategies("c99_unknown") == []
|