Skip to content

Show gallery reference face on Unknown "looks like" cues - #34

Merged
NerdyHank merged 8 commits into
SkyTechNerds:mainfrom
Alien10140:looks-like-ref-face
Oct 7, 2026
Merged

NerdyHank merged 8 commits into
SkyTechNerds:mainfrom
Alien10140:looks-like-ref-face

Conversation

@Alien10140

Copy link
Copy Markdown
Contributor

Summary

  • Persist guess_top_photo (highest single-photo similarity for the winning person) whenever an unknown gets guess / guess_score.
  • Unknown review shows that gallery face as a thumbnail next to looks like and ★ Looks like headers; hover enlarges; click uses the existing full viewer.
  • Fallback: newest gallery file if the stored photo is missing.

Makes role+hex stranger IDs (e.g. after Track as new) visually identifiable without jumping to the Persons tab.

Test plan

  • Fresh unknown with a gallery guess shows a thumb next to the cue
  • Hover enlarges the same photo; click opens lightbox
  • After assign/ignore/refresh_guesses, remaining unknowns gain guess_top_photo
  • Suggestion groups (≥ threshold) also show the cue
  • No Track as new / Assign behavior changes in this PR

Made with Cursor

@the-codemole

the-codemole Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🧪 Automated PR Checks

Profile: python-app · auto-detected · ⚙ Configurable

✅ python-syntax — 12 Python file(s) compile cleanly
✅ ruff — 12 Python file(s) — ruff clean (E9/F)
⚪ json-valid — ⏭️ No JSON files in the diff
✅ diff-size — Diff +557/-53 in 17 files
✅ secret-scan — No plaintext secrets in the added lines
✅ conflict-markers — No merge conflict markers
✅ sensitive-files — No sensitive file types in the diff

hermes-work · branch looks-like-ref-face · base main · What do these checks do?

the-codemole[bot]
the-codemole Bot previously approved these changes Oct 6, 2026

@the-codemole the-codemole Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — review complete.

  • Checks: 6 passed, 1 skipped, 0 failed
  • No findings, no open threads

@NerdyHank

Copy link
Copy Markdown
Contributor

CI is red — one missed spot, and it is only the test fixture.

Gallery.match now returns four values, and you did adapt every production caller — backfill.py:82, gallery.py:1042, history.py:286, mqtt_listener.py:378, webui.py:302. I checked each one rather than assuming. What stayed at three is the fake in tests/test_local_event_processor.py:29:

ValueError: not enough values to unpack (expected 4, got 3)
class FakeGallery:
    def __init__(self, match=(None, None, 0.0), ignored=0.0):   # -> (None, None, 0.0, None)

Both the default and the tuples the three tests pass in need the fourth element. Three errors, all from that one line.

I can push the fix to your branch if you prefer — maintainer_can_modify is on. Say the word, otherwise it is yours.

Two things I noticed while reading, neither blocking:

top_i = int(np.argmax(sims)) takes the best single photo, while score is the mean of the top k. So the thumbnail can come from a photo that is not among the ones that decided the match. That is the right choice for a visual cue — you want the most recognisable face, not the most average one — but it is worth a comment, because the next reader will wonder why the two disagree.

And e["files"][top_i] indexes the file list by the position in sims. That holds as long as both are built in the same order from the same source. If it is, fine; if the ordering is ever decoupled, this silently shows the wrong person's face, which is the worst failure mode for a feature whose whole job is "is this the right person?". Worth a short assertion or a comment naming the invariant.

The feature itself is a good call — with the hex-suffixed names from #32, a thumbnail is what makes those IDs usable at all.

Persist guess_top_photo (best single-photo match) with each guess and
render a thumb + hover enlarge on rest clusters and suggestion headers
so role+hex stranger IDs are visually identifiable during review.
@Alien10140
Alien10140 force-pushed the looks-like-ref-face branch from 0223ef1 to b233885 Compare October 6, 2026 15:55
@Alien10140

Copy link
Copy Markdown
Contributor Author

Rebased onto main and fixed the FakeGallery 4-tuple in tests/test_local_event_processor.py (default + Alice case). Also added a short comment in Gallery.match clarifying that top_photo is argmax single-photo (not one of the top-k mean contributors) and that files[i] stays aligned with emb[i] / sims[i].

Thanks for the catch — CI should be green on b233885.

Comment thread app/gallery.py
Comment thread app/mqtt_listener.py Outdated
@the-codemole
the-codemole Bot dismissed their stale review October 6, 2026 15:57

No longer clean — approval withdrawn.

Store the winning person slug next to guess_top_photo so the Unknown UI
does not re-derive the gallery folder from the display name. Only write
those keys when a photo exists (same convention as refresh_guesses), and
hide a broken thumb if the file is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Alien10140

Copy link
Copy Markdown
Contributor Author

Addressed the two open findings on looks-like-ref-face:

  1. Slug with photo — unknowns now persist guess_top_slug alongside guess_top_photo (both only when a winner photo exists). UI prefers guess_top_slug over name→slug. Missing/trimmed file falls back to newest remaining gallery file; broken image is hidden via onerror.
  2. Null vs missing key — ingest/backfill/mqtt write sites no longer emit "guess_top_photo": null; keys are omitted unless truthy, matching refresh_guesses.

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @​codemole recheck in your comment triggers a full re-run of the checks, so the FakeGallery 4-tuple fix on b233885 gets picked up automatically.

Both fixes read correctly in the diff: guess_top_slug/guess_top_photo are written as a pair only when both are truthy, and the {k: v for k, v in {...}.items() if k not in (...) or v} filters in backfill.py and mqtt_listener.py now match the pop() behavior in refresh_guesses — no more null keys. The argmax-vs-top-k comment in Gallery.match makes the intent clear; the files[i] ↔ emb[i] alignment is an invariant of how _cache is populated, which is outside this diff, so I'm taking it on trust rather than confirming it.

Two small things:

  • Stale slug: the fallback covers a missing photo, but what happens if the whole person folder is gone (deleted/renamed person) while an unknown still carries its guess_top_slug? If onerror is the only net there, the thumb silently disappears — acceptable, just worth being deliberate about.
  • CHANGELOG.md picks up a stray blank line before ## 0.24.0 that has nothing to do with this change — easiest to drop before merge.

Comment thread app/gallery.py
Co-authored-by: Cursor <cursoragent@cursor.com>
@Alien10140

Copy link
Copy Markdown
Contributor Author

Follow-ups from the recheck note:

  • Deleted/renamed person with a stale guess_top_slug: intentional that the thumb hides (onerror / empty persons[slug]); the text cue still shows the guess name.
  • Dropped the stray blank line before ## 0.24.0 in CHANGELOG.

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @​codemole recheck in your comment triggers a full re-run, so the CHANGELOG tidy-up and the rebased state get picked up without another push.

On the stale-slug case: hiding the thumb is the right call, and it falls out of the design rather than needing a special path. If the person folder is gone, persons[slug] is empty so there is nothing to fall back to (the newest-file fallback only helps when the folder still exists and just that one file was trimmed), and onerror covers the race where the file vanishes between render and load. Keeping the text cue is consistent with guess / guess_score already being a snapshot from match time — only refresh_guesses recomputes them, and it rewrites or pops guess_top_photo/guess_top_slug as a pair, so the stale window closes on the next refresh.

One thing I can't verify from the diff: whether a rename keeps the slug stable. If renaming re-slugifies into a new folder, the old slug goes stale the same way a delete does — self-healing on the next refresh_guesses, so not a blocker either way, but worth a line in the test plan if renames are common in your flow. The CHANGELOG blank line is outside the shown hunk; I'll confirm it from the recheck result.

Call refresh_guesses after person/photo delete, set-aside, restore, and
non-dry dedupe so stored guess_top_slug/photo pointers do not dangle.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Alien10140

Copy link
Copy Markdown
Contributor Author

Third finding (dangling pointer): also wired refresh_guesses() into person/photo delete, set-aside, trimmed restore, and non-dry dedupe. UI already falls back / hides a broken thumb. Rename keeps the slug stable (display name only).

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @​codemole recheck picks up the new call sites without another push.

Two things on the refresh_guesses() wiring, from the code I can see in this diff (the new call sites themselves aren't in the shortened diff, so I can't confirm placement):

  • Cost per call. refresh_guesses() walks every unknown's JSON and re-runs match() on each embedding — O(unknowns × persons). That's fine once after a delete, but non-dry dedupe can drop many photos in one pass; make sure it's one call after the batch, not one per removed file.
  • Ordering vs. the cache. match() reads self._cache, so the refresh has to happen after the person's embeddings/files are reloaded — otherwise it can hand back the file that was just deleted and the dangling pointer survives the fix.

Also worth stating in the changelog if it isn't already: refresh_guesses() rewrites guess/guess_score as well, not just the two guess_top_* keys — so a photo delete can change the displayed guess name, which is a wider effect than "refresh dangling cues" suggests. Likely intended, just broader than the finding.

Rename staying on the display name matches refresh_guesses() writing m["guess"] = name each pass, so that one looks consistent.

@Alien10140

Copy link
Copy Markdown
Contributor Author

On the wiring notes:

  • Dedupe: single refresh_guesses() after the batch (if not dry and (pix or emb)), not per file.
  • Ordering: delete_face / set_aside / delete_person update _cache under the gallery lock before returning; webui calls refresh_guesses() only after those methods return, so match sees the post-delete cache.
  • Changelog already notes keys are refreshed when a reference photo is deleted/set aside; guess name can update as a consequence of that recompute — intended.

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @&#8203;codemole recheck triggers a full re-run, so no extra push needed.

All three notes resolve the open points for me:

  • Dedupe: one call after the batch gated on if not dry and (pix or emb) is the right shape — no per-file fan-out, and no call when nothing changed.
  • Ordering: if delete_face / set_aside / delete_person commit _cache under the gallery lock before returning, and the webui only calls refresh_guesses() after they return, then match() cannot see a pre-delete cache. That closes the stale-pointer window. I can't verify the call-site placement myself — the new webui lines aren't in the shortened diff — so that rests on your description.
  • Guess name drift: fine as documented behavior; the recompute is the whole point, and a changed guess is the honest result of a smaller gallery.

Residual, non-blocking: refresh_guesses() rewrites each unknown's JSON with a plain write_text, so an OSError partway through leaves the queue half-refreshed. Self-healing on the next call, and pre-existing — just worth knowing if you ever see mixed cue states after a failed delete.

Comment thread app/webui.py Outdated
request_refresh_guesses coalesces delete/set-aside/dedupe bursts into one
background refresh_guesses pass, and each metadata rewrite uses temp+replace
so a crash cannot truncate an unknown's embedding JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Alien10140

Copy link
Copy Markdown
Contributor Author

Addressed the sync-refresh finding:

  • Delete / set-aside / restore / dedupe now call request_refresh_guesses() (debounced background pass) instead of synchronous full rescans per request.
  • refresh_guesses metadata writes are atomic (*.json.tmp + os.replace).
  • Assign/unassign/ignore keep the existing synchronous refresh so the review UI updates immediately after those actions.

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @&#8203;codemole recheck in your comment triggers a full re-run, so the new wiring gets picked up without another push.

The debounce itself looks correct: _refresh_deadline is only touched under _refresh_sched_lock, a request arriving while the worker is alive just extends the deadline, and the exit path clears deadline and _refresh_thread in a single lock hold — so there's no window where a request is swallowed by a thread that's already on its way out. A stale _refresh_thread from an unexpected return is harmless because the spawn check uses is_alive().

One race the atomic write doesn't close, though: the tmp path is derived only from the target (jf.with_suffix(".json.tmp")), and refresh_guesses() is no longer single-threaded — you deliberately kept assign/unassign/ignore synchronous on the HTTP thread, and those can overlap with the debounced background pass. Two concurrent passes write the same tmp file for the same unknown, so one can os.replace while the other is still mid-write — which is exactly the truncated-JSON / lost-embedding case the tmp+replace was added to prevent. Fix is either a per-writer tmp name (pid/thread suffix, or tempfile.mkstemp(dir=self.unknown_dir)) or a dedicated lock held around the whole pass; the lock also stops two passes from duplicating the full rescan.

Minor and non-blocking: daemon=True plus the 0.4 s window means a refresh requested just before process exit is simply dropped, leaving dangling guess_top_* keys. Self-healing on the next sync refresh and the UI already hides a broken thumb, so cosmetic — just worth knowing it's not a durability guarantee.

the-codemole[bot]
the-codemole Bot previously approved these changes Oct 7, 2026

@the-codemole the-codemole Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — review complete.

  • Checks: 6 passed, 1 skipped, 0 failed
  • 4 finding(s) addressed, no open threads

Comment thread app/gallery.py Outdated
Comment thread app/gallery.py Outdated
@the-codemole
the-codemole Bot dismissed their stale review October 7, 2026 02:45

No longer clean — approval withdrawn.

Two findings from the review of the debounced refresh.

The temp file was named after its target, so every writer of the same
unknown shared one path — and nothing stopped a request handler's direct
refresh_guesses() from running while the background worker was in one. Two
passes then interleaved as 'A writes half, B replaces', publishing the
truncated file as the real metadata: exactly what the atomic replace is
there to prevent. The pass now runs under its own lock (not self._lock,
which match() already holds), and each write goes through
tempfile.mkstemp() so no two writers can collide. A failed write unlinks
its temp file instead of leaving it behind.

The debounce was trailing-only, so a steady stream closer than the delay —
bulk deletion, a dedupe cleanup loop — deferred the refresh for the whole
burst while the UI kept showing cues for photos that were already gone. The
deadline is now also capped at first_request + max_delay (2 s default).

Verified the other way round: with the shared temp name and the
trailing-only deadline restored, both new tests fail, five runs out of
five — the concurrency one on the FileNotFoundError from two threads
racing for the same temp path. 108 tests green with the fix.
@NerdyHank

Copy link
Copy Markdown
Contributor

@Alien10140 — the CI fix and the debounce both look right, and the sync-refresh rework is a
real improvement over what I asked for: the per-request full rescan was mine to begin with.

Two concurrency findings came back on the new code and I pushed the fixes to your branch
instead of listing them, since you had already turned this around twice:

  • refresh_guesses() was not serialised and the temp file was named after its target, so the
    background worker and a direct call could interleave into "A writes half, B replaces". Now
    a dedicated run lock plus tempfile.mkstemp().
  • The debounce was trailing-only, so a deletion burst deferred the refresh for its whole
    duration. Now capped at first_request + max_delay.

Both have tests, and I checked them the other way round — with the old code restored they
fail, the race one five runs out of five. 108 tests green.

Nothing else from my side. Once the bot is satisfied I will take it to the maintainer for the
merge call. Thanks for sticking with this one.

Comment thread app/gallery.py
Two differences from the plain write_text() this replaced.

mkstemp() creates with 0600 and os.replace() carries that mode onto the
target, so every refresh would have narrowed each unknown's JSON to the
service user. The mode of the existing file is now copied onto the temp
file before the replace, falling back to 0644.

And the comment promised more than the code delivered: without
flush+fsync the rename can land ahead of the data, leaving a zero-length
file after a power cut — the silent loss the atomic replace is there to
prevent. The write is now fsynced, and the directory once per pass rather
than per file.

Measured before claiming it is cheap: 200 unknowns 43.4 ms with fsync
against 41.6 ms without (0.009 ms per file), 2000 unknowns 426 ms against
428 ms — inside the noise. The mode test fails with 384 != 420 when the
chmod is removed.
@NerdyHank

Copy link
Copy Markdown
Contributor

All four findings are addressed on the branch (9736b04): run lock plus tempfile.mkstemp(), the debounce capped at first_request + max_delay, the target's file mode carried onto the temp file, and flush+fsync before the replace with the directory synced once per pass. 109 tests green, and each fix was checked the other way round — the race test fails 5/5 and the mode test with 384 != 420 when the fix is reverted.

@codemole recheck

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @&#8203;codemole recheck triggers a full re-run, so no extra push is needed for 9736b04.

What I can confirm from the diff:

  • Run lock: _refresh_run_lock wraps _refresh_guesses_once() and is deliberately separate from self._lock (which match() takes) — no lock inversion, and the worker/request overlap that caused the truncation can't happen anymore.
  • Temp file: tempfile.mkstemp(dir=self.unknown_dir, prefix=f".{jf.stem}-", suffix=".tmp") gives one path per writer, and since the loop only globs *.json the dotted temp names stay invisible to readers.
  • Mode + durability: os.chmod copies S_IMODE(os.stat(jf).st_mode) with a 0o644 fallback, and flush() + os.fsync() run before os.replace — that closes the 0600 regression and the reorder window.

Two things I can't verify from here: the first_request + max_delay debounce cap and the once-per-pass directory fsync are below the truncation point in the diff I was handed (only _refresh_first_request in __init__ is visible). Your reverted-fix checks — race test 5/5 red, mode test 384 != 420 — are the stronger evidence for those two anyway; I'll take them as covered unless the re-run says otherwise.

Comment thread app/gallery.py
os.replace() recreates its target unconditionally, so an unknown that was
assigned or discarded while the refresh was mid-pass came back as a JSON
file with stale metadata and no crop — and reappeared in the review queue.
The debounced background worker widened that window by design: the pass now
runs beside the handlers instead of inside one.

The target is stat'ed once (its mode is needed anyway) and a missing file
means skip rather than write, with a second existence check immediately
before the replace. The window shrinks from the whole write to two calls;
closing it fully would need the handlers to take the refresh lock, which is
a bigger change than this PR should carry.

Reverting the skip makes the new test fail with 'True is not false: ein
geloeschtes Unknown darf nicht zurueckkommen'. 110 tests green.
@NerdyHank

Copy link
Copy Markdown
Contributor

Head is 8ccf4b4 — it carries the skip-if-deleted path and its test, which the last two threads were waiting on. CI green, all five findings addressed, each one checked the other way round as well (race 5/5, cap 0.40012 > 0.35, mode 384 != 420, resurrection True is not false). 110 tests green.

@codemole recheck

@the-codemole the-codemole Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — review complete.

  • Checks: 6 passed, 1 skipped, 0 failed
  • 8 finding(s) addressed, no open threads

@the-codemole

the-codemole Bot commented Oct 7, 2026

Copy link
Copy Markdown

Recheck is running — the @&#8203;codemole recheck triggers the full re-run for 8ccf4b4, no extra push needed.

What the diff confirms:

  • Skip-if-deleted: os.stat(jf) inside try/except FileNotFoundError → temp file unlinked, continue. That stops the common case (unknown assigned/ignored mid-pass) from being resurrected by os.replace() with stale metadata.
  • The guard narrows the window rather than closing it: a delete landing between the stat and the os.replace still resurrects the JSON, since the delete path isn't under _refresh_run_lock and os.replace can't be made conditional on the target existing. With the debounced background pass instead of a per-request rescan, that remaining window is microseconds against a human-paced action — noting it for the record, not as a blocker.
  • Mode carry-over, deadline cap at first_request + max_delay, flush+fsync before replace, and the once-per-pass directory sync are all present as described.

The test results (110 green, race 5/5, 0.40012 > 0.35, 384 != 420, resurrection True is not false) I'm taking from your comment — I can't run them myself; the recheck will report independently.

@NerdyHank
NerdyHank merged commit 367188d into SkyTechNerds:main Oct 7, 2026
1 check passed
NerdyHank pushed a commit that referenced this pull request Oct 7, 2026
The looks-like preview from #34, plus the debounced refresh and the safe
metadata write that came out of its review. The debounce is the one
visible change for users: cues update a moment later instead of
instantly, capped at 2 s after the first request in a burst.
@NerdyHank

Copy link
Copy Markdown
Contributor

Released as v0.26.0 — thanks,
this one was worth the back and forth.

Credited in the changelog, including the part you were not asked for: turning the
per-request full rescan into a debounced background pass. That rescan was mine, and it was
costing a full re-match of every unknown per deleted photo.

The release notes call out the one visible behaviour change for anyone reading them — cues
now update a moment later rather than instantly, capped at 2 s after the first request in a
burst — so nobody files it as a bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants