[AZ-344] C3 CrossDomainMatcher Protocol + factory + RollingHealthWindow

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 05:43:33 +03:00
parent d6756f1855
commit 89c223882b
16 changed files with 1404 additions and 50 deletions
@@ -0,0 +1,190 @@
# 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); § 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_<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``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.