Safety fix: never return an edible verdict from a single photo
#2
by HomesteaderLabs - opened
- .github/workflows/observability.yml +56 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +13 -0
- STATUS.md +79 -0
- app.py +29 -8
- pipeline/convergence.py +4 -1
- pipeline/infer.py +57 -0
- pipeline/session.py +64 -0
- tests/test_two_photo.py +108 -0
.github/workflows/observability.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: observability
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
paths:
|
| 6 |
+
- "observability/**"
|
| 7 |
+
- "pipeline/**"
|
| 8 |
+
- ".github/workflows/observability.yml"
|
| 9 |
+
pull_request:
|
| 10 |
+
paths:
|
| 11 |
+
- "observability/**"
|
| 12 |
+
- "pipeline/**"
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
test:
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
services:
|
| 18 |
+
postgres:
|
| 19 |
+
image: postgres:16
|
| 20 |
+
env:
|
| 21 |
+
POSTGRES_PASSWORD: forager
|
| 22 |
+
POSTGRES_DB: forager_fs_obs
|
| 23 |
+
ports:
|
| 24 |
+
- 5434:5432
|
| 25 |
+
options: >-
|
| 26 |
+
--health-cmd "pg_isready -U postgres"
|
| 27 |
+
--health-interval 2s
|
| 28 |
+
--health-timeout 5s
|
| 29 |
+
--health-retries 15
|
| 30 |
+
env:
|
| 31 |
+
FORAGER_OBS_DSN: postgresql://postgres:forager@localhost:5434/forager_fs_obs
|
| 32 |
+
steps:
|
| 33 |
+
# Two checkouts side by side, because observability/requirements.txt
|
| 34 |
+
# installs the shared package as `-e ../forager-obs`. Actions cannot check
|
| 35 |
+
# out above the workspace, so this repo goes in a subdirectory too and the
|
| 36 |
+
# later steps set working-directory.
|
| 37 |
+
- uses: actions/checkout@v4
|
| 38 |
+
with:
|
| 39 |
+
path: forager-field-station
|
| 40 |
+
- uses: actions/checkout@v4
|
| 41 |
+
with:
|
| 42 |
+
repository: thefullnacho/forager-obs
|
| 43 |
+
path: forager-obs
|
| 44 |
+
- uses: actions/setup-python@v5
|
| 45 |
+
with:
|
| 46 |
+
python-version: "3.13"
|
| 47 |
+
- name: Install deps (incl. editable forager-obs)
|
| 48 |
+
working-directory: forager-field-station
|
| 49 |
+
run: pip install -r observability/requirements.txt
|
| 50 |
+
- name: Shared safety rules (no DB, no models)
|
| 51 |
+
working-directory: forager-obs
|
| 52 |
+
run: python -m pytest -q
|
| 53 |
+
- name: Two-photo safety tests + observability schema/views
|
| 54 |
+
working-directory: forager-field-station
|
| 55 |
+
run: |
|
| 56 |
+
python -m pytest observability/tests/ tests/test_two_photo.py -q
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"observability/tests/test_observability.py::test_abstain_is_never_toxic_as_edible",
|
| 3 |
+
"observability/tests/test_observability.py::test_positive_recall_view",
|
| 4 |
+
"observability/tests/test_observability.py::test_safety_view_reports_zero_for_safe_handling",
|
| 5 |
+
"observability/tests/test_observability.py::test_writer_derives_toxic_as_edible",
|
| 6 |
+
"tests/test_two_photo.py::test_confident_deadly_mushroom_is_not_shown_safe",
|
| 7 |
+
"tests/test_two_photo.py::test_deadly_can_surface_on_single_photo",
|
| 8 |
+
"tests/test_two_photo.py::test_single_photo_edible_only_when_confident",
|
| 9 |
+
"tests/test_two_photo.py::test_single_photo_never_commits_edible",
|
| 10 |
+
"tests/test_two_photo.py::test_single_photo_never_surfaces_edible",
|
| 11 |
+
"tests/test_two_photo.py::test_two_agreeing_photos_can_commit_safe",
|
| 12 |
+
"tests/test_two_photo.py::test_two_photos_no_deadly_shown_edible"
|
| 13 |
+
]
|
STATUS.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# STATUS
|
| 2 |
+
|
| 3 |
+
Running log of where the Field Station actually is. Newest entry at the top. Append, do not
|
| 4 |
+
rewrite.
|
| 5 |
+
|
| 6 |
+
Cross-repo facts live in the Forager wiki at `~/Documents/Forager/forager-wiki/`.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 2026-08-15 β shared observability package; the dev harness stops shipping to the Space
|
| 11 |
+
|
| 12 |
+
**The dev harness was being uploaded to the public Space.** `deploy.py`'s IGNORE list excluded
|
| 13 |
+
`scripts/*` but not `observability/*`, so the whole Postgres harness went up with every deploy.
|
| 14 |
+
Now excluded. Verified the Space runtime never imported it: `app.py`, `pipeline/` and `game/`
|
| 15 |
+
are clean.
|
| 16 |
+
|
| 17 |
+
**`forager-obs` adopted.** The `toxic_as_edible` rule was implemented here inline in
|
| 18 |
+
`SessionWriter` and separately in forager_ml as a property on `InferenceOutcome`, with the
|
| 19 |
+
flagship 0.0 claim resting on both. It now comes from
|
| 20 |
+
`github.com/thefullnacho/forager-obs` (public, `main`), an editable sibling install, along with
|
| 21 |
+
connect/migrate, the batch-writer contract (`SessionWriter` is now a `BaseWriter`) and the
|
| 22 |
+
val-set walk including `k_groups`.
|
| 23 |
+
|
| 24 |
+
**The session-grained schema stays here.** forager_ml's is image-grained. Those answer
|
| 25 |
+
different questions and are deliberately not merged β recorded in the wiki so a later pass
|
| 26 |
+
does not "fix" it.
|
| 27 |
+
|
| 28 |
+
**Compose collision fixed.** This repo and forager_ml both bound host port 5433 *and* derived
|
| 29 |
+
the same Compose project name from their parent directory (`observability` in both), so
|
| 30 |
+
bringing up the second silently recreated the first's container. Now 5434 /
|
| 31 |
+
`forager-fs-obs`, and both run side by side for the first time.
|
| 32 |
+
|
| 33 |
+
**Verified:** 4 observability tests + 4 two-photo tests green against a live Postgres on 5434,
|
| 34 |
+
and the Space's pipeline imports unchanged.
|
| 35 |
+
|
| 36 |
+
### In flight
|
| 37 |
+
|
| 38 |
+
- **`two-photo-safety` branch is 1 commit ahead and this repo has no git remote.** It is a
|
| 39 |
+
local repo that deploys to Hugging Face via `deploy.py`.
|
| 40 |
+
- **The Space has not been redeployed.** The `deploy.py` exclusion takes effect on the next
|
| 41 |
+
`python deploy.py <user>/forager-field-station`, so `observability/` is still present in the
|
| 42 |
+
currently published Space.
|
| 43 |
+
|
| 44 |
+
### Next concrete action
|
| 45 |
+
|
| 46 |
+
Decide whether to redeploy the Space now (which removes the dev harness from the public repo)
|
| 47 |
+
or fold it into the next content deploy.
|
| 48 |
+
|
| 49 |
+
## 2026-10-25 β merge + GitHub remote
|
| 50 |
+
|
| 51 |
+
**Changed:** `two-photo-safety` fast-forwarded into `master` (two-photo safety protocol,
|
| 52 |
+
observability harness, confidence-banded single-photo gate, shared `forager-obs`). Repo now has
|
| 53 |
+
a private GitHub remote, `thefullnacho/forager-field-station`, default branch `master`; both
|
| 54 |
+
branches pushed. `assets/` (local backyard photos, video) is gitignored.
|
| 55 |
+
|
| 56 |
+
### In flight
|
| 57 |
+
|
| 58 |
+
- **The Space has still not been redeployed.** `observability/` is still in the published
|
| 59 |
+
Space until the next `deploy.py` run. [non-production] Approve the redeploy (it publishes).
|
| 60 |
+
- The `forager_ml` convergence drift check stays local-only. Its reason (no remote here) is
|
| 61 |
+
gone, so it could move into CI now.
|
| 62 |
+
|
| 63 |
+
### Next concrete action
|
| 64 |
+
|
| 65 |
+
Space redeploy deferred (2026-10-25): fold it into the next content deploy, which will also
|
| 66 |
+
remove `observability/` from the public Space.
|
| 67 |
+
|
| 68 |
+
## 2026-10-27 β safety fix deploy blocked
|
| 69 |
+
|
| 70 |
+
The fix that removes the 0.85 single-photo edible shortcut (`209f6b6`) is on master but **not
|
| 71 |
+
live**. The live Space is `build-small-hackathon/Forager-Field-Notes`, not the
|
| 72 |
+
`HomesteaderLabs/forager-field-station` named in the docs (that one doesn't exist). The
|
| 73 |
+
HomesteaderLabs token has read-only access to the hackathon org, so the upload was refused (403).
|
| 74 |
+
Nothing changed on the Space; it still serves the 2026-06-14 build and is sleeping. A new Space
|
| 75 |
+
under HomesteaderLabs returned 402: Gradio on free CPU needs PRO.
|
| 76 |
+
|
| 77 |
+
- [non-production] Get write access back on `build-small-hackathon`, or choose a new home
|
| 78 |
+
(PRO for HomesteaderLabs, or a different host), then re-run `deploy.py`.
|
| 79 |
+
- [non-production] Until then, decide whether the live Space should keep serving the shortcut.
|
app.py
CHANGED
|
@@ -15,7 +15,8 @@ import time
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
-
from pipeline.convergence import build_result
|
|
|
|
| 19 |
from pipeline.infer import Pipeline
|
| 20 |
from game.ui import build_game_tab, build_stump_tab
|
| 21 |
|
|
@@ -73,7 +74,17 @@ def _loading() -> str:
|
|
| 73 |
def _card(r) -> str:
|
| 74 |
label, color, glyph = TIER.get(r.safety, TIER["UNKNOWN"])
|
| 75 |
|
| 76 |
-
if r.abstained:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
color, label, glyph = INK_UNK, "UNKNOWN", "?"
|
| 78 |
title, sub = "UNKNOWN", "not confident enough β refusing by default"
|
| 79 |
rows = [
|
|
@@ -112,14 +123,20 @@ def _card(r) -> str:
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
-
def identify(image):
|
| 116 |
-
"""Generator: show the vine animation, then the readout.
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
yield _idle()
|
| 119 |
return
|
| 120 |
t0 = time.time()
|
| 121 |
yield _loading()
|
| 122 |
-
result =
|
| 123 |
elapsed = time.time() - t0
|
| 124 |
if elapsed < 1.6: # let the vine finish drawing
|
| 125 |
time.sleep(1.6 - elapsed)
|
|
@@ -358,6 +375,9 @@ with gr.Blocks(title="Forager's Field Station") as demo:
|
|
| 358 |
with gr.Column(scale=1):
|
| 359 |
img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
|
| 360 |
elem_classes="eink-input", height=300)
|
|
|
|
|
|
|
|
|
|
| 361 |
btn = gr.Button("βΈ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
|
| 362 |
if os.path.isdir(EXAMPLES_DIR):
|
| 363 |
samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
|
|
@@ -371,8 +391,9 @@ with gr.Blocks(title="Forager's Field Station") as demo:
|
|
| 371 |
gr.HTML(QUOTE_BAR)
|
| 372 |
gr.Markdown(SAFETY_NOTICE, elem_id="notice")
|
| 373 |
|
| 374 |
-
btn.click(identify, inputs=img, outputs=out)
|
| 375 |
-
img.change(identify, inputs=img, outputs=out)
|
|
|
|
| 376 |
|
| 377 |
with gr.Tab("β BEAT THE MACHINE"):
|
| 378 |
build_game_tab(PIPE)
|
|
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
+
from pipeline.convergence import build_result # noqa: F401 (re-exported for callers)
|
| 19 |
+
from pipeline.session import resolve_session
|
| 20 |
from pipeline.infer import Pipeline
|
| 21 |
from game.ui import build_game_tab, build_stump_tab
|
| 22 |
|
|
|
|
| 74 |
def _card(r) -> str:
|
| 75 |
label, color, glyph = TIER.get(r.safety, TIER["UNKNOWN"])
|
| 76 |
|
| 77 |
+
if r.abstained and r.reason == "need_second_photo":
|
| 78 |
+
# Leaning toward an edible ID, but the safety rule withholds it until a
|
| 79 |
+
# second photo confirms. Not a failure β a request.
|
| 80 |
+
color, label, glyph = INK_UNK, "SECOND PHOTO", "β"
|
| 81 |
+
title, sub = "ADD A SECOND ANGLE", "an edible ID needs two photos that agree"
|
| 82 |
+
rows = [
|
| 83 |
+
f"<div class='rdt-row'>{r.key_diff}</div>",
|
| 84 |
+
f"<div class='rdt-meta'>photograph the same specimen from another angle, "
|
| 85 |
+
f"then scan again</div>",
|
| 86 |
+
]
|
| 87 |
+
elif r.abstained:
|
| 88 |
color, label, glyph = INK_UNK, "UNKNOWN", "?"
|
| 89 |
title, sub = "UNKNOWN", "not confident enough β refusing by default"
|
| 90 |
rows = [
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
|
| 126 |
+
def identify(image, image2=None):
|
| 127 |
+
"""Generator: show the vine animation, then the readout.
|
| 128 |
+
|
| 129 |
+
Takes an optional second photo. The safety rule (pipeline.session) means a
|
| 130 |
+
single photo never surfaces an edible verdict β it asks for the second β so
|
| 131 |
+
both inputs feed one resolve_session call.
|
| 132 |
+
"""
|
| 133 |
+
images = [im for im in (image, image2) if im is not None]
|
| 134 |
+
if not images:
|
| 135 |
yield _idle()
|
| 136 |
return
|
| 137 |
t0 = time.time()
|
| 138 |
yield _loading()
|
| 139 |
+
result = resolve_session(PIPE, images)
|
| 140 |
elapsed = time.time() - t0
|
| 141 |
if elapsed < 1.6: # let the vine finish drawing
|
| 142 |
time.sleep(1.6 - elapsed)
|
|
|
|
| 375 |
with gr.Column(scale=1):
|
| 376 |
img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
|
| 377 |
elem_classes="eink-input", height=300)
|
| 378 |
+
img2 = gr.Image(type="pil", label="SECOND ANGLE β confirms an edible ID",
|
| 379 |
+
sources=["upload", "webcam"], elem_classes="eink-input",
|
| 380 |
+
height=180)
|
| 381 |
btn = gr.Button("βΈ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
|
| 382 |
if os.path.isdir(EXAMPLES_DIR):
|
| 383 |
samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
|
|
|
|
| 391 |
gr.HTML(QUOTE_BAR)
|
| 392 |
gr.Markdown(SAFETY_NOTICE, elem_id="notice")
|
| 393 |
|
| 394 |
+
btn.click(identify, inputs=[img, img2], outputs=out)
|
| 395 |
+
img.change(identify, inputs=[img, img2], outputs=out)
|
| 396 |
+
img2.change(identify, inputs=[img, img2], outputs=out)
|
| 397 |
|
| 398 |
with gr.Tab("β BEAT THE MACHINE"):
|
| 399 |
build_game_tab(PIPE)
|
pipeline/convergence.py
CHANGED
|
@@ -37,6 +37,9 @@ class ForagerResult:
|
|
| 37 |
expert_model: str
|
| 38 |
abstained: bool
|
| 39 |
reason: str # why we abstained (or "" when committed)
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@property
|
| 42 |
def is_deadly(self) -> bool:
|
|
@@ -71,7 +74,7 @@ def _commit(domain: str, c: dict) -> ForagerResult:
|
|
| 71 |
domain=domain, species=species, scientific_name=meta["scientific"], confidence=conf,
|
| 72 |
safety=meta["safety"], lookalike=meta["lookalike"], key_diff=meta["key_diff"],
|
| 73 |
low_confidence=conf < LOW_CONFIDENCE_THRESHOLD, expert_model=c["expert"],
|
| 74 |
-
abstained=False, reason="",
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
|
|
| 37 |
expert_model: str
|
| 38 |
abstained: bool
|
| 39 |
reason: str # why we abstained (or "" when committed)
|
| 40 |
+
energy: float | None = None # winning call's OOD energy β telemetry,
|
| 41 |
+
# not a gate (energy doesn't separate
|
| 42 |
+
# deadly-as-safe); kept for drift monitoring.
|
| 43 |
|
| 44 |
@property
|
| 45 |
def is_deadly(self) -> bool:
|
|
|
|
| 74 |
domain=domain, species=species, scientific_name=meta["scientific"], confidence=conf,
|
| 75 |
safety=meta["safety"], lookalike=meta["lookalike"], key_diff=meta["key_diff"],
|
| 76 |
low_confidence=conf < LOW_CONFIDENCE_THRESHOLD, expert_model=c["expert"],
|
| 77 |
+
abstained=False, reason="", energy=c.get("energy"),
|
| 78 |
)
|
| 79 |
|
| 80 |
|
pipeline/infer.py
CHANGED
|
@@ -122,6 +122,11 @@ class Pipeline:
|
|
| 122 |
idx = int(probs.argmax())
|
| 123 |
return self._classes[name][idx], float(probs[idx]), _energy(logits)
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def identify(self, img: Image.Image) -> dict:
|
| 126 |
"""
|
| 127 |
Returns a dict describing the call:
|
|
@@ -148,3 +153,55 @@ class Pipeline:
|
|
| 148 |
|
| 149 |
call = {"expert": ename, "species": species, "confidence": conf, "energy": round(energy, 4)}
|
| 150 |
return {**out, "abstain": False, "calls": [call]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
idx = int(probs.argmax())
|
| 123 |
return self._classes[name][idx], float(probs[idx]), _energy(logits)
|
| 124 |
|
| 125 |
+
def _run_probs(self, name: str, x: np.ndarray) -> tuple[np.ndarray, float]:
|
| 126 |
+
"""Returns (full softmax prob vector, energy) β for multi-photo fusion."""
|
| 127 |
+
logits = self._sessions[name].run(None, {"input": x})[0][0]
|
| 128 |
+
return _softmax(logits), _energy(logits)
|
| 129 |
+
|
| 130 |
def identify(self, img: Image.Image) -> dict:
|
| 131 |
"""
|
| 132 |
Returns a dict describing the call:
|
|
|
|
| 153 |
|
| 154 |
call = {"expert": ename, "species": species, "confidence": conf, "energy": round(energy, 4)}
|
| 155 |
return {**out, "abstain": False, "calls": [call]}
|
| 156 |
+
|
| 157 |
+
def identify_many(self, images: list[Image.Image]) -> dict:
|
| 158 |
+
"""Fuse K photos of one specimen into a single call.
|
| 159 |
+
|
| 160 |
+
Late-fusion by GEOMETRIC MEAN of the experts' softmax vectors (treats
|
| 161 |
+
each photo as independent evidence: agreement sharpens the peak,
|
| 162 |
+
disagreement flattens it), plus a CONSENSUS requirement β every photo's
|
| 163 |
+
own top class must match the fused top class. This is the K>=2 protocol
|
| 164 |
+
validated in scripts/multiview_smoketest.py (deadly-shown-edible -> 0.00%
|
| 165 |
+
at K=2). K=1 degenerates exactly to identify()'s single call.
|
| 166 |
+
|
| 167 |
+
Abstains (and says why) when the photos disagree on domain or class, so
|
| 168 |
+
a confident-but-wrong single frame can't carry an edible verdict.
|
| 169 |
+
"""
|
| 170 |
+
if not images:
|
| 171 |
+
return {"domain": "unknown", "domain_confidence": 0.0,
|
| 172 |
+
"abstain": True, "reason": "low_confidence"}
|
| 173 |
+
if len(images) == 1:
|
| 174 |
+
return self.identify(images[0])
|
| 175 |
+
|
| 176 |
+
# ββ Stage 1: every photo must route to the SAME in-domain domain ββββββ
|
| 177 |
+
routed = [self._run(ROUTER, preprocess(img)) for img in images]
|
| 178 |
+
domains = {d for d, _, _ in routed}
|
| 179 |
+
dconf = min(c for _, c, _ in routed)
|
| 180 |
+
domain = next(iter(domains))
|
| 181 |
+
out = {"domain": domain if len(domains) == 1 else "mixed",
|
| 182 |
+
"domain_confidence": dconf}
|
| 183 |
+
if len(domains) != 1 or dconf < ROUTER_CONFIDENCE_THRESHOLD or domain not in DOMAIN_EXPERTS:
|
| 184 |
+
reason = ("off_domain" if len(domains) == 1 and domain not in DOMAIN_EXPERTS
|
| 185 |
+
else "uncertain_domain")
|
| 186 |
+
return {**out, "abstain": True, "reason": reason}
|
| 187 |
+
|
| 188 |
+
# ββ Stage 2: run the domain's expert on each photo, fuse, check consensus
|
| 189 |
+
ename = DOMAIN_EXPERTS[domain]
|
| 190 |
+
classes = self._classes[ename]
|
| 191 |
+
per_photo = [self._run_probs(ename, preprocess(img)) for img in images]
|
| 192 |
+
prob_rows = np.stack([p for p, _ in per_photo]) # [K, C]
|
| 193 |
+
energy = float(np.mean([e for _, e in per_photo]))
|
| 194 |
+
|
| 195 |
+
logp = np.log(np.clip(prob_rows, 1e-9, 1.0)).mean(axis=0) # geometric mean
|
| 196 |
+
fused = np.exp(logp - logp.max())
|
| 197 |
+
fused /= fused.sum()
|
| 198 |
+
top = int(fused.argmax())
|
| 199 |
+
|
| 200 |
+
per_photo_top = {int(row.argmax()) for row in prob_rows}
|
| 201 |
+
if per_photo_top != {top}:
|
| 202 |
+
# Photos disagree on the species β refuse rather than commit.
|
| 203 |
+
return {**out, "abstain": True, "reason": "no_consensus"}
|
| 204 |
+
|
| 205 |
+
call = {"expert": ename, "species": classes[top],
|
| 206 |
+
"confidence": float(fused[top]), "energy": round(energy, 4)}
|
| 207 |
+
return {**out, "abstain": False, "calls": [call]}
|
pipeline/session.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""session.py β the two-photo safety policy that sits over the resolver.
|
| 2 |
+
|
| 3 |
+
The core safety rule, stated plainly:
|
| 4 |
+
|
| 5 |
+
You are never shown an edible verdict on the strength of a single photo.
|
| 6 |
+
|
| 7 |
+
A single photo can show DEADLY (over-warning is always safe) or abstain. A
|
| 8 |
+
SAFE/CAUTION identification is only surfaced when a SECOND photo agrees with the
|
| 9 |
+
first (geometric-mean fusion + consensus, in infer.identify_many). So the second
|
| 10 |
+
photo is requested exactly, and only, when the system is leaning toward telling
|
| 11 |
+
you something is edible β the one moment extra rigor is worth the friction.
|
| 12 |
+
|
| 13 |
+
This is what closes the ~1% single-shot deadly-shown-edible residual to 0.00%
|
| 14 |
+
(scripts/multiview_smoketest.py).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
from .convergence import ForagerResult, build_result
|
| 20 |
+
from .infer import Pipeline
|
| 21 |
+
|
| 22 |
+
# There is no confidence bar that lets one photo commit edible. A 0.85 bar used to:
|
| 23 |
+
# the val sets had put every single-photo deadly-as-edible miss at <= 0.772. Over
|
| 24 |
+
# the full val sets, two toxic mushroom photos commit as SAFE above it (lion's
|
| 25 |
+
# mane at 0.91, chanterelle at 0.87), and val is the optimistic case. A confident
|
| 26 |
+
# wrong answer looks exactly like a confident right one, so no threshold on one
|
| 27 |
+
# photo's confidence can hold; only a second look can.
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def resolve_session(pipe: Pipeline, images: list[Image.Image]) -> ForagerResult:
|
| 31 |
+
"""Run the K-photo pipeline and apply the single-photo edible gate.
|
| 32 |
+
|
| 33 |
+
- 0 photos -> abstain.
|
| 34 |
+
- DEADLY (any photo count) -> surface it. Over-warning is safe.
|
| 35 |
+
- 1 photo, SAFE/CAUTION, at any confidence -> withhold; ask for a second photo.
|
| 36 |
+
- 2+ photos -> the fused/consensus verdict from identify_many stands.
|
| 37 |
+
"""
|
| 38 |
+
result = build_result(pipe.identify_many(images))
|
| 39 |
+
|
| 40 |
+
if (len(images) == 1
|
| 41 |
+
and not result.abstained
|
| 42 |
+
and result.safety in ("SAFE", "CAUTION")):
|
| 43 |
+
return _need_second_photo(result)
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _need_second_photo(provisional: ForagerResult) -> ForagerResult:
|
| 48 |
+
"""Turn a single-photo edible lean into a 'add a second photo' refusal,
|
| 49 |
+
carrying the provisional species so the UI can say what it's leaning toward
|
| 50 |
+
without committing to it."""
|
| 51 |
+
return ForagerResult(
|
| 52 |
+
domain=provisional.domain,
|
| 53 |
+
species="unknown",
|
| 54 |
+
scientific_name=provisional.scientific_name,
|
| 55 |
+
confidence=provisional.confidence,
|
| 56 |
+
safety="UNKNOWN",
|
| 57 |
+
lookalike=provisional.lookalike,
|
| 58 |
+
key_diff=(f"Leaning {provisional.species.replace('_', ' ')}, but an edible call "
|
| 59 |
+
f"needs a second photo from a different angle to confirm."),
|
| 60 |
+
low_confidence=True,
|
| 61 |
+
expert_model=provisional.expert_model,
|
| 62 |
+
abstained=True,
|
| 63 |
+
reason="need_second_photo",
|
| 64 |
+
)
|
tests/test_two_photo.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Two-photo safety protocol tests, against the real ONNX models.
|
| 2 |
+
|
| 3 |
+
The load-bearing guarantee: no edible verdict is ever surfaced from a single
|
| 4 |
+
photo, and two disagreeing photos abstain rather than commit.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 14 |
+
|
| 15 |
+
from pipeline.infer import Pipeline # noqa: E402
|
| 16 |
+
from pipeline.session import resolve_session # noqa: E402
|
| 17 |
+
|
| 18 |
+
ML = "/home/alex/Documents/Forager/forager_ml"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(scope="module")
|
| 22 |
+
def pipe():
|
| 23 |
+
return Pipeline()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _img(path):
|
| 27 |
+
return Image.open(path)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _first(cls_dir, n=1):
|
| 31 |
+
files = sorted(f for f in os.listdir(cls_dir)
|
| 32 |
+
if f.lower().endswith((".jpg", ".jpeg", ".png")))
|
| 33 |
+
return [os.path.join(cls_dir, f) for f in files[:n]]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Policy: single photo never surfaces an edible verdict ββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
def test_single_photo_never_commits_edible(pipe):
|
| 39 |
+
"""A single photo never surfaces an edible (SAFE/CAUTION) verdict, at any
|
| 40 |
+
confidence: it is withheld and routed to a second photo."""
|
| 41 |
+
safe_dir = f"{ML}/berry_dataset_split/val/blackberry_common"
|
| 42 |
+
if not os.path.isdir(safe_dir):
|
| 43 |
+
pytest.skip("val data not present")
|
| 44 |
+
for path in _first(safe_dir, 12):
|
| 45 |
+
r = resolve_session(pipe, [_img(path)])
|
| 46 |
+
assert not (not r.abstained and r.safety in ("SAFE", "CAUTION")), (
|
| 47 |
+
f"single photo committed {r.safety} at {r.confidence:.3f} for {path}")
|
| 48 |
+
if r.abstained:
|
| 49 |
+
assert r.reason in ("need_second_photo", "low_confidence",
|
| 50 |
+
"uncertain_domain", "off_domain")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_confident_deadly_mushroom_is_not_shown_safe(pipe):
|
| 54 |
+
"""The case that removed the 0.85 shortcut: toxic-catch-all photos the
|
| 55 |
+
mushroom expert calls lion's mane (0.91) and chanterelle (0.87)."""
|
| 56 |
+
d = f"{ML}/high_value_dataset_split/val/high_value_toxics"
|
| 57 |
+
paths = [f"{d}/599814795.jpg", f"{d}/616602478.jpg"]
|
| 58 |
+
if not all(os.path.isfile(p) for p in paths):
|
| 59 |
+
pytest.skip("val data not present")
|
| 60 |
+
for path in paths:
|
| 61 |
+
r = resolve_session(pipe, [_img(path)])
|
| 62 |
+
assert r.abstained and r.reason == "need_second_photo", (
|
| 63 |
+
f"{path} surfaced as {r.species} {r.safety} at {r.confidence:.3f}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_two_agreeing_photos_can_commit_safe(pipe):
|
| 67 |
+
"""Two photos of the same SAFE class should be allowed to commit (the whole
|
| 68 |
+
point β the second photo unlocks an edible verdict)."""
|
| 69 |
+
safe_dir = f"{ML}/berry_dataset_split/val/blackberry_common"
|
| 70 |
+
if not os.path.isdir(safe_dir):
|
| 71 |
+
pytest.skip("val data not present")
|
| 72 |
+
committed = 0
|
| 73 |
+
paths = _first(safe_dir, 12)
|
| 74 |
+
# try a few pairs; at least one agreeing pair should commit SAFE
|
| 75 |
+
for i in range(0, len(paths) - 1, 2):
|
| 76 |
+
r = resolve_session(pipe, [_img(paths[i]), _img(paths[i + 1])])
|
| 77 |
+
if not r.abstained and r.safety == "SAFE":
|
| 78 |
+
committed += 1
|
| 79 |
+
assert committed >= 1, "no agreeing SAFE pair ever committed β fusion too strict"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_deadly_can_surface_on_single_photo(pipe):
|
| 83 |
+
"""Over-warning is safe: a DEADLY read on one photo is allowed through."""
|
| 84 |
+
deadly_dir = f"{ML}/berry_dataset_split/val/pokeweed_toxic"
|
| 85 |
+
if not os.path.isdir(deadly_dir):
|
| 86 |
+
pytest.skip("val data not present")
|
| 87 |
+
# We don't require every pokeweed to read DEADLY on one shot, only that the
|
| 88 |
+
# policy PERMITS a single-photo DEADLY (never converts it to need_second).
|
| 89 |
+
for path in _first(deadly_dir, 8):
|
| 90 |
+
r = resolve_session(pipe, [_img(path)])
|
| 91 |
+
if r.safety == "DEADLY":
|
| 92 |
+
assert not r.abstained
|
| 93 |
+
return
|
| 94 |
+
pytest.skip("no single-shot DEADLY in sample (still safe: they abstained)")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_two_photos_no_deadly_shown_edible(pipe):
|
| 98 |
+
"""The core claim: fuse two photos of DEADLY berry specimens; none may be
|
| 99 |
+
surfaced as edible (SAFE/CAUTION committed)."""
|
| 100 |
+
for cls in ("pokeweed_toxic", "canada_moonseed_deadly"):
|
| 101 |
+
d = f"{ML}/berry_dataset_split/val/{cls}"
|
| 102 |
+
if not os.path.isdir(d):
|
| 103 |
+
pytest.skip("val data not present")
|
| 104 |
+
paths = _first(d, 12)
|
| 105 |
+
for i in range(0, len(paths) - 1, 2):
|
| 106 |
+
r = resolve_session(pipe, [_img(paths[i]), _img(paths[i + 1])])
|
| 107 |
+
assert not (not r.abstained and r.safety in ("SAFE", "CAUTION")), (
|
| 108 |
+
f"{cls} pair surfaced as {r.safety} β toxic-as-edible at K=2")
|