Learn from a matched press, as stock does

Template learning, the half of the algorithm this daemon never ran. Every match
rate measured against this device -- 1/10, 2/10, 3/10, 7/10 -- was measured
against a day-zero template that no stock user lives with, because a stock
template is rewritten on every successful press and ours never moved a byte.

HarvestTemplate mirrors stock's post-match loop: while the finger is still on
the sensor, capture and fold, sending no event so the matcher cannot re-run and
revise a verdict the client already has. A quick tap pays almost nothing, since
the finger is gone by the time a verdict lands and the first capture reads the
idle floor; a held press contributes the frames it was actually held for.

That last property is why this subsumes the enrolment-tuning thread. The working
hypothesis after the strict-threshold and 30-sample failures was that enrolment
conditions have to resemble verification conditions. Learning is exactly that,
done by the algorithm from real unlock presses instead of by coaching a user
into positions they never use.

FlushTemplate issues the SAVE_DATA, and the worker calls it after the verdict is
posted rather than before. Persisting a learned template is roughly 350 ms of
gpfile and RPMB traffic and it does not belong on an unlock path; stock defers it
the same way, with a lazy-updater timer.

--learn=0 turns the whole thing off, which is the only way the comparison is
single-variable: learning is cumulative, so an A/B needs one binary and one
template lineage rather than two builds. --learn-frames bounds what one press may
contribute, because the template body moves as a single gpfile op against a
516084-byte listener buffer and a 30-sample body already measured 386402.

--probe-learn sends one UPDATE_TEMPLATE with no finger and then ENUMERATEs to
prove the trustlet survived it, which is the part a bare return code cannot say.
This commit is contained in:
Jorijn van der Graaf 2026-09-03 17:45:58 +02:00
commit e2033ed26d

View file

@ -101,6 +101,19 @@ bool g_undecidedIsNoMatch = false;
bool g_irqObserve = false;
bool g_edgeWake = false;
// TEMPLATE LEARNING: fold the frames of a successful press back into the
// stored template, as stock does. On by default because stock does it and
// because every match rate this project has measured was taken against a
// day-zero template; `--learn=0` turns it off so the two can be compared on
// one binary without a rebuild, which is the only way the comparison is
// single-variable.
bool g_learn = true;
// How many frames one press may contribute. Stock has no explicit bound -- it
// harvests until the finger lifts -- but a finger left resting on the sensor
// should not grow the template without limit, and the template body has a hard
// ceiling: it moves as a SINGLE gpfile op against a 516084-byte listener
// buffer, and a 30-sample body already measured 386402.
int g_learnMaxFrames = 8;
// The namespace key the trustlet hashes into the SFS group's directory name.
// It defaults to Android's because that is what this device's existing store
@ -1453,9 +1466,95 @@ public:
SendCommand(app_, ta::Cmd::Cancel, {});
out.cancelled = true;
}
// A press that matched is the only material template learning gets.
if (out.matched && g_learn) HarvestTemplate(g_learnMaxFrames);
return out;
}
// ---- Template learning
//
// Stock's post-match harvest, and the whole reason a stock template grows
// over its life: while the finger is STILL on the sensor after a match,
// keep capturing and fold each frame into the loaded template. Stock's
// loop is {QUERY_FINGER_STATUS, CAPTURE_IMAGE, UPDATE_TEMPLATE} and it
// sends NO event -- so the matcher does not run again and this cannot
// revise the verdict the client has already been given.
//
// A quick tap pays almost nothing: the finger is gone by the time a
// verdict lands, so the first capture reads the idle floor and the loop
// stops. A held press contributes the frames it was actually held for.
// Which means the frames learned from are exactly the frames a real
// unlock produces -- the "enrolment must resemble verification" problem,
// solved by the algorithm instead of by coaching the user.
int HarvestTemplate(int maxFrames) {
namespace ta = fingerprintd::ta;
int folded = 0;
for (int i = 0; i < maxFrames; i++) {
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
ta::BuildCapturePayload(cap);
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
if (!c.invoked || c.result != 0) break;
if (!baseline_.IsFinger(c.metric)) {
if (g_verbose)
std::println(" learn: finger gone (metric={}), {} frame(s) folded",
c.metric, folded);
break;
}
std::vector<std::byte> up(ta::UpdateTemplatePayloadSize);
// Bit 6 on the first frame, mirroring stock: it marks the frame
// whose reported event was FingerTouched, and it selects which of
// the algorithm's two update entries runs.
ta::BuildUpdateTemplate(up, static_cast<std::uint32_t>(folded), folded == 0);
auto u = SendCommand(app_, ta::Cmd::UpdateTemplate, up);
if (!u.Ok()) {
std::println(" learn: UPDATE_TEMPLATE rc={} ({}) result={} -- stopping",
u.rc, ta::StrError(u.rc), static_cast<int>(u.result));
break;
}
folded++;
if (g_verbose)
std::println(" learn: frame {} folded in (metric={})", folded, c.metric);
}
if (folded > 0) templateDirty_ = true;
std::println(" learn: {} frame(s) folded into the template{}", folded,
templateDirty_ ? ", save pending" : "");
return folded;
}
// Persist what the harvest folded in. Stock does this lazily -- it arms a
// timer on the match ("lazy data updater activated") and the SAVE_DATA
// lands at the next authenticate, taking 356 ms and rewriting the template
// plus the share template plus the chip calibration. Deferring it matters
// for the same reason it does on stock: the client already has its answer,
// and a third of a second of RPMB traffic does not belong on the unlock
// path. Here the worker calls this after it has posted the verdict.
bool FlushTemplate() {
namespace ta = fingerprintd::ta;
if (!templateDirty_) return true;
if (g_sfsReadOnly || !g_rpmbWrite) {
std::println(" learn: learned frames DISCARDED -- the store is read-only");
templateDirty_ = false;
return false;
}
std::vector<std::byte> sd(0x10, std::byte{0});
for (std::size_t k = 0; k < 4; k++)
sd[k] = static_cast<std::byte>((ta::SaveMaskTemplate >> (8 * k)) & 0xFF);
auto t0 = std::chrono::steady_clock::now();
auto sv = SendCommand(app_, ta::Cmd::SaveData, sd);
Report(ta::Cmd::SaveData, sv);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
templateDirty_ = false;
if (!sv.Ok()) {
std::println(" learn: the learned template did NOT persist (rc={})", sv.rc);
return false;
}
std::println(" learn: template saved in {} ms", ms);
return true;
}
bool TemplateDirty() const { return templateDirty_; }
bool FingerPresent() const { return fingerPresent_.load(); }
// Before SyncConfig has run there is no answer yet; stock's value is the
// only honest stand-in, and a client asking this early gets it.
@ -1556,6 +1655,8 @@ private:
Sensor sensor_;
fingerprintd::engine::Baseline baseline_;
std::uint32_t gid_ = 0xFFFFFFFF;
// Set when a harvest folded at least one frame in; cleared by the save.
bool templateDirty_ = false;
std::atomic<bool> fingerPresent_{false};
std::thread irqThread_;
std::atomic<bool> irqQuit_{false};
@ -1699,6 +1800,11 @@ private:
else if (o.matched) ev->status = "verify-match";
else ev->status = "verify-no-match";
PostEvent(std::move(ev));
// AFTER the verdict is on its way to the client, never before:
// persisting a learned template is ~350 ms of gpfile and RPMB
// traffic and it must not sit on the unlock path. Stock defers
// it the same way, with a timer.
session_.FlushTemplate();
break;
}
}
@ -2207,13 +2313,43 @@ int RunDaemon() {
// -----------------------------------------------------------------------------
// Diagnostic modes: the probe flow, kept for a phone with no bus client at hand.
// -----------------------------------------------------------------------------
int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, std::uint32_t gid, int frames) {
int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, bool doLearnProbe,
std::uint32_t gid, int frames) {
namespace ta = fingerprintd::ta;
Session s;
if (!s.Start()) return 1;
int n = s.SetActiveGroup(gid);
(void)n;
// Does the trustlet accept UPDATE_TEMPLATE at all? NO FINGER NEEDED, and
// that is the point: this project's record says "DO NOT RETRY 0x1015 until
// a template is loaded" because every earlier shape answered -90, i.e. the
// app was gone. One template is loaded now, so this asks the question for
// the cost of one command -- and ENUMERATE afterwards proves whether the
// trustlet survived it, which is the part a bare rc cannot tell you.
if (doLearnProbe) {
if (n <= 0)
std::println("\n*** no template loaded (ENUMERATE={}) -- 0x1015 reads the "
"template AFTER the update call and faults without one. "
"Refusing to probe. ***", n);
else {
std::println("\n=== UPDATE_TEMPLATE (no finger; {} template(s) loaded) ===", n);
std::vector<std::byte> up(ta::UpdateTemplatePayloadSize);
ta::BuildUpdateTemplate(up, 0, /*touchFrame*/ true);
Report(ta::Cmd::UpdateTemplate,
SendCommand(s.App(), ta::Cmd::UpdateTemplate, up));
std::println(" and again as a held frame (bit 6 clear, the x_update path):");
ta::BuildUpdateTemplate(up, 1, /*touchFrame*/ false);
Report(ta::Cmd::UpdateTemplate,
SendCommand(s.App(), ta::Cmd::UpdateTemplate, up));
// The liveness probe. -90 here means the trustlet took a fault.
auto e = SendCommand(s.App(), ta::Cmd::Enumerate, {});
Report(ta::Cmd::Enumerate, e);
std::println(" trustlet {} (ENUMERATE rc={})",
e.invoked && e.rc >= 0 ? "SURVIVED" : "IS GONE", e.rc);
}
}
if (doCalSave) {
if (g_sfsReadOnly) { std::println(std::cerr, "a calibration save writes; pass --sfs-writable"); return 1; }
std::vector<std::byte> sd(0x10, std::byte{0});
@ -2235,6 +2371,10 @@ int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, std::uint32_t gid, int f
for (int c = 3; c > 0; c--) { std::println("*** press your finger in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); }
auto o = s.Verify(gid, cancel, frames, {}); // probe: any template counts
std::println("verify: decided={} matched={} fid={}", o.decided, o.matched, o.fid);
// The probe is the cheapest end-to-end test of template learning:
// Verify harvests, this persists, and the container on disk should
// come back larger than it went in.
s.FlushTemplate();
}
s.Stop();
return 0;
@ -2287,6 +2427,7 @@ int RunProbeTaLoad(const std::string& path) {
int main(int argc, char** argv) {
std::span<char*> args(argv, static_cast<std::size_t>(argc));
bool probe = false, daemon = false, doAuth = false, doEnrol = false, doCalSave = false;
bool doLearnProbe = false;
std::string probeTa;
std::uint32_t gid = 0;
int frames = 120;
@ -2305,6 +2446,7 @@ int main(int argc, char** argv) {
if (a == "--auth") { doAuth = true; probe = true; }
if (a == "--enrol") { doEnrol = true; probe = true; }
if (a == "--cal-save") { doCalSave = true; probe = true; g_verbose = true; }
if (a == "--probe-learn") { doLearnProbe = true; probe = true; g_verbose = true; }
if (a.starts_with("--frames=")) frames = std::stoi(std::string(a.substr(9)));
if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12)));
if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true;
@ -2317,13 +2459,15 @@ int main(int argc, char** argv) {
if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9)));
if (a.starts_with("--group-path=")) g_groupPath = a.substr(13);
if (a.starts_with("--samples=")) { g_samples = std::stoi(std::string(a.substr(10))); g_samplesForced = true; }
if (a.starts_with("--learn=")) g_learn = a.substr(8) != "0";
if (a.starts_with("--learn-frames=")) g_learnMaxFrames = std::stoi(std::string(a.substr(15)));
if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11);
if (a.starts_with("--gid=")) gid = static_cast<std::uint32_t>(std::stoul(std::string(a.substr(6))));
}
StartTranscript(g_logDir);
if (!probeTa.empty()) return RunProbeTaLoad(probeTa);
if (daemon) return RunDaemon();
if (probe) return RunProbe(doAuth, doEnrol, doCalSave, gid, frames);
if (probe) return RunProbe(doAuth, doEnrol, doCalSave, doLearnProbe, gid, frames);
std::println(std::cerr,
"fingerprintd {}\n"
@ -2331,6 +2475,9 @@ int main(int argc, char** argv) {
" --probe-tee [--gid=N] bring the session up and report\n"
" --probe-ta-load=PATH load one TA image and report the loader result\n"
" --auth | --enrol | --cal-save diagnostic loops (see README)\n"
" --probe-learn send one UPDATE_TEMPLATE, no finger needed\n"
" --learn=0|1 [--learn-frames=N] fold a matched press back into the\n"
" template, as stock does (default on, 8)\n"
" --sfs-root=DIR --sfs-writable --rpmb-write storage policy",
Version);
return 1;