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.
This commit is contained in:
Jorijn van der Graaf 2026-09-05 00:17:13 +02:00
commit 284282350b
3 changed files with 80 additions and 11 deletions

View file

@ -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;

View file

@ -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<Event> 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<Event> Observe(bool present, Mode mode) {
return Observe(present, present, mode);
}
std::vector<Event> Observe(bool present, bool settled, Mode mode) {
std::vector<Event> out;
bool finger = prev_ ? present : settled; // enter high, leave low
bool rising = finger && !prev_;
bool falling = !finger && prev_;
if (rising)

View file

@ -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)