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

#2
by HomesteaderLabs - opened
.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")