a11oy / packages /perception-loop /src /liveness.ts
betterwithage's picture
sync(space): complete build context — fix BUILD_ERROR (CTO)
518343a verified
Raw
History Blame Contribute Delete
3.69 kB
/**
* Liveness state machine — turns a sliding window of frame signals into
* a calibrated `livenessConfidence` with explicit reasons.
*
* Re-expressed from Human.js's iris/blink/head-pose deltas (see
* docs/research/perception-bio-synthesis-2026.md §1). The state machine
* is deliberately simple so it composes with the receipt schema and is
* cheap to run on-device. The formula is mirrored in
* `@szl-holdings/lambda-math` as `gazeStability` for the receipt-side
* monotone-confidence check.
*/
import type { LivenessSummary } from './envelope.js';
export interface FrameSignal {
/** Timestamp (millis since epoch). */
readonly tMs: number;
/** Iris motion magnitude (image-relative pixels per ms). */
readonly irisMotion: number;
/** Eye-aperture ratio (0 = fully closed, 1 = fully open). */
readonly eyeAperture: number;
/** Head-pose delta vs. last frame (radians). */
readonly headPoseDelta: number;
}
export interface LivenessOptions {
/** Sliding window in millis (default: 2000ms). */
readonly windowMs?: number;
/** Min blink count required for a positive blink reason. */
readonly minBlinks?: number;
/** Eye-aperture threshold below which the eye is "closed". */
readonly blinkAperture?: number;
/** Min iris-motion variance for a positive saccade reason. */
readonly minIrisVariance?: number;
/** Min head-pose delta sum for a positive head-motion reason. */
readonly minHeadPoseDelta?: number;
}
const DEFAULTS: Required<LivenessOptions> = {
windowMs: 2000,
minBlinks: 1,
blinkAperture: 0.2,
minIrisVariance: 0.001,
minHeadPoseDelta: 0.05,
};
/**
* Compute liveness over the most recent `windowMs` of frame signals.
*
* Confidence is the count of satisfied liveness criteria divided by the
* total number of criteria (3): blink, iris motion, head pose. Monotone
* non-decreasing in each criterion — adding a satisfied signal can only
* raise the confidence, never lower it. This is the property mirrored in
* `gazeStability` and used in `peak.classification.v1` cutoff arguments.
*/
export function computeLiveness(
signals: readonly FrameSignal[],
options: LivenessOptions = {},
): LivenessSummary {
const opts = { ...DEFAULTS, ...options };
if (signals.length === 0) {
return { livenessConfidence: 0, livenessReasons: [], windowMs: opts.windowMs };
}
const lastT = signals[signals.length - 1]!.tMs;
const windowStart = lastT - opts.windowMs;
const window = signals.filter((s) => s.tMs >= windowStart);
// Blink detection: eye aperture dips below threshold then recovers.
let blinks = 0;
let inBlink = false;
for (const s of window) {
if (s.eyeAperture < opts.blinkAperture && !inBlink) {
inBlink = true;
} else if (s.eyeAperture >= opts.blinkAperture && inBlink) {
inBlink = false;
blinks++;
}
}
// Iris motion variance.
const mean = window.reduce((acc, s) => acc + s.irisMotion, 0) / window.length;
const variance = window.reduce((acc, s) => acc + (s.irisMotion - mean) ** 2, 0) / window.length;
// Head-pose delta sum.
const headDelta = window.reduce((acc, s) => acc + Math.abs(s.headPoseDelta), 0);
const reasons: string[] = [];
let satisfied = 0;
if (blinks >= opts.minBlinks) {
satisfied++;
reasons.push(`blinks=${blinks}`);
}
if (variance >= opts.minIrisVariance) {
satisfied++;
reasons.push(`iris-variance=${variance.toExponential(2)}`);
}
if (headDelta >= opts.minHeadPoseDelta) {
satisfied++;
reasons.push(`head-pose-delta=${headDelta.toFixed(3)}`);
}
return {
livenessConfidence: satisfied / 3,
livenessReasons: reasons,
windowMs: opts.windowMs,
};
}