A facial recognition login service for Linux.
2

Configure Feed

Select the types of activity you want to include in your feed.

core: Add evidence/veto roles to the liveness policy

Liveness checks now declare a `CheckRole`: positive **evidence** of life,
or a spoof **veto**. The policy composes them differently:

evidence = noisy_or(evidence check scores) // raises confidence
combined = evidence × ∏(veto check scores) // veto caps it

This is needed because some signals are evidence of an *attack*, not of
life, and noisy-OR (which ignores low scores) would render them inert. A
veto check still outputs "confidence the subject is live" in 0..1, but
*low* when it detects an attack signature; multiplying caps the final
score, so a confident veto (≈0, "this is a screen") overrides even
strong evidence. An inapplicable veto isn't in the product and caps
nothing, preserving additivity for veto checks too.

`role()` defaults to `Evidence`, so existing checks are unaffected;
spoof detectors override it. `LivenessAssessment` gains `evidence`
(pre-cap) alongside `combined` (post-cap) for diagnostics. The old
`combine` is renamed `noisy_or` to name what it actually does now that
it's only the evidence half.

Proptested: noisy-OR bounded/monotonic/single-identity as before, plus
the defining veto property — adding a veto never raises the final score
— and the combined score stays in [0,1] for any evidence/veto mix.

(The first real checks land next: the micro-motion check is being
removed as a proven dead end — magnitude-of-change is anti-correlated
with liveness, per both measurement and the PAD literature — and
replaced by a NIR-reflectance veto gate.)

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) ffacfa55 b5199370

+257 -60
+257 -60
crates/core/src/liveness/mod.rs
··· 22 22 //! 23 23 //! # Additivity and the security model 24 24 //! 25 - //! Scores combine via **noisy-OR** ([`LivenessPolicy::combine`]): 25 + //! Checks have one of two [`CheckRole`]s. Positive **evidence** ("I see 26 + //! signs of life") combines via **noisy-OR** ([`LivenessPolicy::noisy_or`]); 27 + //! **veto** checks ("I see signs of a spoof") then *cap* the result 28 + //! multiplicatively: 26 29 //! 27 30 //! ```text 28 - //! combined = 1 - ∏ (1 - sᵢ) over *applicable* checks only 31 + //! evidence = 1 - ∏ (1 - eᵢ) over applicable evidence checks 32 + //! combined = evidence × ∏ vⱼ over applicable veto checks 29 33 //! ``` 30 34 //! 31 - //! Two properties make this the right choice, and rule out a weighted 32 - //! average: 35 + //! The noisy-OR of the evidence has two properties that make it the 36 + //! right choice, and rule out a weighted average: 33 37 //! 34 38 //! 1. **Missing checks contribute nothing.** A `NotApplicable` check is 35 39 //! simply absent from the product — it is *not* renormalized away. A ··· 37 41 //! signal up to fill the whole `0..1` range, making a constant sensor 38 42 //! with one mediocre check look as confident as a strobed sensor with 39 43 //! several strong ones. Noisy-OR does not: with one applicable check 40 - //! of score `s`, the combined score is exactly `s`. 44 + //! of score `s`, the evidence score is exactly `s`. 41 45 //! 2. **More evidence can only raise confidence.** Adding an applicable 42 - //! check with score `sᵢ > 0` strictly increases the combined score 43 - //! (or leaves it unchanged at the `1.0` ceiling). So a strobed 44 - //! sensor, which has *strictly more* applicable checks than a 45 - //! constant one, can reach a *strictly higher* maximum confidence. 46 - //! Strobing being more secure is therefore a structural property, not 47 - //! a tuning accident. 46 + //! evidence check with score `eᵢ > 0` strictly increases the evidence 47 + //! score (or leaves it at the `1.0` ceiling). So a strobed sensor, 48 + //! which has *strictly more* applicable checks than a constant one, 49 + //! can reach a *strictly higher* maximum confidence. Strobing being 50 + //! more secure is therefore a structural property, not a tuning 51 + //! accident. 52 + //! 53 + //! Veto then composes by multiplication so that a spoof signal can 54 + //! *override* evidence: a confident veto (vⱼ ≈ 0) drives `combined` to 55 + //! ≈0 no matter how strong the evidence, while an inapplicable veto 56 + //! isn't in the product and so caps nothing (additivity preserved for 57 + //! veto checks too). The security framing: **evidence raises your score, 58 + //! spoof-detectors cap it — you need positive signs of life *and* the 59 + //! absence of detected attacks.** 48 60 //! 49 61 //! The upshot, exactly as intended: liveness works on any IR camera from 50 62 //! the universal checks, and a strobed illuminator is an *additive* bonus ··· 53 65 //! 54 66 //! # Honest scope 55 67 //! 56 - //! The model-free checks here (micro-motion, texture, reflectance) raise 57 - //! the bar meaningfully but are **not** a substitute for a trained 58 - //! anti-spoof model against a determined IR-tuned attack — see the 59 - //! project's security docs. The value of this framework is that such a 60 - //! model drops in later as just another [`LivenessCheck`] with no change 61 - //! to the policy or callers. 68 + //! These model-free checks raise the bar against *casual* attacks (a 69 + //! printed photo, a phone/laptop screen) but are **not** a substitute 70 + //! for a trained anti-spoof model against a determined, sensor-tuned 71 + //! attack — see the project's security docs and ISO/IEC 30107-3. The 72 + //! value of this framework is that such a model drops in later as just 73 + //! another [`LivenessCheck`] with no change to the policy or callers. 62 74 //! 63 75 //! # Checks 64 76 //! 65 - //! - [`micro_motion::MicroMotionCheck`] — universal (any IR sensor): 66 - //! scores involuntary frame-to-frame change across same-illumination 67 - //! frames. A live face is never perfectly still; a rigid photo is. 77 + //! The first signal we tried — raw inter-frame *magnitude* of change — 78 + //! was empirically anti-correlated with liveness (a hand-waved photo or 79 + //! a flickering screen changes *more* than a still live face), matching 80 + //! the literature's lesson that liveness is in signal *structure*, not 81 + //! magnitude. The checks below target structure / physics instead: 82 + //! 83 + //! - **NIR reflectance gate** (veto, single-frame): a screen emits ~no 84 + //! near-IR (dark/absent under this sensor) and paper has a different 85 + //! NIR reflectance/structure than skin's sub-surface scattering. The 86 + //! strongest, cheapest signal on a NIR sensor; needs no strobing. 68 87 //! 69 88 //! # Planned checks (not yet built) 70 89 //! ··· 179 198 } 180 199 } 181 200 201 + /// Whether a check contributes *positive evidence of life* or acts as a 202 + /// *spoof veto*. These compose differently (see [`LivenessPolicy`]): 203 + /// 204 + /// - [`Self::Evidence`] checks combine by noisy-OR — each can only 205 + /// *raise* the liveness confidence. "I see signs of life." 206 + /// - [`Self::Veto`] checks *cap* the final score multiplicatively — a 207 + /// confident one drives the result toward zero regardless of how much 208 + /// positive evidence there was. "I see signs of a spoof." Their `0..1` 209 + /// output is still *confidence the subject is live*: low when the 210 + /// attack signature is present. 211 + /// 212 + /// The distinction exists because some phenomena are evidence of an 213 + /// *attack*, not of life, and noisy-OR (which ignores low scores) would 214 + /// render them inert. A screen held to the camera, for instance, flickers 215 + /// — which a naive motion check reads as strong "life". A veto check 216 + /// recognises that flicker as a screen and caps the score instead. 217 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 218 + pub enum CheckRole { 219 + /// Positive evidence of life; combined by noisy-OR (raises score). 220 + Evidence, 221 + /// Spoof veto; caps the final score multiplicatively (lowers score). 222 + Veto, 223 + } 224 + 182 225 /// A pluggable liveness signal. 183 226 /// 184 227 /// Implementors must be *total* — never panic on any input — and must ··· 189 232 pub trait LivenessCheck: std::fmt::Debug { 190 233 /// A short, stable identifier used in logs / score breakdowns. 191 234 fn name(&self) -> &'static str; 235 + 236 + /// Whether this is positive evidence or a spoof veto. Defaults to 237 + /// [`CheckRole::Evidence`] — the common case; veto checks (spoof 238 + /// detectors) override this. 239 + fn role(&self) -> CheckRole { 240 + CheckRole::Evidence 241 + } 192 242 193 243 /// Evaluate the check against one modality's captured frames. 244 + /// 245 + /// The returned score is always *confidence the subject is live* in 246 + /// `0..1`, regardless of [`Self::role`]: an evidence check scores 247 + /// high when it sees life; a veto check scores *low* when it sees an 248 + /// attack signature. 194 249 fn evaluate(&self, input: &LivenessInput<'_>) -> CheckOutcome; 195 250 } 196 251 ··· 200 255 pub struct CheckScore { 201 256 /// The check's [`LivenessCheck::name`]. 202 257 pub name: &'static str, 258 + /// Whether it contributed evidence or acted as a veto. 259 + pub role: CheckRole, 203 260 /// Its outcome on this capture. 204 261 pub outcome: CheckOutcome, 205 262 } ··· 211 268 /// `NotApplicable` lines so logs show *why* a sensor's ceiling is 212 269 /// what it is (e.g. "differencing: NotApplicable" on constant IR). 213 270 pub checks: Vec<CheckScore>, 214 - /// Noisy-OR combination of the applicable scores, in `0.0..=1.0`. 215 - /// `0.0` when no check applied (no liveness evidence either way). 271 + /// Noisy-OR of the positive-evidence checks alone, before veto 272 + /// capping. Surfaced for diagnostics: comparing `evidence` to 273 + /// `combined` shows how much a veto pulled the score down. 274 + pub evidence: f32, 275 + /// Final score: `evidence` capped by the product of veto scores, in 276 + /// `0.0..=1.0`. `0.0` when no evidence applied. 216 277 pub combined: f32, 217 - /// Number of checks that were applicable (contributed to 218 - /// `combined`). Surfaced so callers can distinguish "scored 0.0 219 - /// because checks failed" from "scored 0.0 because nothing applied". 278 + /// Number of checks that were applicable (evidence + veto) — i.e. 279 + /// contributed to the score. Distinguishes "scored low because a 280 + /// veto fired / evidence was weak" from "nothing applied". 220 281 pub applicable_count: usize, 221 282 } 222 283 ··· 270 331 self.checks.is_empty() 271 332 } 272 333 273 - /// Run every check against `input` and combine the applicable scores 274 - /// via noisy-OR. See the module docs for why noisy-OR and not a 275 - /// weighted average. 334 + /// Run every check against `input` and compose a final liveness 335 + /// score: positive **evidence** is combined by noisy-OR, then capped 336 + /// multiplicatively by every applicable **veto** (spoof detector): 337 + /// 338 + /// ```text 339 + /// final = noisy_or(evidence scores) × ∏(veto scores) 340 + /// ``` 341 + /// 342 + /// A confident veto (score ≈ 0, "this is a screen") drives `final` 343 + /// toward 0 regardless of how much positive evidence there was — 344 + /// which is the whole point: a spoof signal must be able to override 345 + /// evidence. A veto that doesn't apply isn't in the product, so it 346 + /// can't cap anything (additivity preserved). See [`CheckRole`]. 276 347 pub fn assess(&self, input: &LivenessInput<'_>) -> LivenessAssessment { 277 348 let checks: Vec<CheckScore> = self 278 349 .checks 279 350 .iter() 280 351 .map(|c| CheckScore { 281 352 name: c.name(), 353 + role: c.role(), 282 354 outcome: c.evaluate(input), 283 355 }) 284 356 .collect(); 285 - let applicable: Vec<f32> = checks.iter().filter_map(|c| c.outcome.score()).collect(); 357 + 358 + let evidence_scores: Vec<f32> = checks 359 + .iter() 360 + .filter(|c| c.role == CheckRole::Evidence) 361 + .filter_map(|c| c.outcome.score()) 362 + .collect(); 363 + let veto_scores: Vec<f32> = checks 364 + .iter() 365 + .filter(|c| c.role == CheckRole::Veto) 366 + .filter_map(|c| c.outcome.score()) 367 + .collect(); 368 + 369 + let evidence = Self::noisy_or(&evidence_scores); 370 + let veto_cap: f32 = veto_scores 371 + .iter() 372 + .fold(1.0_f32, |acc, &s| acc * s.clamp(0.0, 1.0)); 373 + let combined = (evidence * veto_cap).clamp(0.0, 1.0); 374 + 286 375 LivenessAssessment { 287 - combined: Self::combine(&applicable), 288 - applicable_count: applicable.len(), 376 + applicable_count: evidence_scores.len() + veto_scores.len(), 377 + evidence, 378 + combined, 289 379 checks, 290 380 } 291 381 } 292 382 293 - /// Noisy-OR combination of independent-evidence scores: 383 + /// Noisy-OR combination of independent positive-evidence scores: 294 384 /// `1 - ∏(1 - sᵢ)`. Empty input → `0.0` (no evidence). 295 385 /// 296 386 /// Properties (assuming each `sᵢ ∈ [0,1]`): 297 387 /// - one score `s` → exactly `s` (no inflation); 298 388 /// - monotonic: adding any `sᵢ ≥ 0` never lowers the result; 299 389 /// - bounded in `[0,1]`. 300 - pub fn combine(scores: &[f32]) -> f32 { 390 + pub fn noisy_or(scores: &[f32]) -> f32 { 301 391 let product_of_complements = scores 302 392 .iter() 303 393 .fold(1.0_f32, |acc, &s| acc * (1.0 - s.clamp(0.0, 1.0))); ··· 323 413 } 324 414 } 325 415 326 - /// A stub check returning a fixed outcome, for policy tests. 416 + /// A stub check returning a fixed outcome and role, for policy tests. 327 417 #[derive(Debug)] 328 - struct Stub(&'static str, CheckOutcome); 418 + struct Stub(&'static str, CheckRole, CheckOutcome); 329 419 impl LivenessCheck for Stub { 330 420 fn name(&self) -> &'static str { 331 421 self.0 422 + } 423 + fn role(&self) -> CheckRole { 424 + self.1 332 425 } 333 426 fn evaluate(&self, _: &LivenessInput<'_>) -> CheckOutcome { 334 - self.1 427 + self.2 335 428 } 429 + } 430 + 431 + fn evidence(name: &'static str, score: f32) -> Box<Stub> { 432 + Box::new(Stub(name, CheckRole::Evidence, CheckOutcome::Applicable(score))) 433 + } 434 + 435 + fn veto(name: &'static str, score: f32) -> Box<Stub> { 436 + Box::new(Stub(name, CheckRole::Veto, CheckOutcome::Applicable(score))) 336 437 } 337 438 338 439 fn input<'a>(burst: &'a [Frame]) -> LivenessInput<'a> { ··· 359 460 ); 360 461 } 361 462 362 - // ----- noisy-OR combine -------------------------------------------------- 463 + // ----- noisy-OR (evidence combination) ----------------------------------- 363 464 364 465 #[test] 365 - fn combine_empty_is_zero() { 366 - assert_eq!(LivenessPolicy::combine(&[]), 0.0); 466 + fn noisy_or_empty_is_zero() { 467 + assert_eq!(LivenessPolicy::noisy_or(&[]), 0.0); 367 468 } 368 469 369 470 #[test] 370 - fn combine_single_score_is_identity() { 471 + fn noisy_or_single_score_is_identity() { 371 472 // The anti-inflation property: one applicable check of score s 372 473 // yields exactly s, never scaled up. 373 - assert!((LivenessPolicy::combine(&[0.6]) - 0.6).abs() < 1e-6); 474 + assert!((LivenessPolicy::noisy_or(&[0.6]) - 0.6).abs() < 1e-6); 374 475 } 375 476 376 477 #[test] 377 - fn combine_two_scores_exceeds_each_but_stays_bounded() { 378 - let c = LivenessPolicy::combine(&[0.5, 0.5]); 478 + fn noisy_or_two_scores_exceeds_each_but_stays_bounded() { 479 + let c = LivenessPolicy::noisy_or(&[0.5, 0.5]); 379 480 // 1 - 0.5*0.5 = 0.75 380 481 assert!((c - 0.75).abs() < 1e-6); 381 482 assert!(c <= 1.0); ··· 396 497 #[test] 397 498 fn not_applicable_checks_do_not_change_the_score() { 398 499 let burst = [frame(80)]; 399 - // One applicable 0.6 check + one NotApplicable check must score 400 - // exactly 0.6 — the NotApplicable check is absent, not folded in. 500 + // One applicable 0.6 evidence check + one NotApplicable check 501 + // must score exactly 0.6 — the NotApplicable check is absent. 401 502 let policy = LivenessPolicy::new() 402 - .with_check(Box::new(Stub("a", CheckOutcome::Applicable(0.6)))) 403 - .with_check(Box::new(Stub("b", CheckOutcome::NotApplicable))); 503 + .with_check(evidence("a", 0.6)) 504 + .with_check(Box::new(Stub("b", CheckRole::Evidence, CheckOutcome::NotApplicable))); 404 505 let a = policy.assess(&input(&burst)); 405 506 assert!((a.combined - 0.6).abs() < 1e-6); 406 507 assert_eq!(a.applicable_count, 1); ··· 408 509 } 409 510 410 511 #[test] 411 - fn adding_an_applicable_check_only_raises_confidence() { 512 + fn adding_evidence_only_raises_confidence() { 412 513 let burst = [frame(80)]; 413 514 let one = LivenessPolicy::new() 414 - .with_check(Box::new(Stub("a", CheckOutcome::Applicable(0.6)))) 515 + .with_check(evidence("a", 0.6)) 415 516 .assess(&input(&burst)) 416 517 .combined; 417 518 let two = LivenessPolicy::new() 418 - .with_check(Box::new(Stub("a", CheckOutcome::Applicable(0.6)))) 419 - .with_check(Box::new(Stub("b", CheckOutcome::Applicable(0.4)))) 519 + .with_check(evidence("a", 0.6)) 520 + .with_check(evidence("b", 0.4)) 420 521 .assess(&input(&burst)) 421 522 .combined; 422 523 assert!(two >= one, "more applicable evidence must not lower the score"); 423 524 } 424 525 526 + // ----- veto composition -------------------------------------------------- 527 + 528 + #[test] 529 + fn confident_veto_drives_score_to_zero_despite_strong_evidence() { 530 + // Strong evidence (0.9) but a confident veto (0.0 = "this is a 531 + // screen") must force the final score to ~0. This is the bug the 532 + // two-role model fixes: a spoof signal overrides evidence. 533 + let burst = [frame(80)]; 534 + let a = LivenessPolicy::new() 535 + .with_check(evidence("motion", 0.9)) 536 + .with_check(veto("screen", 0.0)) 537 + .assess(&input(&burst)); 538 + assert!((a.evidence - 0.9).abs() < 1e-6, "evidence reported pre-cap"); 539 + assert!(a.combined.abs() < 1e-6, "veto must cap to ~0, got {}", a.combined); 540 + } 541 + 542 + #[test] 543 + fn partial_veto_scales_evidence_down() { 544 + // A veto that's only somewhat suspicious (0.5) halves the 545 + // evidence rather than vetoing outright. 546 + let burst = [frame(80)]; 547 + let a = LivenessPolicy::new() 548 + .with_check(evidence("motion", 0.8)) 549 + .with_check(veto("screen", 0.5)) 550 + .assess(&input(&burst)); 551 + assert!((a.combined - 0.4).abs() < 1e-6, "0.8 * 0.5 = 0.4, got {}", a.combined); 552 + } 553 + 554 + #[test] 555 + fn inapplicable_veto_does_not_cap() { 556 + // A veto that returns NotApplicable (e.g. its detector couldn't 557 + // run) must not cap the score — additivity for veto checks too. 558 + let burst = [frame(80)]; 559 + let a = LivenessPolicy::new() 560 + .with_check(evidence("motion", 0.8)) 561 + .with_check(Box::new(Stub("screen", CheckRole::Veto, CheckOutcome::NotApplicable))) 562 + .assess(&input(&burst)); 563 + assert!((a.combined - 0.8).abs() < 1e-6, "inapplicable veto must not cap"); 564 + } 565 + 566 + #[test] 567 + fn multiple_vetoes_compound() { 568 + // Two partial vetoes multiply: 1.0 evidence * 0.5 * 0.5 = 0.25. 569 + let burst = [frame(80)]; 570 + let a = LivenessPolicy::new() 571 + .with_check(evidence("motion", 1.0)) 572 + .with_check(veto("v1", 0.5)) 573 + .with_check(veto("v2", 0.5)) 574 + .assess(&input(&burst)); 575 + assert!((a.combined - 0.25).abs() < 1e-6, "vetoes must compound, got {}", a.combined); 576 + } 577 + 425 578 // ----- proptests --------------------------------------------------------- 426 579 427 580 proptest! { 428 - /// combine is always in [0,1] for any score vector (incl. out of 581 + /// noisy_or is always in [0,1] for any score vector (incl. out of 429 582 /// range / non-finite, which are clamped). 430 583 #[test] 431 - fn combine_bounded(scores in proptest::collection::vec(any::<f32>(), 0..=12)) { 432 - let c = LivenessPolicy::combine(&scores); 584 + fn noisy_or_bounded(scores in proptest::collection::vec(any::<f32>(), 0..=12)) { 585 + let c = LivenessPolicy::noisy_or(&scores); 433 586 prop_assert!((0.0..=1.0).contains(&c)); 434 587 } 435 588 436 589 /// Monotonic: appending a clamped score in [0,1] never lowers the 437 - /// combined value. 590 + /// noisy-OR value. 438 591 #[test] 439 - fn combine_monotonic_in_added_evidence( 592 + fn noisy_or_monotonic_in_added_evidence( 440 593 base in proptest::collection::vec(0.0f32..=1.0, 0..=8), 441 594 extra in 0.0f32..=1.0, 442 595 ) { 443 - let before = LivenessPolicy::combine(&base); 596 + let before = LivenessPolicy::noisy_or(&base); 444 597 let mut more = base.clone(); 445 598 more.push(extra); 446 - let after = LivenessPolicy::combine(&more); 599 + let after = LivenessPolicy::noisy_or(&more); 447 600 prop_assert!(after >= before - 1e-6); 448 601 } 449 602 450 603 /// A single score round-trips exactly (no inflation), the core 451 604 /// anti-weighted-average property. 452 605 #[test] 453 - fn combine_single_is_identity(s in 0.0f32..=1.0) { 454 - prop_assert!((LivenessPolicy::combine(&[s]) - s).abs() < 1e-6); 606 + fn noisy_or_single_is_identity(s in 0.0f32..=1.0) { 607 + prop_assert!((LivenessPolicy::noisy_or(&[s]) - s).abs() < 1e-6); 608 + } 609 + 610 + /// A veto can only lower or hold the final score, never raise it: 611 + /// adding a veto check to a fixed evidence set yields combined <= 612 + /// the evidence-only score. This is the defining veto property. 613 + #[test] 614 + fn veto_never_raises_the_score( 615 + ev in 0.0f32..=1.0, 616 + v in 0.0f32..=1.0, 617 + ) { 618 + let burst = [frame(80)]; 619 + let evidence_only = LivenessPolicy::new() 620 + .with_check(evidence("e", ev)) 621 + .assess(&input(&burst)) 622 + .combined; 623 + let with_veto = LivenessPolicy::new() 624 + .with_check(evidence("e", ev)) 625 + .with_check(veto("v", v)) 626 + .assess(&input(&burst)) 627 + .combined; 628 + prop_assert!(with_veto <= evidence_only + 1e-6); 629 + } 630 + 631 + /// The final score is always a valid confidence in [0,1] for any 632 + /// mix of evidence and veto scores. 633 + #[test] 634 + fn combined_score_bounded( 635 + evs in proptest::collection::vec(0.0f32..=1.0, 0..=4), 636 + vetoes in proptest::collection::vec(0.0f32..=1.0, 0..=4), 637 + ) { 638 + let burst = [frame(80)]; 639 + let mut policy = LivenessPolicy::new(); 640 + for (i, &e) in evs.iter().enumerate() { 641 + policy = policy.with_check(Box::new(Stub( 642 + ["e0","e1","e2","e3"][i], CheckRole::Evidence, CheckOutcome::Applicable(e), 643 + ))); 644 + } 645 + for (i, &v) in vetoes.iter().enumerate() { 646 + policy = policy.with_check(Box::new(Stub( 647 + ["v0","v1","v2","v3"][i], CheckRole::Veto, CheckOutcome::Applicable(v), 648 + ))); 649 + } 650 + let a = policy.assess(&input(&burst)); 651 + prop_assert!((0.0..=1.0).contains(&a.combined)); 455 652 } 456 653 } 457 654 }