From 284282350bc81db062dac0638a576b260fc621a9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 5 Sep 2026 00:17:13 +0200 Subject: [PATCH] Hold, do not tap -- and stop spending the verdict on a frame that cannot carry it Jorijn worked out the technique and it changes what every number in this project means: "press and LIFT (quick tap) is wrong, holding the sensor until it gives the result is a 100% success rate." The logs agree, on a properly controlled comparison. Same template, same session, learning off for all four blocks, only the technique differing: tapped 4/15 and 3/15, held 15/15 and 15/15. The frame data says why. Over every frame this project has a verdict for, split at 2.5x the idle floor: full contact, interrupt settled 78/175 = 45% match full contact, interrupt asserted 28/142 = 20% partial, interrupt settled 1/9 = 11% partial, interrupt asserted 0/53 = 0% A tap is caught while the finger is still arriving or already leaving. Such a frame is not a hard verdict waiting to happen, it is a wasted one: with the rescan budget at 0 every frame is terminal, so its rejection ends the press. 62 partial frames produced exactly one match between them. So the tracker becomes a Schmitt trigger. A press now STARTS on settled contact and ENDS on the finger leaving, which means a frame taken mid-landing produces no event at all rather than a false rejection. A press that never settles simply yields no verdict and the loop waits for the next one, which is an honest try again. Enrolment is untouched: it passes one threshold for both and keeps its own sample-quality gate inside the trustlet. fptrial.sh now says hold, and defaults to fifteen presses. Instructing a tap for its whole life is what quietly made every rate this project has quoted a worst case, and a tap is not a case the product has -- nobody taps a phone sensor and walks away, they rest a finger until it unlocks. --- implementations/main.cpp | 22 ++++++++++----- interfaces/Fingerprintd-Engine.cppm | 42 ++++++++++++++++++++++++++++- packaging/fptrial.sh | 27 ++++++++++++++++--- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/implementations/main.cpp b/implementations/main.cpp index b260a6f..474d0ac 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1189,8 +1189,9 @@ public: std::println(std::cerr, "baseline did not calibrate (floor stayed 0)"); return false; } - std::println("idle floor = {}, finger threshold = {}", baseline_.Floor(), - baseline_.Threshold()); + std::println("idle floor = {}, finger threshold = {}, settled threshold = {}", + baseline_.Floor(), baseline_.Threshold(), + baseline_.SettledThreshold()); return true; } @@ -1373,6 +1374,10 @@ public: std::uint32_t fid = 0; int presses = 0; int frames = 0; + // Frames that showed a finger arriving but were not settled enough to + // spend a verdict on. A press made only of these is a "try again", not + // a rejection. + int skippedUnsettled = 0; }; // `accept` is the set of fids that count as a match for THIS request. The @@ -1404,7 +1409,7 @@ public: en::TouchTracker tracker; harvested_ = 0; // no fold has happened in this session yet bool inPress = false, pressMatched = false, pressRejected = false; - int pressFrames = 0, rescans = 0; + int pressFrames = 0, rescans = 0, skipped = 0; std::uint32_t pressFid = 0; auto t0 = std::chrono::steady_clock::now(); auto msSince = [&](auto t) { @@ -1424,8 +1429,11 @@ public: ta::BuildCapturePayload(cap); auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap); bool finger = baseline_.IsFinger(c.metric); + // A press begins only once contact is settled; see TouchTracker. + bool settled = baseline_.IsSettled(c.metric); fingerPresent_.store(finger); out.frames++; + if (finger && !settled && !tracker.FingerDown()) skipped++; std::string note; // No recapture on the rising edge. It was tried, on the theory that @@ -1435,7 +1443,7 @@ public: // still flagged FINGER from the first capture) and an empty image // was reported to the matcher. A guaranteed miss on exactly the // case it was meant to fix. - for (ta::Event ev : tracker.Observe(finger, en::Mode::Authenticate)) { + for (ta::Event ev : tracker.Observe(finger, settled, en::Mode::Authenticate)) { if (ev == ta::Event::FingerTouched) { inPress = true; pressMatched = false; pressRejected = false; pressFrames = 0; rescans = 0; pressFid = 0; @@ -1521,9 +1529,11 @@ public: else std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs)); } fingerPresent_.store(false); - std::println(" verify loop: {} frames in {} ms ({} ms/frame incl. {} ms gap)", + std::println(" verify loop: {} frames in {} ms ({} ms/frame incl. {} ms gap){}", out.frames, msSince(t0), - out.frames ? msSince(t0) / out.frames : 0, g_frameGapMs); + out.frames ? msSince(t0) / out.frames : 0, g_frameGapMs, + skipped ? std::format(", {} unsettled frame(s) skipped", skipped) : ""); + out.skippedUnsettled = skipped; if (cancel) { SendCommand(app_, ta::Cmd::Cancel, {}); out.cancelled = true; diff --git a/interfaces/Fingerprintd-Engine.cppm b/interfaces/Fingerprintd-Engine.cppm index 45e6c51..07d4605 100644 --- a/interfaces/Fingerprintd-Engine.cppm +++ b/interfaces/Fingerprintd-Engine.cppm @@ -69,9 +69,33 @@ export namespace fingerprintd::engine { } } + // A SECOND, higher bar: contact good enough to spend the press's one + // terminal verdict on. Detection at 1.5x answers "is a finger there"; + // this answers "is it all the way down and still". + // + // Measured over every frame this project has a verdict for (2026-09-05, + // n=379), split at 2.5x the floor: + // + // full contact, interrupt settled 78/175 = 45% match + // full contact, interrupt asserted 28/142 = 20% + // partial, interrupt settled 1/9 = 11% + // partial, interrupt asserted 0/53 = 0% + // + // A partial frame is not a hard verdict waiting to happen, it is a + // WASTED one: with max_authentication_rescan_times at 0 every frame is + // terminal, so a partial frame's rejection ends the press. 62 partial + // frames produced exactly one match. Skipping them costs essentially + // nothing and saves 61 killed presses. + static constexpr std::int32_t SettledNum = 5; + static constexpr std::int32_t SettledDen = 2; + bool Ready() const { return seen_ >= want_ && floor_ > 0; } std::int32_t Floor() const { return floor_; } std::int32_t Threshold() const { return floor_ * MultiplierNum / MultiplierDen; } + std::int32_t SettledThreshold() const { return floor_ * SettledNum / SettledDen; } + bool IsSettled(std::int32_t metric) const { + return Ready() && metric >= SettledThreshold(); + } // Nothing is a finger until the floor is known. An uncalibrated // Baseline reports false for everything rather than inventing a @@ -115,8 +139,24 @@ export namespace fingerprintd::engine { class TouchTracker { public: // Returns the events to report for this frame, in order. - std::vector Observe(bool finger, Mode mode) { + // + // TWO thresholds, deliberately: a press STARTS on settled contact and + // ENDS on the finger leaving. A Schmitt trigger, and the reason is + // measured -- a frame taken while the finger is still arriving matches + // 0 times in 53, and at a rescan budget of 0 that rejection is terminal + // and ends the press. Starting the press on the settled frame instead + // spends the verdict on an image that can actually carry it. A press + // that never settles produces no event at all, which is an honest + // "try again" rather than a false rejection. + // + // `settled` defaults to `present` so enrolment, which has its own + // sample-quality gate inside the trustlet, is unchanged. + std::vector Observe(bool present, Mode mode) { + return Observe(present, present, mode); + } + std::vector Observe(bool present, bool settled, Mode mode) { std::vector out; + bool finger = prev_ ? present : settled; // enter high, leave low bool rising = finger && !prev_; bool falling = !finger && prev_; if (rising) diff --git a/packaging/fptrial.sh b/packaging/fptrial.sh index 5b7b145..67e7540 100644 --- a/packaging/fptrial.sh +++ b/packaging/fptrial.sh @@ -1,17 +1,36 @@ #!/bin/sh # fptrial.sh -- a labelled verification protocol against fingerprintd. # -# fptrial.sh [correct_taps] [wrong_taps] defaults 10 and 5 +# fptrial.sh [correct_presses] [wrong_presses] defaults 15 and 0 # -# Runs fprintd-verify once per tap and tells you which finger to use before +# Runs fprintd-verify once per press and tells you which finger to use before # each one. The daemon's transcript records every frame; THIS records the label # and the wall-clock time from "press now" to the client seeing a result, which # is the latency a user feels. Unlabelled runs cannot be turned into a rate. -C=${1:-10}; W=${2:-5} +# +# HOLD, DO NOT TAP -- and this script said the opposite for its whole life, +# which quietly made every rate this project has ever quoted a worst case. +# +# Jorijn, 2026-09-05: "press and LIFT (quick tap) is wrong, holding the sensor +# until it gives the result is a 100% success rate." The logs agree and say why. +# Same template, same session, learning off, only the technique differing: +# +# tapped 4/15 and 3/15 +# held 15/15 and 15/15 +# +# The mechanism is in the frame metrics. A tap is caught while the finger is +# still arriving or already leaving, and such a frame matched 0 times in 53; +# with the rescan budget at 0 that rejection is terminal and ends the press. A +# held finger yields a full-contact frame, and those match. +# +# It is also what a real user does: nobody taps a phone's fingerprint sensor and +# walks away, they rest a finger until it unlocks. Measuring taps was measuring +# a case the product does not have. +C=${1:-15}; W=${2:-0} OUT=/tmp/fptrial-$(date +%Y%m%d-%H%M%S).log now() { awk '{gsub(/\./,""); print $1 "0000000"}' /proc/uptime; } run() { # $1 = label - printf '\n>>> %s -- press and LIFT (quick tap) ... ' "$1" + printf '\n>>> %s -- press and HOLD until it answers ... ' "$1" t0=$(now) res=$(timeout 20 fprintd-verify user 2>&1 | grep -E 'Verify result' | tail -1) t1=$(now)