core,cli: Add dual-modality authentication
Single-modality face auth is identity-matching, not liveness detection.
ArcFace is trained to cluster the embedding space by identity; a sharp
photo of a face produces an embedding very close to the face's own,
because identity is what ArcFace was optimised to preserve. We
confirmed the practical impact: `pareidolia test` (RGB) passes when
shown a phone-screen photo of a relative, with similarity solidly
above the 0.6 threshold.
The same query against the IR camera, however, didn't even reach the
matcher — SCRFD failed to detect a face in the IR view of the phone
screen at all, because phone/laptop displays emit visible light but
almost no near-infrared. Printed photos behave similarly in most
cases. Requiring *both* modalities to pass independently therefore
closes the common presentation-attack vector without adding any
anti-spoof ML.
# Core: `authenticate_dual` + `DualMatchOutcome` + `SingleVerdict`
`Enrollment::authenticate_dual(rgb_query, ir_query, threshold)` takes
two `Option<&Embedding>` queries (one per modality) and returns a
`DualMatchOutcome`:
- `Pass { rgb, ir }` — both modalities cleared the threshold against
their respective enrolled pools. Returns the winning `MatchResult`
from each pool for audit / logging.
- `Reject { rgb, ir }` — at least one modality failed. Both
per-modality `SingleVerdict`s are preserved so the failure mode is
fully diagnosable (which side failed, and how).
- `NoEnrollment` — fires only when the enrollment has zero samples
total; per-modality emptiness collapses into `SingleVerdict::NoSamples`
on that side instead.
`SingleVerdict` carries the four states a per-modality decision can
land in: `Pass`, `BelowThreshold` (face found, similarity below
threshold), `NoFace` (caller didn't supply an embedding — capture
error, no face detected, modality disabled), `NoSamples` (enrollment
has no samples for this kind). The `Option` parameter shape lets the
caller express 'couldn't produce an embedding for this side' directly,
guaranteeing the matcher rejects rather than silently degrading to
single-modality.
# Tests
Eight new unit tests pin the full truth table (empty enrollment, both
queries present and matching, RGB pass + IR below threshold, missing
RGB query, both queries missing, RGB-only enrollment with IR queried,
both below threshold, is_pass helper). Two new property tests pin the
soundness invariants:
- `authenticate_dual_pass_iff_both_single_modality_pass` — the
primary contract: dual.is_pass() ⟺ both single-modality authenticate
calls would have passed. Callers can reason about
`authenticate_dual` as 'AND over single-modality outcomes'.
- `authenticate_dual_with_either_query_missing_never_passes_on_nonempty`
— a missing query on either side always forces Reject for any
non-empty enrollment, regardless of threshold.
One integration smoke test
(`public_dual_modality_authenticate_requires_both_pools_to_pass`)
covers the end-to-end public API including the Pass shape and the
None-query Reject shape.
# CLI: `pareidolia test` learns dual mode
The `conflicts_with = rgb_camera` mutex on `--ir-camera` is removed.
Mode selection becomes:
- `--ir-camera` unset → single RGB mode against the RGB pool
(unchanged from M4.6).
- `--ir-camera` set → dual mode: capture one frame from each sensor
serially (sibling-node cooldown between, like enroll), run
`authenticate_dual`. 'No face detected' on one side becomes
`SingleVerdict::NoFace` rather than a hard error, so the verdict
surfaces fully.
The dual output path prints both per-modality verdicts on reject
('RGB: pass (similarity 0.823 ≥ 0.600, sample 1) / IR: no face
detected in captured frame'), making it obvious which side blocked
auth. The previous IR-single-camera testing flow gives way to dual:
running `pareidolia test --ir-camera /dev/videoX` against the typical
RGB+IR enrollment is now a strictly stronger check than before.
# Documentation
A new `## Security` section in CONTRIBUTING.md explains the threat
model: what ArcFace matches (identity, not liveness), what dual-mode
defeats (phone-screen photos, most paper photos) and what it doesn't
(NIR-tuned prints, 3D masks, NIR-illuminated photographs), and the
separate concern of family-resemblance false-accepts. A defense
matrix summarises the cells.