# C3 CrossDomainMatcher Protocol + Factory + Composition **Task**: AZ-344_c3_matcher_protocol **Name**: C3 `CrossDomainMatcher` Protocol + Factory + Composition **Description**: Define the public `CrossDomainMatcher` Protocol (PEP 544 structural interface), the C3 DTOs (`CandidateMatchSet`, `MatchResult`, `MatcherHealth`), the error hierarchy (`MatcherError` family with `MatcherBackboneError`, `InsufficientInliersError`), and the composition-root factory `build_matcher_strategy(config, lightglue_runtime, ransac_filter, inference_runtime) -> CrossDomainMatcher` that selects the concrete matcher at startup based on `config.matcher.strategy` with lazy import + `BUILD_MATCHER_` flag gating per ADR-002. Includes the rolling-window `MatcherHealth` accumulator infrastructure (constructor-injected into every concrete matcher; updated inside `match` after each frame). The shared `LightGlueRuntime` (AZ-278) and `RansacFilter` (AZ-282) helpers are constructor-injected — neither owned by C3. This task delivers the foundational scaffolding every concrete matcher (AZ-345..AZ-347) depends on; no concrete backbone is implemented here. **Complexity**: 3 points **Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-270_compose_root, AZ-278_lightglue_runtime, AZ-282_ransac_filter, AZ-297_c7_runtime_protocol (for `InferenceRuntime` interface), AZ-266_log_module **Component**: c3_matcher (epic AZ-257 / E-C3) **Tracker**: AZ-344 **Epic**: AZ-257 (E-C3) ### Document Dependencies - `_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md` — the public contract this task implements (Protocol surface + DTOs + error hierarchy + factory signature + 9 invariants + test cases). - `_docs/02_document/components/04_c3_matcher/description.md` — § 1 architectural pattern (Strategy); § 2 `CrossDomainMatcher` interface + DTOs; § 5 error handling (drop-and-continue + below-threshold + all-failed); § 7 caveats (shared helper serial access); § 9 logging. - `_docs/02_document/module-layout.md` — `c3_matcher` Per-Component Mapping; `BUILD_MATCHER_` rows; § Layer 3. - `_docs/02_document/architecture.md` — ADR-001, ADR-002, ADR-009. - `_docs/02_document/contracts/c2_5_rerank/rerank_strategy_protocol.md` — `RerankResult` DTO consumed at the input boundary. - `_docs/02_document/contracts/shared_helpers/lightglue_runtime.md` — helper handle. - `_docs/02_document/contracts/shared_helpers/ransac_filter.md` — helper API consumed for inlier filtering. - `_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md` — `InferenceRuntime` interface consumed by every concrete backbone. ## Problem Without this task, every concrete matcher (AZ-345..AZ-347) and the downstream C3.5 ConditionalRefiner (AZ-258 component) would each invent their own ad-hoc interface, breaking ADR-001 (Strategy), ADR-002 (build-time exclusion), and ADR-009 (interface-first DI). The rolling `MatcherHealth` accumulator also needs ONE owner; without this task, every concrete matcher would re-implement the rolling window → drift between strategies → C5's spoof-promotion gate (AC-NEW-2 / AC-NEW-7) would behave inconsistently across matchers. ## Outcome - `src/gps_denied_onboard/components/c3_matcher/interface.py` defining the `CrossDomainMatcher` Protocol (`@runtime_checkable`) with `match` and `health_snapshot`. Docstring encodes all 9 invariants from the contract. - `src/gps_denied_onboard/components/c3_matcher/__init__.py` re-exporting the Protocol + DTOs (`CrossDomainMatcher`, `MatchResult`, `MatcherHealth`). - `src/gps_denied_onboard/_types/matcher.py` defining the three frozen + slotted dataclasses: `CandidateMatchSet`, `MatchResult`, `MatcherHealth`. Cross-component-consumed → lives in shared `_types/`. - `src/gps_denied_onboard/components/c3_matcher/errors.py` defining `MatcherError`, `MatcherBackboneError`, `InsufficientInliersError`. - `src/gps_denied_onboard/components/c3_matcher/_health_window.py` defining `RollingHealthWindow` — the 60 s rolling window with O(1) accumulators (`consecutive_low_inlier`, `mean_inliers_60s`, `backbone_error_count_60s`). Provided to every concrete matcher via constructor injection so they share semantics. - `src/gps_denied_onboard/runtime_root/matcher_factory.py` exporting `build_matcher_strategy(config, lightglue_runtime, ransac_filter, inference_runtime) -> CrossDomainMatcher`. The factory: 1. Reads `config.matcher.strategy` (one of: `"disk_lightglue"`, `"aliked_lightglue"`, `"xfeat"`). 2. Lazy-imports per the strategy resolution table. 3. ImportError "No module named" → `ConfigurationError(f"BUILD_MATCHER_{strategy.upper()} is OFF...")`. Other ImportErrors re-raised. 4. Constructs the strategy via its module-level `create(config, lightglue_runtime, ransac_filter, inference_runtime, health_window)` factory function. 5. Returns the instance. - Composition-root `compose_root` extension: invoke `build_matcher_strategy` AFTER `LightGlueRuntime` + `RansacFilter` are constructed; bind the result to the same C2.5 ingest thread. Identity-share the `LightGlueRuntime` instance with C2.5 (per AZ-342 AC-10). - Config schema extension to AZ-269: `config.matcher.strategy` (enum), `config.matcher.min_inliers_threshold` (int, default 60), `config.matcher.residual_warn_threshold_px` (float, default 2.5). - INFO log on every successful `build_matcher_strategy`: `kind="c3.matcher.strategy_loaded"` with strategy name + thresholds. - ERROR log on `ConfigurationError` (specific missing flag). ## Scope ### Included - The `CrossDomainMatcher` Protocol with both `match` and `health_snapshot` methods. - The three DTOs in `_types/matcher.py`. - The three-class error hierarchy in `c3_matcher/errors.py`. - The `RollingHealthWindow` accumulator in `_health_window.py` (constructor-injected into every matcher). - The composition-root factory with lazy-import + `ConfigurationError` mapping. - Config schema extension for `config.matcher.{strategy, min_inliers_threshold, residual_warn_threshold_px}`. - Strategy resolution table comment matching the contract verbatim. - Composition-root wiring path that identity-shares `LightGlueRuntime` with C2.5. - Unit tests covering: Protocol conformance (`runtime_checkable`), DTO immutability + slots, factory rejection on missing flag, factory acceptance for valid values, rolling window O(1) accumulator correctness, INFO log emission, error hierarchy catchability. - INFO / ERROR log emission per description.md § 9. ### Excluded - Any concrete matcher implementation — owned by AZ-345 (DISK+LightGlue), AZ-346 (ALIKED+LightGlue), AZ-347 (XFeat). - The `LightGlueRuntime` helper — already AZ-278. - The `RansacFilter` helper — already AZ-282. - The C7 `InferenceRuntime` — owned by AZ-297. - The C2.5 `RerankResult` DTO — consumed; produced by AZ-342. - Component-internal acceptance tests beyond Protocol-conformance + factory-validation: C3-IT-01..05 + C3-PT-01 deferred to Step 9 / E-BBT. ## Acceptance Criteria **AC-1: Protocol conformance — `runtime_checkable`** Given a `FakeMatcher` test double implementing both `match` and `health_snapshot` When `isinstance(fake, CrossDomainMatcher)` is evaluated Then result is `True`; an object missing either method returns `False` **AC-2: DTO immutability + slots** All three DTOs use `frozen=True, slots=True`; mutation raises `FrozenInstanceError`; `__slots__` non-empty. **AC-3: Factory rejects missing build flag** Given `config.matcher.strategy = "nonexistent_matcher"` When `build_matcher_strategy(...)` is called Then `ConfigurationError("BUILD_MATCHER_NONEXISTENT_MATCHER is OFF...")` is raised; ONE ERROR log `kind="c3.matcher.build_flag_off"` is emitted. **AC-4: Factory rejects unknown strategy at config-load time** Given `config.matcher.strategy = "garbage"` (not in the resolution table) When `load_config(...)` is called Then `ConfigurationError` raised at config-load time; the factory is never invoked. **AC-5: Successful factory load emits INFO log** Given `config.matcher.strategy = "disk_lightglue"` AND a valid lazy-importable test double module When `build_matcher_strategy(...)` is called Then a `CrossDomainMatcher` instance is returned; ONE INFO log `kind="c3.matcher.strategy_loaded"` is emitted with `{strategy, min_inliers_threshold, residual_warn_threshold_px}`. **AC-6: Strategy resolution — every entry resolves to its module path** Given each of three valid `config.matcher.strategy` values When `build_matcher_strategy` is called for each Then resolved module path matches the contract's table verbatim. **AC-7: Error hierarchy catchability** Test instances of `MatcherBackboneError` + `InsufficientInliersError` caught by `except MatcherError`. **AC-8: Public API surface — `__init__.py` re-exports** Given `from gps_denied_onboard.components.c3_matcher import CrossDomainMatcher, MatchResult, MatcherHealth` When the import is evaluated Then all three names resolve; internal names (`RollingHealthWindow`, `_health_window`) are NOT in `__all__`. **AC-9: Strategy bound to single ingest thread by composition root** Single-thread binding enforced; second binding attempt raises `RuntimeError`. **AC-10: `LightGlueRuntime` is identity-shared between C3 and C2.5** Given a `compose_root(config)` invocation that wires both C2.5 and C3 When the resulting strategies are inspected Then `c3_strategy._lightglue_runtime is c2_5_strategy._lightglue_runtime` (identity); ONE INFO log confirming the shared binding is emitted (the SAME log line as AZ-342 AC-10 — emitted ONCE). **AC-11: `RollingHealthWindow` O(1) accumulator correctness** Given a `RollingHealthWindow` (60 s) AND a sequence of (frame_id, inlier_count, had_backbone_error) events spanning 90 s When `health_snapshot()` is called at t=60s, t=70s, t=90s Then `consecutive_low_inlier`, `mean_inliers_60s`, `backbone_error_count_60s` match an independent sliding-window computation; each `health_snapshot()` call is O(1) (microbench p99 ≤ 50 µs). **AC-12: `RollingHealthWindow.update()` API** Given the window has incremental update entry-points (called from inside concrete matchers' `match` after each frame) When `update(timestamp_ns, best_inlier_count, had_backbone_error)` is called Then accumulators are updated incrementally; `mean_inliers_60s` is the rolling mean; `consecutive_low_inlier` resets to 0 when a frame's `inlier_count >= min_inliers_threshold`. ## Non-Functional Requirements **Performance** - `build_matcher_strategy` p99 ≤ 50 ms (factory itself; concrete-strategy construction cost owned by AZ-345..AZ-347). - `RollingHealthWindow.update` p99 ≤ 5 µs; `health_snapshot` p99 ≤ 50 µs. **Compatibility** - Protocol method-signature changes are major version bumps (lockstep update). - DTO field additions are minor; field removals are major. **Reliability** - Lazy-import via `importlib.import_module`; build-time-excluded matchers never load CUDA / TensorRT. - Single-thread invariant enforced at composition-root binding time (AC-9). - `RollingHealthWindow` is a non-thread-safe single-thread structure; matches the single-thread binding invariant. ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|--------------|------------------| | AC-1 | Protocol conformance | Fake passes; partial fake fails | | AC-2 | DTO immutability + slots | `FrozenInstanceError`; non-empty `__slots__` | | AC-3 | Factory + missing flag | `ConfigurationError`; ERROR log | | AC-4 | Config load + unknown strategy | `ConfigurationError` at load time | | AC-5 | Factory + valid load | Strategy returned; INFO log with structured fields | | AC-6 | All 3 strategy values resolve | Module paths match resolution table | | AC-7 | Error catchability | All errors caught by `except MatcherError` | | AC-8 | Public API re-exports | Public names resolve; internals not in `__all__` | | AC-9 | Single-thread binding | Second binding raises `RuntimeError` | | AC-10 | Identity-share with C2.5 | `is` identity preserved | | AC-11 | Rolling window correctness | Matches independent sliding-window computation | | AC-12 | `update` semantics | `consecutive_low_inlier` resets on a high-inlier frame; `mean_inliers_60s` is rolling | | NFR-perf-window | `RollingHealthWindow.update` × 100k | p99 ≤ 5 µs | ## Constraints - **Lazy import is mandatory** (ADR-002). - **`@runtime_checkable` MUST be used.** - **DTOs MUST be `frozen=True, slots=True`.** - **Concrete matcher modules export `create(...)` as their entry-point.** - **`config.matcher.strategy` is an enum** validated at config load. - **The factory does NOT instantiate `LightGlueRuntime` or `RansacFilter`** — runtime root constructs ONCE and shares with C2.5 (AC-10). - **`RollingHealthWindow` is single-thread** — no locks; matches single-thread binding invariant. Adding locks would mask binding bugs. ## Risks & Mitigation **Risk 1: `runtime_checkable` Protocol checks have known performance cost** - *Mitigation*: `isinstance` only at composition-root binding (AC-9), not per-frame. **Risk 2: Lazy-import error message obscures real failure mode** - *Mitigation*: factory catches `ImportError`, inspects message; "No module named" → "BUILD flag OFF"; otherwise re-raises preserving native context. **Risk 3: `RollingHealthWindow` 60 s window data structure choice (deque vs ring buffer)** - *Mitigation*: implementation detail; AC-11 + AC-12 + NFR-perf-window are the contract. Any structure satisfying O(1) update + O(1) snapshot is acceptable. **Risk 4: `compose_root` thread-binding registry / shared-helper composition not yet implemented in AZ-270** - *Mitigation*: same as AZ-342 Risk 4. Keep AC-9 / AC-10; if AZ-270 lacks the registry, escalate via tracker dependency mechanism. ## Runtime Completeness - **Named capability**: `CrossDomainMatcher` Protocol + composition-root factory + `RollingHealthWindow` accumulator + ADR-002 build-time exclusion. - **Production code that must exist**: real Protocol + real DTOs + real error hierarchy + real `build_matcher_strategy` factory + real `RollingHealthWindow` + real config schema extension + real composition-root wiring path that identity-shares `LightGlueRuntime` with C2.5. - **Allowed external stubs**: `FakeMatcher`, `FakeLightGlueRuntime`, `FakeRansacFilter`, `FakeInferenceRuntime` for tests. Production wiring uses real concretes. - **Unacceptable substitutes**: direct `from .disk_lightglue import DiskLightGlueMatcher` in the factory (defeats ADR-002); a `Type[CrossDomainMatcher]` registry that pre-imports all matchers (defeats lazy-import); making `RollingHealthWindow` thread-safe with locks (would mask single-thread binding bugs); skipping the identity-share with C2.5 (would double GPU memory). ## Contract This task produces/implements the contract at `_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md`. Consumers MUST read that file — not this task spec — to discover the interface.