core: Add per-device IR calibration model and store
Calibration records how a given IR camera delivers illuminated frames so
capture can be optimized per device. It is purely an optimization and
capability layer: `capture_ir_face` already works without it (capture a
burst, keep the brightest), so a missing/unmatched/corrupt record just
falls back to the safe mode-agnostic burst — never an error. That's why
this store, unlike the enrollment store, needs no HMAC.
# Device identity (card + bus)
Records are keyed by `CameraIdentity { card, bus }`. Neither field alone
is a usable key: `/dev/videoN` renumbers across reboots, the card name
isn't unique across identical models, and the bus (port topology)
changes when a device is replugged elsewhere. `resolve_calibration`
matches leniently, returning a typed `CalibrationMatch`:
- `Exact` — card + bus both match.
- `MovedPort` — card matches, bus differs, and exactly one connected
camera has that card name, so the device just moved ports: safe to
adopt and refresh the stored bus.
- `Ambiguous` — card matches but several connected cameras share that
name, so we can't guess which: the caller should ask the user.
- `None` — no record; fall back to mode-agnostic capture.
This mirrors the decisions-in-core / rendering-in-CLI shape of
`camera::select::IrCameraResolution`.
# Store
`CalibrationStore` persists all records in one small CBOR file (the set
is tiny and the matching API wants it whole). Writes are atomic
(temp-file + fsync + rename), matching the enrollment store's crash
discipline. `upsert` replaces by exact identity; `replace_by_card` drops
a stale-bus record when a device moved ports.
# CameraInfo.bus
`CameraInfo` gains a `bus: Option<String>`, filled by `enumerate()` from
the V4L2 bus info and left `None` for mock/synthetic sources and the
by-path `open()` path. This is the input `CameraIdentity::from_info`
reads. Calibration shares the `v4l2` feature gate since it depends on the
backend's `IrCaptureMode`.
core: Add enrollment data model and matching algorithm
First M4 slice. Pure data + math: enrolled samples, the per-user
enrollment that holds them, and the cosine-similarity-based matching
algorithm that drives the eventual PAM auth decision. The on-disk
store (HMAC, atomic writes) lands in subsequent commits; this slice
gives matching tests something to run against and pins the algorithmic
contract.
# Best-of-N, not mean-of-N
The matching layer takes the *best* of the N enrolled-sample
similarities, not their mean. Mean-of-N would make a frontal-only
enrollment fail on a slight head turn even though the user is clearly
recognisable from one of the angles already captured. Best-of-N matches
how real enrolment data is captured (multiple poses to span variation)
and how face recognition systems are typically operated.
# Match outcomes
`MatchOutcome` distinguishes three states: `Pass` (clears threshold),
`Reject` (below threshold, includes the best result so logs can show
how close), and `NoEnrollment` (no samples to match against). The
third variant is separated from `Reject` because the remediation
differs — the user needs to enrol, not retry — and the PAM glue will
want to route those cases differently when they cross the IPC boundary
in M5.
# Numeric edge cases pinned
- NaN similarities (dim-mismatched embeddings) are filtered out of
best-of-N selection rather than poisoning it. A mismatched-model
query against a populated enrollment surfaces as 'no valid candidate'
rather than 'best match was NaN'.
- Threshold comparison is `>=`: similarity exactly at the threshold
passes. The test `authenticate_at_exactly_the_threshold_passes_inclusively`
hand-crafts a similarity of 0.6 against threshold 0.6 to pin this.
# Tests
15 unit tests covering enrollment construction / push / remove,
best-match selection (single, multi-axis, NaN-skipping, mixed-validity),
threshold behaviour, and the MatchOutcome helpers.
5 property tests covering the invariants the matching layer relies on:
- Pushing a sample never decreases best-match similarity (B-o-N
monotonicity).
- Removing a sample never increases best-match similarity (mirror).
- Query identical to an enrolled sample yields similarity ≈ 1.0.
- Threshold at -∞ always passes any non-empty enrollment.
- Threshold above 1.0 never passes any normalised embedding.
Plus three cross-crate integration smoke tests for the public surface.