HomesteaderLabs commited on
Commit
6f38f56
Β·
verified Β·
1 Parent(s): 921b248

Safety fix: never return an edible verdict from a single photo

Browse files

The live build has a 0.85-confidence shortcut that can return an edible verdict from one photo. In testing, two photos of toxic mushrooms cleared it as SAFE. This PR removes the shortcut, so an edible verdict always needs a second photo, and brings the Space up to date with the current pipeline.

Merging is all that's needed. From HomesteaderLabs (alex@homesteaderlabs.com).

.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
- if image is None:
 
 
 
 
 
 
118
  yield _idle()
119
  return
120
  t0 = time.time()
121
  yield _loading()
122
- result = build_result(PIPE.identify(image))
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")