Judge verification per press, and log every frame of it

A wrong finger against the daemon was a silent wait: the loop exited only on a
terminal verdict, and at the stock rescan budget a wrong finger never produces
one -- it answers "not identified yet" on every frame until the right finger
shows up. fprintd's PAM module needs a verify-no-match to deny or retry, so
that is a client that hangs.

The unit of decision is now a press. Within one press the matcher may reject
early frames and match a later one, so a press is judged when the finger lifts:
any match wins, only rejections is no-match, no terminal frame at all is
undecided and scanning continues. A press that matches is reported the moment
it does rather than making the user hold for a release.

Undecided presses are exactly why the rescan budget matters here. At 0 every
frame is terminal and every press decides; at the stock budget seven of
nineteen correct-finger presses ended undecided in the last measurement, which
under a press rule would read as rejections. So the daemon is being run with
rescan forced to 0 while that trade-off is measured on the new template rather
than assumed.

Every frame is logged under --verbose and every press always, so the next
wrong-finger control is visible in the transcript instead of absent from it.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 22:24:12 +02:00
commit 50617f994f

View file

@ -1200,14 +1200,27 @@ public:
// ---- Verification
//
// Runs until the first terminal verdict or a cancel. A frame is one of
// three things and only the third is a verdict: release (poison intact),
// rescan (rc=-11), or match/reject.
// The unit of decision is a PRESS, not a frame. A frame is one of three
// things -- release (poison intact), rescan (rc=-11), match/reject -- and
// within one press the matcher may reject early frames and match a later
// one, so a press is judged when the finger LIFTS: any match wins; only
// rejections means no-match; no terminal frame at all means undecided,
// and scanning continues into the next press.
//
// That last case is why the rescan budget matters here. At the stock
// budget a wrong finger answers "not identified yet" on every frame and
// never yields a terminal rejection, so its presses are all undecided and
// a client waits forever -- fprintd's PAM module needs a verify-no-match
// to deny or retry. With max_authentication_rescan_times at 0 every frame
// is terminal and every press decides. A wrong finger stops being a
// silent wait.
struct VerifyOutcome {
bool decided = false;
bool matched = false;
bool cancelled = false;
std::uint32_t fid = 0;
int presses = 0;
int frames = 0;
};
VerifyOutcome Verify(std::uint32_t gid, std::atomic<bool>& cancel, int maxFrames) {
@ -1226,6 +1239,10 @@ public:
if (!a.Ok()) return out;
en::TouchTracker tracker;
bool inPress = false, pressMatched = false, pressRejected = false;
int pressFrames = 0, rescans = 0;
std::uint32_t pressFid = 0;
// The reference frame loop is {QUERY, CAPTURE, REPORT, QUERY, REPORT}.
// The trailing query acknowledges the trustlet's event state; without
// it, after the first verdict every later frame answers "not
@ -1239,8 +1256,15 @@ public:
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
bool finger = baseline_.IsFinger(c.metric);
fingerPresent_.store(finger);
out.frames++;
std::string note;
for (ta::Event ev : tracker.Observe(finger, en::Mode::Authenticate)) {
if (ev == ta::Event::FingerTouched) {
inPress = true; pressMatched = false; pressRejected = false;
pressFrames = 0; rescans = 0; pressFid = 0;
out.presses++;
}
std::vector<std::byte> evbuf(ta::EventContextSize);
ta::BuildEventContext(evbuf, { .event = ev });
// A zero-initialised buffer cannot tell "the matcher never
@ -1250,14 +1274,35 @@ public:
auto r = SendCommand(app_, ta::Cmd::ReportEvent, evbuf);
if (!r.invoked) continue;
ta::Verdict v = ta::Classify(r.rc, r.fid);
if (ta::IsTerminal(v)) {
out.decided = true;
out.matched = (v == ta::Verdict::Match);
out.fid = out.matched ? r.fid : 0;
std::println(" verdict: {} fid={}", out.matched ? "MATCH" : "REJECTED", out.fid);
break;
switch (v) {
case ta::Verdict::Match: pressMatched = true; pressFid = r.fid; note += " MATCH"; break;
case ta::Verdict::Rejected: pressRejected = true; note += " rej"; break;
case ta::Verdict::NotIdentifiedYet: rescans++; note += " -11"; break;
case ta::Verdict::MatcherNeverRan: break;
}
if (ev == ta::Event::FingerReleased && inPress) {
// The press is over: judge it.
inPress = false;
if (pressMatched) {
out.decided = true; out.matched = true; out.fid = pressFid;
} else if (pressRejected) {
out.decided = true; out.matched = false;
}
std::println(" press {}: {} frames, {} rescans -> {}", out.presses, pressFrames,
rescans, pressMatched ? std::format("MATCH fid={}", pressFid)
: pressRejected ? "NO MATCH" : "undecided");
}
}
if (finger) pressFrames++;
// A press that matched is decided the moment it does; do not make
// the user keep holding for a release.
if (pressMatched && !out.decided) {
out.decided = true; out.matched = true; out.fid = pressFid;
std::println(" press {}: {} frames -> MATCH fid={}", out.presses, pressFrames, pressFid);
}
if (g_verbose)
std::println(" frame {:3}: metric={:<4}{}{}", i + 1, c.metric,
finger ? " FINGER" : " ", note);
SendCommand(app_, ta::Cmd::QueryEventStatus, q);
std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs));
}
@ -1434,6 +1479,9 @@ private:
}
case Job::Kind::Verify: {
auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600);
std::println("verify: {} over {} press(es), {} frame(s)",
o.cancelled ? "cancelled" : !o.decided ? "undecided"
: o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::VerifyStatus });
ev->done = true;
ev->fid = o.fid;