diff --git a/_docs/02_tasks/todo/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md b/_docs/02_tasks/done/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md similarity index 100% rename from _docs/02_tasks/todo/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md rename to _docs/02_tasks/done/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 62baeb5..51878b2 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -6,10 +6,10 @@ step: 7 name: Implement status: in_progress sub_step: - phase: 1 - name: parse - detail: "AZ-618 split into AZ-619..AZ-624 (subtasks of AZ-618; epic AZ-602). Next batch = AZ-619 (Phase A: c13_fdr + clock, 2pt). AZ-618 stays In Progress as umbrella; subtasks are To Do." + phase: 12 + name: archive + detail: "AZ-619 (Phase A) implemented + committed + tests green (12/12 incl. AZ-591 regression). Spec moved to done/. AZ-619 transitioned In Testing. Next batch = AZ-620 (Phase B: c6_descriptor_index + c6_tile_store, 3pt). Recommended session boundary here per 2026-05-18 lesson — fresh session picks up cleanly with AZ-620." retry_count: 0 cycle: 1 tracker: jira -last_completed_batch: 89 +last_completed_batch: 90 diff --git a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py index a40837a..494e144 100644 --- a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py +++ b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py @@ -51,8 +51,10 @@ from __future__ import annotations import logging import os from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final +from gps_denied_onboard.clock.wall_clock import WallClock +from gps_denied_onboard.fdr_client.client import make_fdr_client from gps_denied_onboard.runtime_root import register_strategy from gps_denied_onboard.runtime_root.matcher_factory import build_matcher_strategy from gps_denied_onboard.runtime_root.pose_factory import build_pose_estimator @@ -66,12 +68,27 @@ if TYPE_CHECKING: from gps_denied_onboard.config import Config __all__ = [ + "AIRBORNE_MAIN_PRODUCER_ID", "AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS", "AirborneBootstrapError", + "build_pre_constructed", "register_airborne_strategies", ] +AIRBORNE_MAIN_PRODUCER_ID: Final[str] = "airborne_main" +"""Producer ID for the per-binary shared FdrClient placed under +``pre_constructed['c13_fdr']``. + +Per-component callers can still obtain their own FdrClient via +``make_fdr_client(, config)`` — the cache in +:mod:`gps_denied_onboard.fdr_client.client` ensures one instance per +``producer_id``. The ``"airborne_main"`` instance is the one passed via +``pre_constructed`` for the wrappers that accept ``fdr_client=`` as a +kwarg. +""" + + _LOG = logging.getLogger("gps_denied_onboard.runtime_root.airborne_bootstrap") @@ -365,6 +382,30 @@ _AIRBORNE_REGISTRATIONS: tuple[ ) +def build_pre_constructed(config: "Config") -> dict[str, Any]: + """Build the airborne ``pre_constructed`` dict for :func:`compose_root`. + + AZ-619 (Phase A) lands the foundational keys ``c13_fdr`` and ``clock``. + Phases B..F (AZ-620..AZ-624) extend this function to populate the + remaining 10 keys in :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`. + + Returns a fresh dict on each call. The ``c13_fdr`` instance is cached + inside :func:`make_fdr_client` (per-producer cache) so two calls within + the same process return dicts where ``pre_constructed['c13_fdr']`` is + the SAME object — AC-619.2. ``clock`` is a fresh :class:`WallClock` + each call (stateless; the cache would be a no-op). + + Replay-mode override: :func:`compose_root` merges ``replay_components`` + over ``pre_constructed`` so the :class:`WallClock` here is replaced by + the replay branch's :class:`TlogDerivedClock`. That's intentional and + matches the contract in :func:`compose_root`'s docstring. + """ + return { + "c13_fdr": make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config), + "clock": WallClock(), + } + + def register_airborne_strategies() -> None: """Register every airborne (component, strategy) pair into ``_STRATEGY_REGISTRY``. diff --git a/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py b/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py new file mode 100644 index 0000000..caef4c3 --- /dev/null +++ b/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py @@ -0,0 +1,109 @@ +"""AZ-619 — Phase A of AZ-618: ``build_pre_constructed`` seeds c13_fdr + clock. + +Verifies the contract at +``_docs/02_tasks/todo/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md``: + +* AC-619.1: ``build_pre_constructed(default_config)`` returns a dict + containing keys ``c13_fdr`` (FdrClient instance) and ``clock`` + (WallClock instance). +* AC-619.2: invoking twice in the same process returns dicts where + ``c13_fdr`` is the SAME FdrClient instance (per ``make_fdr_client`` + cache); ``clock`` may be a fresh WallClock each call. + +AC-619.3 (this file exists with the above tests) is satisfied by the +existence of this module. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from gps_denied_onboard.clock.wall_clock import WallClock +from gps_denied_onboard.config import Config +from gps_denied_onboard.fdr_client import client as fdr_client_module +from gps_denied_onboard.fdr_client.client import FdrClient +from gps_denied_onboard.runtime_root.airborne_bootstrap import ( + AIRBORNE_MAIN_PRODUCER_ID, + build_pre_constructed, +) + + +@pytest.fixture(autouse=True) +def _isolated_fdr_cache() -> Iterator[None]: + # Arrange: every test starts with an empty FdrClient cache so AC-619.2's + # "same instance across calls" assertion is exercised against fresh state + # rather than stale cache from a prior test. + fdr_client_module._reset_for_tests() + yield + fdr_client_module._reset_for_tests() + + +def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None: + # Arrange + config = Config() + + # Act + pre_constructed = build_pre_constructed(config) + + # Assert + assert "c13_fdr" in pre_constructed + assert "clock" in pre_constructed + assert isinstance(pre_constructed["c13_fdr"], FdrClient) + assert isinstance(pre_constructed["clock"], WallClock) + + +def test_ac_619_1_c13_fdr_uses_airborne_main_producer_id() -> None: + # Arrange + config = Config() + + # Act + pre_constructed = build_pre_constructed(config) + + # Assert: the FdrClient is keyed under the documented producer ID so + # any per-component caller using the same ID gets the same instance. + assert pre_constructed["c13_fdr"].producer_id == AIRBORNE_MAIN_PRODUCER_ID + + +def test_ac_619_2_c13_fdr_cached_across_calls() -> None: + # Arrange + config = Config() + + # Act + first = build_pre_constructed(config) + second = build_pre_constructed(config) + + # Assert: same FdrClient instance via make_fdr_client cache. + assert first["c13_fdr"] is second["c13_fdr"] + + +def test_ac_619_2_clock_is_independent_instance_per_call() -> None: + # Arrange + config = Config() + + # Act + first = build_pre_constructed(config) + second = build_pre_constructed(config) + + # Assert: WallClock is stateless; identity-share is not contractual. + # Both must be valid WallClock instances. Identity may or may not match. + assert isinstance(first["clock"], WallClock) + assert isinstance(second["clock"], WallClock) + + +def test_phase_a_only_seeds_two_keys() -> None: + """Phase A scope discipline: exactly the two documented keys. + + Phases B..F (AZ-620..AZ-624) will add more keys; this test will be + relaxed at that point. For now it pins the AZ-619 contract precisely so + a regression that adds keys here without updating the spec is caught. + """ + # Arrange + config = Config() + + # Act + pre_constructed = build_pre_constructed(config) + + # Assert + assert set(pre_constructed.keys()) == {"c13_fdr", "clock"}