PawTrace / DECISIONS.md
Elliott Duke
Cleanup of the system and documentation
d7d2eaf
|
Raw
History Blame Contribute Delete
44.8 kB

DECISIONS.md

Running log of implementation decisions where the spec (PROJECT_SPEC_dog_reunification_V1.md) left room, plus anything discovered that contradicts the spec. Newest entries at the bottom of each section.

Implementer choices (from spec §19)

  • Frontend styling: Tailwind CSS. Faster to build responsive/mobile-first UI; utility classes keep styling co-located with components. (spec §5, §19)
  • Auth: JWT access tokens (Bearer), password hashing via passlib[bcrypt]. Stateless, simple to consume from the SPA, and matches the OpenAPI/codegen story. Refresh tokens deferred. (spec §11, §19)
  • Dog-detector crop: planned, never built. The intent was an optional crop-to-dog pass before embedding. It was not needed once the embedder was fine-tuned on full-body photos as well as face crops, so no detector module exists and Picture.is_probably_not_dog is never set. (spec §8, §19)

Decisions where the spec left room

  • Python 3.13 is used (local interpreter); spec asked for 3.11+. No incompatibilities expected.
  • Finders without accounts do not create a users row. Their contact lives on the cases row (finder_name/finder_email/finder_phone), per spec §7.1 note. De-dupe into lightweight finder users deferred. (spec §7.1)
  • Embeddings stored in their own embeddings table (per spec §7.5/§7.6 refinement), vector as float32 BLOB, L2-normalized at write time so cosine == dot product.
  • Single config object via pydantic-settings, loaded from .env. All thresholds/radius levels are named config values with documented defaults (spec §0, §15).

Resolved spec questions

  • REVIEW_THRESHOLD / STRONG_THRESHOLD govern the case matcher only. The public photo search returns the top N ranked results with no cutoff, deliberately: a page of low scores tells a searcher their dog is probably not in the database, where an empty page is ambiguous. Retune with scripts/eval_matching.py if the case workflow is put into real use (spec §9.3, §9.6).
  • RADIUS_LEVELS default [0, 10, 25, 50, 100, -1] where -1 == nationwide (no filter).
  • Nearby-shelter lookup is a bundled static list for MVP (spec §12).

Estimated-breed candidate gate (extends spec §9.3 metadata gate)

  • New swap point: BreedClassifier (backend/app/ml/breed.py), structured exactly like Embedder: Protocol + MockBreedClassifier default + lazily-imported HFBreedClassifier + get_breed_classifier()/reset_breed_classifier_cache() singletons. It produces breed labels only, used purely as a candidate pre-filter — never for similarity (that matcher is selected separately).
  • Predictions stored in their own versioned breed_predictions table (mirrors embeddings): per-picture, keyed by breed-model name/version, one row per top-K rank. Match time uses only the active breed model's rows; other versions count as "no predictions" → fail-open.
  • Fail-open, symmetric top-K gate (open choice #1: chose symmetric for recall). A candidate is excluded only on a definite conflict: both query and candidate have predictions under the active model and their top-K label sets do not intersect. Missing predictions on either side never exclude — same recall-first discipline as _metadata_compatible and geo.within_radius. The gate runs after the geo + color/size gates and before the cosine work, so it prunes the expensive comparison. BREED_FILTER_ENABLED=false disables it entirely (predictions still stored).
  • BREED_TOP_K default 7 (open choice #3; anywhere in 5–10), exposed as config to tune vs recall.
  • Human-entered breed is deliberately not folded in (open choice #2: no, settled). KnownDog.breed / UnknownDog.est_breed are free text, and owners are often wrong about mixed breeds. A confident-but-wrong human label would exclude the correct dog outright, so the gate stays purely model-driven to keep it recall-first.
  • Mock-first / HF opt-in. Default BREED_CLASSIFIER=mock is deterministic and weight-free (reuses the mock embedder's pixel hashing so identical images yield identical breed sets — this keeps the seeded Rex match working). The real HF model (jhoppanne/Dogs-Breed-Image-Classification-V1, ~43 MB) is enabled only via config and lazily imports torch/transformers, exactly like the real embedders. Labels are read from model.config.id2label at load time (no hardcoded breed list).
  • Best-effort in the upload pipeline. Breed prediction is wrapped in try/except so an upload never fails if the breed model errors or isn't downloaded; the embedding stays required.
  • Migrations: versions/ previously relied on create_all; added a baseline autogenerated migration covering the full schema including breed_predictions. Existing dev DBs predate the table — delete backend/data/app.db and re-seed, or run alembic upgrade head then python -m scripts.process_dataset --all to populate predictions for already-uploaded photos.

Batch loading & test-data management (datasets)

  • New datasets table + nullable dataset_id FK on known_dogs, unknown_dogs, users (Alembic 31362988755b, chained on the a18bdb0cac22 baseline; batch-mode ALTERs with named FK constraints since SQLite batch mode requires names). Cases/matches/pictures link transitively through the dogs/users, not via their own dataset_id.
  • Purge is application-level and transactional, not DB-cascade. Pictures link to dogs by convention (subject_type/subject_id, no FK) and embeddings/breed_predictions FK pictures with no ON DELETE, so a clean purge deletes children before parents in one transaction (notifications → breed_predictions/embeddings → matches → pictures → cases → unknown_dogs → known_dogs → users → dataset) and returns per-type counts. Everything-or-nothing via commit/rollback. (services/datasets.py)
  • Purge also deletes media files. The picture storage keys are collected before the rows are deleted, then the files are removed after the DB commit succeeds (best-effort: a failed unlink only leaves an orphan, never inconsistent data; a rolled-back purge deletes nothing). Count returned as media_files. Earlier builds left media on disk (the storage step was a no-op), which accumulated orphans across purges/re-seeds; scripts/prune_media.py removes any orphans not referenced by a pictures row (dry-run by default) and stays as a safety net for manual DB resets.
  • One dog per folder, multiple pictures (default). The CSV folder column is the identity key (each DogFaceNet subfolder == one real dog). The loader groups a folder's rows into a single KnownDog/UnknownDog and attaches every image as a Picture (first = primary); a known dog gets one optional lost case, a found dog one found Case. This supersedes the original "one row == one dog" behavior, which created a separate same-named dog per photo — wrong for multi-photo identities and bad for matching (1-picture dogs). Pass group_by_folder=False / --one-dog-per-image for the legacy per-row behavior. Owners/finders are still de-duped by email and linked to the dataset; found_zip equals the dog's known ZIP so default-radius (level 0 = same ZIP) matching works. Note: datasets loaded before this fix have one dog per image — purge and re-load them.
  • Holdout default: 1 image, or 2 when a dog has 5+ images and --holdout is not given; always leaves ≥1 registration image (a single-image dog yields 0 holdouts, logged).
  • --mark-lost also opens a lost Case per marked known dog (status lost, last_known_zip set) so the known dataset is actually matchable; --mark-lost-pct controls the share (default 100).
  • Embedding reuse: extracted embed_picture() in services/images.py, used by both the upload pipeline and embed-all — embedding generation is never reimplemented.
  • Many-to-many match: run_matching_for_case gained an optional candidate_dataset_id to scope the candidate pool; the /admin/datasets/{id}/match?candidate_dataset_id= endpoint passes it through.
  • Progress feedback = polling. No realtime channel existed, so dataset loads run in a worker thread tracked by a process-local in-memory job registry (services/jobs.py); the dashboard polls GET /admin/jobs/{id}. Ephemeral by design (single-process local target, A8); a durable queue is a documented future swap.
  • Schema change ⇒ re-seed. Existing dev DBs lack dataset_id; create_all does not ALTER, so delete backend/data/app.db and re-seed, or alembic upgrade head.

Storage-only load (defer embeddings)

  • process_and_store_picture gained generate_embedding / generate_breed flags (default True, so normal uploads/seed are unchanged). The batch loader's new skip_embeddings option sets both to False — images are validated, normalized (EXIF stripped), stored, and thumbnailed, but no embeddings or breed predictions are generated. Exposed as --skip-embeddings (CLI), skip_embeddings (load-job form), and a "Load images into storage only" checkbox in the dashboard. Matching needs embeddings, so "Run matching after load" is force-disabled when skipping.
  • Batch embedding generator: the existing POST /admin/datasets/{id}/embed-all (idempotent — only embeds pictures lacking an embedding under the active model) is now surfaced as an "Embed" button per dataset row on the Data Loading page, next to "Purge" (in addition to the dataset detail page's "Run Embeddings"). This is the intended two-step flow: bulk-load into storage now, generate embeddings later. embed-all runs synchronously; fine for the mock embedder. (If a real CNN embedder on very large datasets makes the request too long, move embed_all onto the background job system used by load — a documented future step.)

Real re-ID embedder: HuggingFace model (EMBEDDER=hf)

  • New HFEmbedder (backend/app/ml/embedder.py) behind the existing Embedder swap point — no API/schema changes. It loads an HF image-classification model (EMBEDDER_HF_MODEL, default jhoppanne/Dogs-Breed-Image-Classification-V1) and uses its penultimate, pre-classifier pooled features as the re-ID vector: last hidden state → global-average-pool (CNN feature maps) or mean-over-tokens (transformer sequences) → L2-normalize. Breed labels are NOT used for similarity — that stays the separate BreedClassifier. Embedding dim is inferred at load with one dummy forward (architecture-agnostic).
  • torch/transformers lazily imported, exactly like HFBreedClassifier; default EMBEDDER=mock still needs none of it. torch is installed CPU-only.
  • Versioning makes the swap safe: embeddings are tagged model_name="hf-embed", model_version=<repo name>. Matching only compares embeddings from the active model, so after switching EMBEDDER=mock→hf you must regenerate vectors (admin embed-all); old mock vectors are simply ignored, not mixed.
  • Same model, two roles: this repo can use the model both as the breed gate (BREED_CLASSIFIER=hf, labels) and the re-ID embedder (EMBEDDER=hf, features). They load independent copies for interface cleanliness; sharing one load is a possible future optimization.
  • Perf note: CPU embedding of the full DogFaceNet set (~8k images) via embed-all is slow and runs synchronously today — fine to run once, but a background-job version of embed-all is the documented next step for large real-model runs.

Breed filtering (HF softmax labels)

  • Same HF model, the classifier head this time. BreedClassifier=hf runs jhoppanne/Dogs-Breed-Image-Classification-V1's softmax and stores the top-K labels in breed_predictions (separate from the re-ID embeddings). BREED_TOP_K=10 so the UI can filter by top-1 through top-10.
  • Filter semantics (per-photo, recall-friendly): a dog matches breed B at depth k if any of its photos predicts B within rank < k. breed_k=1 = "top match"; 5/10 = top-K. Displayed predicted_breeds is the dog-level aggregate (distinct labels ordered by best rank then score across the dog's photos), so a dog matched on one photo's top-1 may show a different label first — expected for multi-photo dogs.
  • Active breed model is auto-detected from the DB (active_breed_model_in_db, prefers non-mock, most-used) — reading predictions never loads the HF model. estimated_breeds_for was refactored to use this too, so match-candidate hydration no longer risks loading the breed model.
  • Endpoints: GET /admin/breeds (distinct labels + counts) and GET /admin/dogs?breed=&breed_k= (filter). Profiles now include predicted_breeds.
  • Tests pin BREED_CLASSIFIER=mock (conftest) so the suite never loads the HF model; the .env default is hf for real use. Generate predictions with python -m scripts.process_dataset --all (--limit N for a partial run).

Public "search by photo" (home page)

  • What: anonymous visitors can upload a dog photo (+ optional ZIP) on the home page and get the found/unknown dogs already in the system ranked by photo similarity. POST /search/by-photo (multipart file, optional zip, top_k). No auth, no DB writes.
  • Read-only by design: it is a lookup, not a report. It never creates a KnownDog/Case, so it doesn't pollute the matching pipeline or notify anyone. The copy steers users who find a likely match to the real "report my dog as lost" flow (where confirmed, mediated matching happens).
  • Transient embedding: images.embed_bytes() runs the same validate→normalize→encode pipeline as stored uploads, writes to a tempfile, embeds via the active model, deletes the temp file. The query photo is never persisted — same vector a stored picture would get, zero footprint.
  • Scoring reuses the matcher: matching.search_unknowns_by_vectors() scores the active found/unknown pool (pending|lost|at_shelter) by the same dog-level max cosine aggregate the case matcher uses (_dog_level_score). No REVIEW_THRESHOLD cut here — this is a "show me the closest" ranked list, so we return the top-K regardless of absolute score (recall-first; the model eval showed scores run uniformly high, so a threshold would mostly mislead).
  • ZIP is optional + fail-open: with a ZIP we scope to DEFAULT_SEARCH_RADIUS=100 mi (fail-open on unknown ZIPs, like the case matcher) and surface per-result distance_miles; empty ZIP = nationwide. Results are always ranked by score, not distance.
  • Results carry their own photos so the UI viewer (PhotoTools modal) shows every angle without a separate auth'd detail endpoint (the admin /admin/dog/... route stays admin-only).
  • Tests: backend/tests/test_search.py (ranking, no-auth, persists-nothing, ZIP distance, bad image) and frontend/src/components/__tests__/PhotoTools.test.tsx (upload→render, photo viewer).

Public breed-estimation toy (home page)

  • What: a for-fun home-page widget — upload a photo, get the top N (3/5/7/10) breed labels. POST /search/breed (multipart file, top_n). No auth, no DB writes.
  • Reuses the breed swap point, not a new model: images.predict_breeds_bytes() mirrors embed_bytes (same validate→normalize→encode→tempfile→cleanup) and calls the active BreedClassifier (get_breed_classifier). With BREED_CLASSIFIER=hf it's the real softmax; with mock (tests/offline) it's deterministic pseudo-labels. The endpoint slices to top_n, so the practical max is bounded by BREED_TOP_K (10 in .env).
  • Distinct from the match-time breed gate: this is display/entertainment only and never touches matching; it just exposes the classifier the gate already uses.
  • Note: the home page presents this together with photo-search in a single PhotoTools widget — one upload, a large centered preview, and buttons to run matching, breed estimation, or both.
  • Tests: test_search.py (top_n length, deterministic labels, clamp to 10, bad image) + frontend/src/components/__tests__/PhotoTools.test.tsx (upload→ranked list, run-both).

ZIP filtering fix (geo centroids were missing)

  • Bug: photo-search ZIP filtering (and case-match radius) silently did nothing. Two causes: (1) the app default zip_centroid_file was CWD-relative (./data/...), so launching uvicorn from backend/ looked in backend/data/ and loaded 0 centroids; (2) the shipped centroid CSV was a 31-row national demo set, while the DogFaceNet demo data uses ~480 real Texas ZIPs — 0 of which had centroids. within_radius fails open on unknown ZIPs, so every candidate passed → ZIP no-op, distance always null.
  • Fix 1 (path): config.zip_centroid_file now defaults to an absolute path resolved from the repo root (parents[2]/data/zip_centroids.csv), so it's found regardless of CWD. .env / ZIP_CENTROID_FILE still overrides.
  • Fix 2 (coverage): a one-off generator produced data/zip_centroids.csv (539 rows) covering the national demo ZIPs (kept verbatim so matching tests' distances are unchanged) plus every Texas ZIP in prepare_dogfacenet.ZIP_CODES, mapped to a metro-level centroid by ZIP3 prefix. Inter-metro distances are realistic (Houston↔Dallas ≈ 225 mi) so radius filtering works; intra-metro distance ≈ 0. A real per-ZIP gazetteer is a drop-in CSV replacement later.
  • Verified: 1395/1395 demo dogs now have centroids; a Houston search at 100 mi keeps 382/1395 (drops other metros). Test: test_search_by_photo_zip_excludes_out_of_radius.

One model pass per stored image (embedding + breed together)

  • Problem: every stored picture forwarded through the model TWICE — once in HFEmbedder.embed (penultimate features) and once in HFBreedClassifier.predict (logits) — even though both use the same model (jhoppanne/Dogs-Breed-Image-Classification-V1). Batch workflows were worse: running embed_dataset then backfill_breeds = two full passes over the dataset.
  • Fix: HFEmbedder.embed_and_breed(paths, top_k) does ONE forward with output_hidden_states=True and returns both the pooled re-ID vector (from hidden_states[-1]) and the softmax breed labels (from logits). Verified the vector is identical to the old standalone embed() — existing embeddings stay valid, no regeneration needed.
  • Orchestration: images.embed_and_breed_picture(db, pic) uses the single pass when _same_hf_model() (embedder=hf, breed=hf, same repo id); otherwise falls back to the two separate models (mock defaults / mismatched ids), embedding required + breed best-effort. Breed rows are tagged via hf_breed_name_version() so they match HFBreedClassifier's tags exactly.
  • Wired in: process_and_store_picture (live uploads) calls it when both are requested; datasets.embed_all (admin "Run Embeddings + Breeds" button) now reports embedded + breeds; and scripts/process_dataset.py is the batch path that replaces embed_dataset + backfill_breeds (one pass instead of two). embed_dataset remains for mock / split configs; backfill_breeds has since been removed.
  • Scope: applies to pictures stored in the system. The public front-page toys (embed_bytes, predict_breeds_bytes) are intentionally separate one-off queries and out of scope.
  • Tests: test_embed_breed_single_pass.py — a fake same-HF embedder proves the model runs exactly once and both rows are written with the right tags; plus the mock-fallback idempotency.

Purge fix: datasets that share users (reused test emails)

  • Bug: purging "DogFaceNet known" failed with FOREIGN KEY constraint failed on DELETE FROM users and rolled the whole transaction back (dataset left fully intact). Root cause: the prepare scripts mint owner emails owner1@test.com…, and batch_loader._get_or_create_user dedupes users by email — so a second dataset (MPDD) whose CSV reused those emails attached its 96 dogs to the FIRST dataset's users. Purging the first dataset then tried to delete users still owning the second dataset's dogs.
  • Fix (purge_dataset): a user may be legitimately shared across datasets, so instead of force- deleting all of the dataset's users, only delete users with no surviving references (KnownDog.owner_id / Case.person_id after the dataset's own rows are gone) and detach the rest (dataset_id = NULL) so the dataset row can be removed without a dangling FK. New users_detached count is returned. Also extended match cleanup to remove matches whose candidate is one of the purged dataset's dogs (not just by case), so a cross-dataset match can't be left pointing at a deleted dog.
  • Verified live: purged DogFaceNet known (1393 dogs, 5969 pictures + media, 11938 embeddings, 59690 breed preds; 1297 users deleted, 96 detached). MPDD (#3/#4) fully intact, all owners valid, 0 media orphans. "DogFaceNet unknown" purge was checked and had cleaned up correctly (no DB or media orphans).
  • Latent data-hygiene note: reused test emails still ENTANGLE datasets (one user owning dogs in several). The purge now tolerates it, but future prep runs (YT-BB-Dog, Sibetan) will keep sharing owner1@test.com… unless the prepare scripts namespace emails per dataset. Recommended follow-up.
  • Test: test_purge_retains_user_shared_with_another_dataset (shared owner across two datasets).

Removed color/size as entered information (subjective/unreliable)

  • Why: color and size are subjective, inconsistently reported, and were pruning candidates in the matcher — a wrong color/size guess could hide a correct match.
  • Matching: deleted the _metadata_compatible color/size gate and the apply_metadata_filter path in run_matching_for_case. Matching now gates only on ZIP radius + estimated breed (model softmax), then ranks by embedding similarity. (Breed filtering was explicitly wanted; kept.)
  • Inputs removed: KnownDogCreate/KnownDogUpdate, LostCaseCreate.new_dog_color/size, FoundSightedCreate, and the /cases/found + /cases/sighted form params no longer accept color/size. Frontend forms (MyDogs add-dog, ReportLost new-dog, ReportFound/Sighted) dropped the fields, and the now-always-empty color/size display was removed from the owner views (MyDogs card, DogDetail).
  • Deliberately kept: the color/size DB columns (nullable) and the *Out response schemas — no destructive migration, and any already-loaded/legacy data still round-trips. Batch-loader CSVs and admin dataset views still carry their (often "unknown") values; these are inert now that matching ignores them. FastAPI/Pydantic ignore the extra fields, so old clients/CSVs don't break.
  • Verified: 71 backend + 14 frontend tests pass, clean typecheck/build.

Per-dataset + combined re-ID metrics (scripts/eval_datasets.py)

  • New read-only eval over the loaded (known+unknown) dataset pairs, auto-detected by name (" known" + " unknown"). Reuses DB embeddings (active model).
  • Intra-subject similarity: per dog, cosine over all pairs of its OWN photos (min/max/avg) → eval_out/intra_subject_similarity.csv, plus a per-dataset summary and a sampled different-dog baseline for context.
  • Retrieval quality: found dog → ranked known gallery with the production dog-level max-cosine score; recall@1/5/10 (+counts), mean/median rank, MRR, true-vs-best-wrong separation. Reported per dataset AND combined (shared gallery; the other dataset's dogs become distractors). Identity is namespaced per source (base:folder) so colliding folder numbers across datasets never cross-match → this is how "filter matching by dataset" is realized for the eval. Summary → eval_out/retrieval_summary.csv.
  • Measures the MODEL (pure embedding retrieval, full gallery). Deliberately does NOT apply the production ZIP-radius / breed gates: on this synthetic data each dog's found+known share a ZIP, so a ZIP gate would inflate recall as an artifact rather than reflect the model.

Photo-level retrieval / CMC (scripts/eval_photo_retrieval.py)

  • Different protocol from eval_datasets.py (which is dog-level: whole found dog vs whole known dogs). Here every INDIVIDUAL photo (on the recombined pre-split whole dogs) is a query matched against ALL other photos, its own excluded; a hit @k means a same-dog photo is in the top-k. Reported per dataset then combined (shared photo gallery; other dataset = distractors). Recall@1/5/10/20 + mean/ median rank + MRR. Identity namespaced per source so folder-number collisions don't cross-match.
  • _cmc computes each query's nearest-same-dog rank as 1 + #{sim > s_true} with s_true and the count taken from the SAME similarity row (one matmul) — a first attempt used two matmuls and a float mismatch nudged true matches off rank 1 (recall@1 low by ~5pts). Verified elementwise identical to a brute-force argsort on MPDD.
  • Results (hf-embed): MPDD r@1/5/10/20 = 65.1/81.0/87.3/91.8%; DogFaceNet 62.9/79.8/85.6/90.1%; Combined 62.7/79.5/85.3/89.8%. Lower than the dog-level eval (single-photo query is weaker than aggregating a whole dog + larger per-photo gallery). Summary -> eval_out/photo_retrieval_cmc.csv.

Outlier photo removal + re-eval (scripts/remove_outliers.py)

  • Quarantined the 22 flagged removable_outlier photos (10 MPDD + 12 DogFaceNet) — each the single isolated photo whose removal lifts its dog's MIN pairwise similarity back above 0.5. Files moved to data/outlier_photos/ (reversible); Picture+Embedding+BreedPrediction rows deleted. Every affected dog kept >=2 photos. Verified 0 DB-keys-missing-on-disk afterward.
  • Re-ran both eval scripts on the cleaned data (counts −22 exactly):
    • Consistency: dogs with min<0.5 fell MPDD 15->5, DFN 13->1 (remaining low-min dogs have >1 bad photo, so no single removable outlier left).
    • Photo-level CMC improved: MPDD r@1/5/10/20 65.1/81.0/87.3/91.8 -> 66.7/82.6/88.9/93.2; DFN 62.9/79.8/85.6/90.1 -> 63.0/79.9/85.8/90.2; Combined 62.7/79.5/85.3/89.8 -> 62.9/79.6/85.5/90.0. MPDD moved most (small set); DFN barely (12/8363).
  • NOTE (pre-existing, unrelated): ~5240 orphan media files on disk with no DB row (from the earlier DogFaceNet reload). Invisible to the DB-based evals; cleanup via scripts/prune_media.py when wanted.

Fix: load progress bar freezes on large batch loads

Two independent causes, both hit only on LONG loads (many polls over many minutes):

  • Frontend (root cause): the poll's catch { clearInterval(t) } permanently stopped polling on a SINGLE failed request, so the bar froze at its last value while the load kept running server-side. Now tolerates a run of transient failures (~10s / 20 polls) before giving up, and surfaces a "Lost connection" error only if the job is truly unreachable (failsRef counter).
  • Backend (why polls failed): SQLite had no busy_timeout and used the default rollback journal, so the status poll's admin-auth SELECT could transiently hit "database is locked" during the load's long write transaction. Added PRAGMA journal_mode=WAL (readers don't block on the writer) + PRAGMA busy_timeout=10000. Bonus: the test suite got ~35% faster under WAL.
  • Tests: new "keeps polling through a transient poll failure" AdminData test; all backend/frontend green.
  • Not changed (documented follow-up): the whole load is still ONE transaction (commit at the end), so the SQLAlchemy session grows for very large loads. Periodic commit + expunge would bound memory and smooth progress further, at the cost of all-or-nothing semantics.

Batch encoding progress bar (background embed job)

  • The admin "Run Embeddings + Breeds" button was a synchronous blocking POST — no progress, and on a large dataset it just spun / could time out. Converted encoding to a background JOB reusing the same registry + polling the loader uses.
  • Backend: datasets.embed_all(db, dataset, *, progress=None, commit_every=25) now commits + reports {processed,total,dataset_id} every 25 pictures (and expunge_all to bound the session) instead of one commit at the end — safe because encoding is additive/idempotent. New endpoint POST /admin/datasets/{id}/embed-all-job runs it in a worker thread and returns a job id; the old synchronous embed-all endpoint stays for small sets / tests.
  • Frontend: shared useJobPolling hook (resilient polling, same transient-failure tolerance as the load bar) + JobProgressBar component. DatasetDetail's encode button now starts the job, shows a live bar, and prints the summary + refreshes stats on completion.
  • Tests: test_embed_all_reports_progress (monotonic progress events), test_embed_all_job_runs_in_ background (start->poll->done), and a DatasetDetail UI test (bar -> summary). All suites green.

Palette: warm tan/brown theme (replaced generic blue/white)

  • The app consistently uses brand-* and gray-* Tailwind tokens, so retheming is a two-scale change in tailwind.config.js — no component rewrites:
    • brand → warm tan→coffee-brown scale (was blue). Recolors buttons, links, active nav, focus rings, progress bars, and breed chips.
    • gray → warmed taupe/sand neutrals (overrides Tailwind's cool default gray). Warms every surface, border, and text token the app already uses (page bg gray-50 = warm cream, cards stay white for a paper-on-desk feel).
    • New accent = a muted dusty-denim blue (deliberately not the default bright blue); the 4 generic blue-100/blue-700 "found/unknown/open" badges now use it as a cool counterpoint.
  • Contrast checked on the key pairs (brand-600/white ~6.6:1, brand-700 on brand-50, gray-900 on gray-50, accent-700 on accent-100) — all comfortably readable. tsc/tests/build green.

Date plausibility gate in matching

  • A dog can't be FOUND before it was LOST, so a lost dog is only compared to found dogs whose found date is on/after lost_date - grace. Added _date_compatible(lost_date, found_date) in matching.py, applied (pre-cosine, alongside the ZIP + breed gates) in BOTH directions of run_matching_for_case: lost-query→found-pool and found-query→lost-pool. Uses each case's event_date (the user-entered lost/found date, not the report timestamp).
  • Recall-first / fail-open: missing dates never exclude; only a definite conflict (found earlier than lost minus grace) prunes. Config: DATE_FILTER_ENABLED=true, MATCH_DATE_GRACE_DAYS=2 (grace absorbs imprecise dates). The admin test-match (pure re-ID check) intentionally skips this gate.
  • Batch-loaded datasets set both cases' event_date to the load date, so their found/lost dates are equal → always compatible (dataset matching/eval unaffected).
  • Tests: found-before-lost excluded despite identical photo; found within grace still matches.

Narrow-first case matching + widen the constraints

Directive: searches start narrow (location + top-10 breeds + top-10 matches) then widen. Scope chosen: lost/found case matching, MANUAL widen button (keep it). What was already true vs added:

  • Location: already narrow-first (starts at radius_levels[0]=0) with the manual /widen ladder. ✓
  • Top-10 breed gate: already applied (BREED_TOP_K=10, breed_filter_enabled). ✓
  • Top-10 matches: TOP_N was 5 → now 10 (the narrow-pass result cap).
  • "Then widen the constraints": the widen ladder previously loosened only location. Now the WIDEST rung (nationwide, radius -1) also drops the breed gate (nationwide = radius == -1; the breed gate is skipped there). So the manual widen progresses location → … → breed, with no schema change (state derived from the existing search_radius_miles). Date/ZIP/threshold gates unchanged.
  • Tests: narrow pass caps at 10 matches; breed gate excludes a disjoint-breed dog at radius 0 but matches it once widened to nationwide.

Privacy: shelter/vet location is public (only homes stay private)

  • Reversed the earlier rule that hid current_location_detail (the shelter/vet holding a found dog). A shelter/vet is a public place, and telling a matched owner where to reclaim their dog is the point — so it's now included in the hydrated match candidate (hydrate.py) and UnknownDogOut. Home locations remain protected: never collected below ZIP granularity, contact stays mediated.
  • Frontend: MatchCard shows "📍 Being held at <shelter/vet>"; the found-report field is relabeled (public, "use the shelter/vet name — not a home address").
  • Test flipped: test_unknown_dog_shelter_shown_but_location_stays_zip_level asserts the shelter name IS returned while location stays ZIP-level. Updated model/schema/spec-§14 comments.

Removed the "sighted" workflow (found = you have the dog)

  • A found report now always means the reporter HAS the dog: in their own custody (pending) or dropped at a shelter/vet (at_shelter). Removed the seen-but-not-caught "sighted" flow entirely.
  • Backend: deleted POST /cases/sighted and the shared _create_found_sighted helper (folded into a single create_found_case); removed CaseType.sighted and the now-unused UnknownDogStatus.lost ("sighted, not in custody"); dropped lost from the found/unknown matching pool filter (both the case matcher and the public photo search); renamed FoundSightedCreateFoundCreate; updated seed.py's far-away demo dog from sighted→found.
  • Data migration (raw SQL, enums already removed): 1 sighted case → found, 1 lost unknown → pending.
  • Frontend: removed the /report/sighted route + home button; ReportFound is found-only (no kind prop); api.createReport(kind)api.createFound; CaseType and MatchCard copy updated.
  • All backend/frontend tests green; typecheck + build clean.

Removed seeded demo data (generated-dot "dogs")

  • The synthetic make_sample_images dogs (Rex, Maple, 2 demo found dogs) and their info were noise now that real datasets are loaded. New scripts/purge_seed_data.py deletes every non-dataset dog (dataset_id IS NULL) + its pictures/embeddings/breeds/cases/matches/notifications/media, plus the demo owner users — but ONLY non-admin users owning no dataset dog, so the admin and the 96 detached (dataset_id-NULL) MPDD owners are preserved.
  • Ran it: removed 2 known + 2 unknown dogs, 2 owners (alice/bob), 3 cases, 1 match, 4 pictures + 4 media files. Verified 0 non-dataset dogs remain, admin intact, datasets intact (1489 known + 1489 unknown), media 8862 = 8862 on disk (0 orphans).
  • scripts/seed.py slimmed to create ONLY the admin account (no synthetic dogs), so re-seeding can't reintroduce them; README updated. make_sample_images.py stays (the test suite uses it).
  • All backend tests green.

Generated realistic cases for every loaded dog (one-off script, since removed)

  • One LOST case per known dog + one FOUND case per unknown dog, rebuilt cleanly (old cases/matches purged first). Pairing is by dataset + folder identity ("DogN" ↔ "Test found dog N").
  • Constraints honored: lost dates within the last 2 days; each found date is on/after its paired lost date and never in the future; each found ZIP equals the paired lost ZIP or a neighboring ZIP (another ZIP sharing the metro centroid — 40% of found reports); known-dog ZIPs (which were null) taken from the paired found dog's ZIP.
  • Each known dog got a fresh owner owner{i} (owner0…owner1488) as its owner + the lost case's reporter. The ~2825 old shared/entangled test owners (now orphaned) were removed; admin kept.
  • Verified all 1489 pairs: dates in range, found≥lost, all found ZIPs same-metro as their lost ZIP, every known dog lost with an owner. Cases are NOT auto-matched (run matching separately if wanted).

Admin dashboard: all cases, owners, and run-matching-on-demand

  • All Cases (/admin/cases, page + GET /admin/cases): every case, newest first, hydrated with its dog (thumb/name/kind), reporter (owner for lost, finder for found), ZIP, date, status, and match count. Filter by type (all/lost/found), paginated. Replaced the old thin list[CaseOut].
  • Owners (/admin/owners, page + GET /admin/owners): each owner with the dogs they own (thumb/name/status), paginated.
  • Run matching on demand (POST /admin/cases/{id}/run-match): runs the PRODUCTION matcher (run_matching_for_case — narrow-first ZIP/breed/date gates, persists ranked Match rows) for a chosen case and returns the hydrated matches. Surfaced as a "Run matching" button per row on the All Cases page, opening a modal of the ranked candidates (MatchCard). Distinct from Test Matching (/admin/test-match), which is the non-persisting pure-re-ID diagnostic.
  • Admin dashboard links updated (All Cases / All Dogs / Owners / Test Matching / Data Loading).
  • Tests: test_admin_all_cases_and_owners, test_admin_run_case_match. All suites green (79 backend, 16 frontend); verified live on the 2978-case dataset.

Fix: run-matching 500 (matching must not load the model) + case detail view

  • Bug: POST /admin/cases/{id}/run-match 500'd. Root cause: run_matching_for_case called get_embedder() / get_breed_classifier() just to read the active model's name/version — which loads the HF model over the network. On a server without a HF token that gets rate-limited/offline, the load fails → 500. Matching only needs the PRECOMPUTED embeddings, never the model.
  • Fix: added active_embed_model_in_db(db) (mirrors active_breed_model_in_db) and switched run_matching_for_case + rank_dog_against_opposite to pick the model from stored rows — no model load, works fully offline. Removed the now-dead _active_breed_model(). The public photo search keeps get_embedder() (it must embed the uploaded photo anyway). Verified run-match returns 200 with the hf-embed vectors under EMBEDDER=mock HF_HUB_OFFLINE=1 (zero network calls).
  • Case detail: new GET /admin/cases/{id} returns the case + its subject dog (all photos) + its persisted matches (each candidate hydrated with photos). All-Cases rows are now clickable → CaseDetailModal showing the dog's photos and the ranked matches, with a Run/Re-run matching button inside. Tests: test_admin_case_detail; all suites green (80 backend, 16 frontend).

All cases moved to Houston ZIPs

  • The case generator drew ZIPs from HOUSTON_ZIPS (the 77002–77099 subset of the prepare list) instead of the spread-across-Texas dataset ZIPs. Conventions kept: one deterministic ZIP per dog (per-folder seeded), found ZIP = same or a neighboring (other Houston) ZIP (~40%), owner ZIP = the dog's ZIP, lost dates within the last 2 days, found ≥ lost.
  • Owner creation is now idempotent (upsert owner{i}@example.com) so re-running doesn't hit the email UNIQUE constraint.
  • Re-ran: 1489 lost + 1489 found cases, all ZIPs Houston (95 distinct), 0 constraint violations.
  • Consequence: all Houston ZIPs share one centroid, so at radius 0 every dog is in range of every other — ZIP no longer narrows the pool; matching leans on photos (+ breed/date gates). Matches were cleared by the rebuild; re-run matching (per case in the admin All Cases view, or in bulk) if wanted.

Side-by-side photo comparison (case dog vs each match)

  • MatchCard now takes optional queryPhotos + queryLabel. When present, clicking a match's thumbnail opens a TWO-COLUMN modal: the case's own dog photos on the left, the candidate match's photos on the right (with the % match). Without those props it falls back to the single-dog gallery.
  • Owner view (CaseDetail): new owner/admin-authorized GET /cases/{id}/dog returns the case's dog (kind + all photos); CaseDetail fetches it and passes it to each MatchCard.
  • Admin view (AdminCases → CaseDetailModal): passes the case's subject-dog photos (already in the detail payload) to each MatchCard. So comparison works in both regular and admin case viewing.
  • Tests: test_case_dog_endpoint_returns_photos_and_guards_owner (photos + 403 for non-owner). All suites green (81 backend, 16 frontend).

Removed human-entered breed; display the model's estimated breed (top 3)

  • Owners/finders are unreliable on breed, so it's no longer collected. Removed the breed input from KnownDogCreate/KnownDogUpdate, LostCaseCreate.new_dog_breed, FoundCreate.est_breed, the /cases/found est_breed form param, and the frontend forms (MyDogs add-dog, ReportLost new-dog).
  • Everywhere breed was shown, it now shows the top-3 estimated breeds (model's HF-softmax predictions, already surfaced as estimated_breeds / predicted_breeds): MatchCard (card + modal
    • comparison), DogPhotosModal, PhotoSearch results, AdminDogs cards, AdminTestMatch candidates, DogDetail (removed the human "Breed" field), and the ReportLost dog picker. Shared estimatedBreeds() helper (top-3, "Breed not estimated" fallback).
  • DB columns (KnownDog.breed, UnknownDog.est_breed) kept nullable for backward compat (same as color/size); they're just no longer collected or displayed. Matching never used human breed anyway (the gate uses the model's breed_predictions).
  • All suites green (81 backend, 16 frontend), clean build.

Found-report 500 fix + owner re-run + admin delete buttons

  • Found-report 500 (root cause): each found report ran rematch_open_lost_cases_against, which re-ran FULL matching for EVERY open lost case (1490 now) — O(cases×candidates), holding the SQLite write lock for minutes → timeout/500 and locking out other requests (the Houston co-location made every dog a radius-0 candidate, worsening it). The found case's OWN matching already surfaces + notifies matching lost dogs, so the auto-rematch is redundant. Made it bounded + geo-filtered and OFF by default (REMATCH_MAX_CASES=0); raise it to opt in for small sets. Also fixed a latent FK bug: run_matching_for_case deleted prior pending matches without detaching notifications that referenced them (nulls Notification.match_id first now) — this surfaced once re-matching a case that had produced a strong-match notification.
  • Owner re-run matching: POST /cases/{id}/rematch (owner/admin, re-runs at current radius) + a "Re-run matching" button on the owner CaseDetail (next to Widen). Two tests updated to re-run explicitly now that auto-rematch is off by default.
  • Admin delete (temporary, for re-demoing): DELETE /admin/dogs/{kind}/{id} (dog + pictures + media + embeddings + breeds + its cases + matches referencing it) and DELETE /admin/cases/{id} (case + matches, dog kept). Delete buttons on AdminDogs cards and AdminCases rows.
  • Tests: owner rematch (+403), admin delete dog/case (+404/400). All green (84 backend, 16 frontend).

Admin dashboard as a button hub + admin nav + delete people

  • Admin home (Admin.tsx) is now just a grid of bigger link cards (Pending Matches, All Cases, All Dogs, Owners, Test Matching, Data Loading). The pending-matches review view moved to its own page AdminPending.tsx at /admin/pending.
  • Nav: when an admin is logged in, "My Dogs"/"My Cases" are hidden (only the Admin link + Log out).
  • Delete people: DELETE /admin/owners/{id} (+ delete_person service) removes a person and their dogs/pictures/media/embeddings/breeds/cases/matches; refuses to delete an admin. Delete button per owner on the Owners page (dogs + cases already had delete buttons).
  • Test: test_admin_delete_person (deletes owner+dogs, 404 on re-delete, 400 on admin). All green.

1-decimal match % + tie-break by most-recent report

  • Match percentages now show one decimal (e.g. 94.4%) so close scores stop collapsing onto the same integer: ConfidenceBar, MatchCard header, and the PhotoSearch result badges.
  • Matching ranking (run_matching_for_case): primary sort is the similarity ROUNDED to one decimal (matches the display); candidates that tie at that precision are ordered most-recently-reported first, using the candidate's case created_at (fetched alongside the date-gate query; ties with no report time sort last). CandidateScore gained a reported_at field.
  • Tests: test_equal_score_matches_break_ties_by_most_recent_report; two frontend tests updated for the decimal. All green (87 backend, 16 frontend).