Defines the public `CrossDomainMatcher` Protocol (PEP 544 @runtime_checkable, two methods: `match` + `health_snapshot`), the three frozen+slotted DTOs (`CandidateMatchSet`, `MatchResult`, `MatcherHealth`) in the L1 `_types/matcher.py` layer, the `MatcherError` family (`MatcherBackboneError`, `InsufficientInliersError`), and the composition-root `build_matcher_strategy` factory with lazy-import + `BUILD_MATCHER_<variant>` gating per ADR-002. `RollingHealthWindow` accumulator (60 s, amortised O(1) update, strict O(1) snapshot) is constructed by the factory and injected into every concrete matcher so all backbones share window semantics; this is what backs C5's spoof-promotion gate. Legacy placeholder `MatchResult` removed from `_types/matching.py`; import-only consumers (`c4_pose.interface`, `c3_5_adhop.interface`) repointed at the new `_types/matcher.py` home — zero behavioural change to those components. AC-9 (single-thread binding) and AC-10 (LightGlueRuntime identity-share with C2.5) deferred to AZ-270 runtime-root composition, mirroring the AZ-342 Risk-4 escape clause. All other ACs + NFRs covered by 70 new conformance tests. Co-authored-by: Cursor <cursoragent@cursor.com>
15 KiB
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_<variant> 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); § 2CrossDomainMatcherinterface + 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_matcherPer-Component Mapping;BUILD_MATCHER_<variant>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—RerankResultDTO 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—InferenceRuntimeinterface 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.pydefining theCrossDomainMatcherProtocol (@runtime_checkable) withmatchandhealth_snapshot. Docstring encodes all 9 invariants from the contract.src/gps_denied_onboard/components/c3_matcher/__init__.pyre-exporting the Protocol + DTOs (CrossDomainMatcher,MatchResult,MatcherHealth).src/gps_denied_onboard/_types/matcher.pydefining the three frozen + slotted dataclasses:CandidateMatchSet,MatchResult,MatcherHealth. Cross-component-consumed → lives in shared_types/.src/gps_denied_onboard/components/c3_matcher/errors.pydefiningMatcherError,MatcherBackboneError,InsufficientInliersError.src/gps_denied_onboard/components/c3_matcher/_health_window.pydefiningRollingHealthWindow— 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.pyexportingbuild_matcher_strategy(config, lightglue_runtime, ransac_filter, inference_runtime) -> CrossDomainMatcher. The factory:- Reads
config.matcher.strategy(one of:"disk_lightglue","aliked_lightglue","xfeat"). - Lazy-imports per the strategy resolution table.
- ImportError "No module named" →
ConfigurationError(f"BUILD_MATCHER_{strategy.upper()} is OFF..."). Other ImportErrors re-raised. - Constructs the strategy via its module-level
create(config, lightglue_runtime, ransac_filter, inference_runtime, health_window)factory function. - Returns the instance.
- Reads
- Composition-root
compose_rootextension: invokebuild_matcher_strategyAFTERLightGlueRuntime+RansacFilterare constructed; bind the result to the same C2.5 ingest thread. Identity-share theLightGlueRuntimeinstance 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
CrossDomainMatcherProtocol with bothmatchandhealth_snapshotmethods. - The three DTOs in
_types/matcher.py. - The three-class error hierarchy in
c3_matcher/errors.py. - The
RollingHealthWindowaccumulator in_health_window.py(constructor-injected into every matcher). - The composition-root factory with lazy-import +
ConfigurationErrormapping. - 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
LightGlueRuntimewith 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
LightGlueRuntimehelper — already AZ-278. - The
RansacFilterhelper — already AZ-282. - The C7
InferenceRuntime— owned by AZ-297. - The C2.5
RerankResultDTO — 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_strategyp99 ≤ 50 ms (factory itself; concrete-strategy construction cost owned by AZ-345..AZ-347).RollingHealthWindow.updatep99 ≤ 5 µs;health_snapshotp99 ≤ 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).
RollingHealthWindowis 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_checkableMUST be used.- DTOs MUST be
frozen=True, slots=True. - Concrete matcher modules export
create(...)as their entry-point. config.matcher.strategyis an enum validated at config load.- The factory does NOT instantiate
LightGlueRuntimeorRansacFilter— runtime root constructs ONCE and shares with C2.5 (AC-10). RollingHealthWindowis 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:
isinstanceonly 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:
CrossDomainMatcherProtocol + composition-root factory +RollingHealthWindowaccumulator + ADR-002 build-time exclusion. - Production code that must exist: real Protocol + real DTOs + real error hierarchy + real
build_matcher_strategyfactory + realRollingHealthWindow+ real config schema extension + real composition-root wiring path that identity-sharesLightGlueRuntimewith C2.5. - Allowed external stubs:
FakeMatcher,FakeLightGlueRuntime,FakeRansacFilter,FakeInferenceRuntimefor tests. Production wiring uses real concretes. - Unacceptable substitutes: direct
from .disk_lightglue import DiskLightGlueMatcherin the factory (defeats ADR-002); aType[CrossDomainMatcher]registry that pre-imports all matchers (defeats lazy-import); makingRollingHealthWindowthread-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.