Elraphaa commited on
Commit
f6f7b53
·
verified ·
1 Parent(s): 37fc3bf

Deploy verification service: scoring + advisory layer

Browse files
.dockerignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the build context and the image small.
2
+ #
3
+ # Cloud Build uploads the context before building, and Artifact Registry's free
4
+ # tier is 0.5 GB, so anything shipped here is paid for twice: once in upload
5
+ # time on a Ghanaian connection, once in storage.
6
+
7
+ __pycache__/
8
+ *.pyc
9
+ *.pyo
10
+ .pytest_cache/
11
+
12
+ # Tests and the attack harness are developer tools. They are run from a
13
+ # checkout against the live database, never from inside the container, and
14
+ # attack_set.py in particular must not be trivially runnable on the server.
15
+ test_*.py
16
+ attack_set.py
17
+ calibrate.py
18
+
19
+ .env
20
+ .env.*
21
+ *.md
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # One container, no vendor lock-in. Runs on Cloud Run, Render, a Hugging Face
2
+ # Space, or a laptop. Nothing in here is specific to a host.
3
+ FROM python:3.11-slim
4
+
5
+ # OpenCV needs these even in the headless build.
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libglib2.0-0 libgl1 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Run as uid 1000 with a writable $HOME. Hugging Face Spaces requires exactly
11
+ # this, and without a writable HOME huggingface_hub cannot write its model
12
+ # cache and the container dies on first request with a permission error that
13
+ # reads like a network failure.
14
+ RUN useradd -m -u 1000 user
15
+ USER user
16
+ ENV HOME=/home/user \
17
+ PATH=/home/user/.local/bin:$PATH \
18
+ HF_HOME=/home/user/.cache/huggingface \
19
+ PYTHONUNBUFFERED=1
20
+
21
+ WORKDIR $HOME/app
22
+
23
+ COPY --chown=user requirements.txt .
24
+ RUN pip install --no-cache-dir --upgrade pip \
25
+ && pip install --no-cache-dir -r requirements.txt
26
+
27
+ COPY --chown=user . .
28
+
29
+ # BAKE THE MODEL INTO THE IMAGE. This is the difference between a live demo and
30
+ # ninety seconds of dead air on stage.
31
+ #
32
+ # Cloud Run scales to zero, so the first request after idle starts a cold
33
+ # container. If the weights are not already present, that request also waits on
34
+ # a ~90 MB download from Hugging Face before it can score anything. Downloading
35
+ # at BUILD time moves that cost to a machine nobody is watching, and cold start
36
+ # becomes container boot plus ONNX session init.
37
+ #
38
+ # It also removes a runtime dependency on huggingface.co being reachable and
39
+ # not rate-limiting, which is not a thing to discover during a pitch.
40
+ #
41
+ # Costs ~90 MB of image size. Artifact Registry's free tier is 0.5 GB, so this
42
+ # may tip storage into a few cents a month. Worth it.
43
+ RUN python -c "from greenproof_ml.embed import _model_path; print('baked:', _model_path())"
44
+
45
+ EXPOSE 7860
46
+
47
+ # One worker deliberately: the ONNX session is per-process and ~90 MB of
48
+ # weights, and check-in traffic is a handful of requests per round. Two workers
49
+ # would double memory to serve a queue that is never deep.
50
+ #
51
+ # SHELL FORM, so ${PORT} is expanded at runtime. Every container host injects
52
+ # the port it wants on a different variable-or-default convention, and the
53
+ # exec form ["uvicorn", ...] would pass the literal string "${PORT}" and the
54
+ # container would die on boot with an unreadable error. 7860 is the fallback.
55
+ CMD uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1
README.md CHANGED
@@ -1,11 +1,112 @@
1
- ---
2
- title: Greenproof Ml
3
- emoji:
4
- colorFrom: yellow
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- short_description: Greenproof
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GreenProof Verification
3
+ emoji: 🌳
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # GreenProof verification service
12
+
13
+ Scores a tree check-in and writes back a confidence, a verdict and a per-signal
14
+ breakdown. The only server-side surface in the stack.
15
+
16
+ ## Why this exists
17
+
18
+ Everything else in GreenProof is Supabase called directly from the browser. This
19
+ service exists for one reason: **a client-computed verification result is
20
+ forgeable.** The anon key ships inside the PWA and must be assumed public, so
21
+ `confidence`, `verdict` and `signals` are not granted to `authenticated` at the
22
+ column level. This process holds the only key that can write them.
23
+
24
+ `POST /score` takes an id and nothing else. Every input is re-read from the
25
+ database and from storage — a caller can ask for a check-in to be scored, but
26
+ can never influence what the score is.
27
+
28
+ ## Endpoints
29
+
30
+ | Method | Path | Purpose |
31
+ |---|---|---|
32
+ | GET | `/health` | liveness, model-loaded, config present |
33
+ | POST | `/score` | score one check-in — `{"checkin_id": "..."}` |
34
+ | POST | `/advise` | species + care advice — **advisory, never a verdict** |
35
+ | POST | `/backfill?limit=50` | score everything still `pending` |
36
+ | GET | `/docs` | interactive OpenAPI |
37
+
38
+ `/advise` is the only endpoint that is not verification. It writes
39
+ `species_guess` and `advice` and cannot touch `confidence`, `verdict` or
40
+ `signals` — enforced by column grants, and by `pipeline.py` never importing the
41
+ advisor. It is also the only endpoint that **spends money** (~3 cents a call),
42
+ which is why it can be locked behind `ADVISE_TOKEN`.
43
+
44
+ ## How a check-in is judged
45
+
46
+ **Stage 1 — gates.** Disqualifying on their own, run before any scoring.
47
+
48
+ | Gate | Catches |
49
+ |---|---|
50
+ | duplicate image (pHash) | resubmitted photo, gallery photo, internet photo |
51
+ | GPS radius | right tree, wrong place |
52
+ | travel speed | one account submitting from two impossible places |
53
+
54
+ **Stage 2 — scores.** Continuous 0–1 signals, weighted into a confidence.
55
+
56
+ | Score | Signal |
57
+ |---|---|
58
+ | location | distance from the registered point |
59
+ | liveness | excess-green vegetation fraction |
60
+ | scene match | DINOv2 cosine + ORB/RANSAC inliers vs previous visits |
61
+ | growth | canopy and trunk plausibility, not measurement |
62
+
63
+ Five of the six fraud types in our attack set are caught in stage 1 by ordinary
64
+ deterministic code. That is the honest reason the system works, and it is why
65
+ overall accuracy is much better than the 54% rank-1 of image matching alone.
66
+
67
+ ## Honest limits
68
+
69
+ - Image matching is **corroboration, not identity**. Measured on our own 15
70
+ plants: rank-1 54% against 5% chance, AUC 0.77 — real signal, nowhere near
71
+ enough to authorise a payout. It is **bimodal**: near-perfect on distinctive
72
+ trees, near-zero inside a dense same-species stand.
73
+ - Wide-shot matching works partly off the **background**, not the tree.
74
+ - Young bark is smooth; BarkNet's ~94% figures are on **mature** bark.
75
+ - Thresholds and weights in `scoring.py` are **provisional**. They are replaced
76
+ by weights fitted under leave-one-tree-out cross-validation once real rounds
77
+ exist. Nothing is tuned on the data used to report performance.
78
+ - A missing signal lowers confidence and routes to a human. It never counts as
79
+ zero, and it never fails closed on a genuine visit. Designed to degrade.
80
+
81
+ ## Configuration
82
+
83
+ Space → Settings → Variables and secrets:
84
+
85
+ | Name | Kind | Value |
86
+ |---|---|---|
87
+ | `SUPABASE_URL` | variable | your project URL |
88
+ | `SUPABASE_SERVICE_KEY` | **secret** | the `service_role` key |
89
+ | `ANTHROPIC_API_KEY` | **secret** | for `/advise` only — omit and advice is simply disabled |
90
+ | `ADVISE_TOKEN` | **secret** | any random string; required in `X-Advise-Token` on `/advise` |
91
+ | `ALLOWED_ORIGINS` | variable | your Vercel URL |
92
+
93
+ **The service key must never appear in the frontend.** It can write any verdict
94
+ for any tree, and it is the value the whole security model rests on.
95
+
96
+ **`ANTHROPIC_API_KEY` is billable.** A leak is someone else's spending. Omitting
97
+ it is a supported state, not a failure: `/score` is unaffected and `/advise`
98
+ returns `{"written": false}` — the advisory layer fails soft by design so it can
99
+ never take verification down with it.
100
+
101
+ **`ADVISE_TOKEN` matters on a public Space.** Without it, anyone who finds this
102
+ URL and a valid check-in id can spend your Anthropic credit three cents at a
103
+ time. `/score` needs no such lock: it costs only our own CPU.
104
+
105
+ ## Local run
106
+
107
+ ```
108
+ cd ml
109
+ pip install -r requirements.txt
110
+ export SUPABASE_URL=... SUPABASE_SERVICE_KEY=...
111
+ uvicorn app:app --reload --port 7860
112
+ ```
app.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GreenProof verification service.
2
+
3
+ The ONE new surface in the stack. Everything else is Supabase called directly
4
+ from the browser; this exists because a client-computed verification result is
5
+ forgeable, so scoring has to happen somewhere the user cannot reach.
6
+
7
+ Runs on Hugging Face Spaces (Docker SDK). Host-agnostic: the same container
8
+ runs on a laptop, Cloud Run, or anywhere else that can run Docker.
9
+
10
+ GET / service metadata
11
+ GET /health liveness + whether the model is loaded
12
+ POST /score score one check-in by id
13
+ POST /advise species + care advice for one check-in (advisory only)
14
+ POST /backfill score every pending check-in (used after T0)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import secrets
22
+ import threading
23
+
24
+ from fastapi import FastAPI, Header, HTTPException
25
+ from fastapi.middleware.cors import CORSMiddleware
26
+ from pydantic import BaseModel, Field
27
+
28
+ from greenproof_ml import embed as embed_mod
29
+ from greenproof_ml import store
30
+ from greenproof_ml.pipeline import score_checkin
31
+ from greenproof_ml.scoring import MODEL_NAME, MODEL_VERSION
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
35
+ )
36
+ log = logging.getLogger("greenproof")
37
+
38
+ app = FastAPI(title="GreenProof verification service", version=MODEL_VERSION)
39
+
40
+ # The PWA is served from Vercel, so this is a genuine cross-origin call.
41
+ # Set ALLOWED_ORIGINS to the Vercel URL in the Space's variables; the default
42
+ # is permissive so the pilot is never blocked by a CORS typo at 6am, and the
43
+ # endpoints carry no secrets a caller could extract — the service key stays
44
+ # server-side and every write is derived from stored photos, not from the
45
+ # request body.
46
+ origins = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
47
+ app.add_middleware(
48
+ CORSMiddleware,
49
+ allow_origins=[o.strip() for o in origins if o.strip()],
50
+ allow_methods=["GET", "POST"],
51
+ allow_headers=["*"],
52
+ )
53
+
54
+ _model_ready = threading.Event()
55
+
56
+
57
+ @app.on_event("startup")
58
+ def _warm() -> None:
59
+ """Load the model in the background.
60
+
61
+ Spaces health-check the container early. Blocking startup on a ~90 MB model
62
+ download makes the Space look dead and get restarted, which restarts the
63
+ download — a loop that has eaten whole afternoons.
64
+ """
65
+
66
+ def run() -> None:
67
+ try:
68
+ embed_mod.warm()
69
+ _model_ready.set()
70
+ log.info("model ready: %s %s", MODEL_NAME, MODEL_VERSION)
71
+ except Exception:
72
+ log.exception("model failed to load")
73
+
74
+ threading.Thread(target=run, daemon=True).start()
75
+
76
+
77
+ class ScoreRequest(BaseModel):
78
+ checkin_id: str = Field(..., description="checkins.id to score")
79
+
80
+
81
+ class ScoreResponse(BaseModel):
82
+ checkin_id: str
83
+ confidence: int
84
+ verdict: str
85
+ signals: dict
86
+
87
+
88
+ @app.get("/")
89
+ def root() -> dict:
90
+ return {
91
+ "service": "greenproof-verification",
92
+ "model": MODEL_NAME,
93
+ "version": MODEL_VERSION,
94
+ "docs": "/docs",
95
+ }
96
+
97
+
98
+ @app.get("/health")
99
+ def health() -> dict:
100
+ return {
101
+ "ok": True,
102
+ "model_ready": _model_ready.is_set(),
103
+ "supabase_configured": bool(
104
+ os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_SERVICE_KEY")
105
+ ),
106
+ }
107
+
108
+
109
+ @app.post("/score", response_model=ScoreResponse)
110
+ def score(req: ScoreRequest) -> ScoreResponse:
111
+ """Score one check-in.
112
+
113
+ Takes only an id. The request body cannot influence the outcome — every
114
+ input is re-read from the database and from storage. A caller can ask for a
115
+ check-in to be scored; it can never say what the score should be.
116
+ """
117
+ if not _model_ready.is_set():
118
+ raise HTTPException(503, "Model still loading, retry shortly")
119
+ try:
120
+ result = score_checkin(req.checkin_id)
121
+ except LookupError as e:
122
+ raise HTTPException(404, str(e)) from e
123
+ except Exception as e: # noqa: BLE001
124
+ log.exception("scoring failed for %s", req.checkin_id)
125
+ raise HTTPException(500, f"Scoring failed: {e}") from e
126
+
127
+ return ScoreResponse(
128
+ checkin_id=req.checkin_id,
129
+ confidence=result.confidence,
130
+ verdict=result.verdict,
131
+ signals=result.signals,
132
+ )
133
+
134
+
135
+ # Optional shared secret for /advise. Unset means the endpoint is open.
136
+ #
137
+ # WHY THIS EXISTS, AND WHY ONLY ON THIS ENDPOINT.
138
+ #
139
+ # /score is safe to leave open: it takes an id, re-reads every input from the
140
+ # database, and costs us nothing but a few seconds of our own CPU. /advise is
141
+ # different in one specific way - IT SPENDS MONEY. Each call is about 3 cents of
142
+ # Anthropic usage.
143
+ #
144
+ # On a Cloudflare quick tunnel that barely mattered: the hostname rotated and the
145
+ # service was up for minutes at a time. A permanent public Space URL is a
146
+ # different proposition, and an endpoint that bills the operator per request is
147
+ # worth a lock even when the realistic risk is low.
148
+ #
149
+ # Unset by default so local runs and `uvicorn app:app` need no configuration.
150
+ # Set it in the Space's secrets and in .env, and tools/advise_pilot.py sends it.
151
+ ADVISE_TOKEN = os.environ.get("ADVISE_TOKEN", "").strip()
152
+
153
+
154
+ @app.post("/advise")
155
+ def advise(req: ScoreRequest, x_advise_token: str = Header(default="")) -> dict:
156
+ """Species identification and care advice for one check-in.
157
+
158
+ SEPARATE FROM /score ON PURPOSE, and the separation is the design.
159
+
160
+ Scoring is fast, local, offline-capable and replayable - it re-runs over the
161
+ whole pilot dataset whenever a threshold moves, and it must never acquire a
162
+ dependency on an external API that can be slow, rate-limited or down. This
163
+ endpoint is none of those things: it makes a paid network call to a model
164
+ whose error rate we have not measured.
165
+
166
+ So they share a service and nothing else. This writes only `species_guess`
167
+ and `advice`; it cannot move a confidence or a verdict, and a failure here
168
+ leaves the check-in exactly as scoring left it.
169
+
170
+ Note it does NOT require the DINOv2 model to be loaded - the two paths have
171
+ no components in common, so a cold model should not block advice.
172
+ """
173
+ # compare_digest, not ==, so a wrong token cannot be recovered by timing.
174
+ if ADVISE_TOKEN and not secrets.compare_digest(x_advise_token, ADVISE_TOKEN):
175
+ raise HTTPException(401, "Missing or invalid X-Advise-Token")
176
+
177
+ from greenproof_ml import advisor
178
+
179
+ try:
180
+ result = advisor.advise_checkin(req.checkin_id)
181
+ except LookupError as e:
182
+ raise HTTPException(404, str(e)) from e
183
+ except Exception as e: # noqa: BLE001
184
+ log.exception("advice failed for %s", req.checkin_id)
185
+ raise HTTPException(500, f"Advice failed: {e}") from e
186
+
187
+ if result is None:
188
+ # Not an error. Either the tree shows no decline and the policy skipped
189
+ # it, or the model declined to answer. Both leave the row untouched.
190
+ return {"checkin_id": req.checkin_id, "written": False}
191
+
192
+ return {"checkin_id": req.checkin_id, "written": True, **result}
193
+
194
+
195
+ @app.post("/backfill")
196
+ def backfill(limit: int = 50) -> dict:
197
+ """Score everything still pending.
198
+
199
+ This is what makes T0 safe to run before the service exists: registration
200
+ only captures and uploads, and the photos sit as `pending` until this is
201
+ called. Nothing about the pilot depends on the ML service being live on the
202
+ day.
203
+ """
204
+ if not _model_ready.is_set():
205
+ raise HTTPException(503, "Model still loading, retry shortly")
206
+
207
+ rows = store.list_pending(limit)
208
+ done, failed = [], []
209
+ for row in rows:
210
+ try:
211
+ result = score_checkin(row["id"])
212
+ done.append({"id": row["id"], "verdict": result.verdict, "confidence": result.confidence})
213
+ except Exception as e: # noqa: BLE001
214
+ log.exception("backfill failed for %s", row["id"])
215
+ failed.append({"id": row["id"], "error": str(e)})
216
+
217
+ return {"scored": len(done), "failed": len(failed), "results": done, "errors": failed}
greenproof_ml/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """GreenProof verification signals, scoring and persistence."""
2
+
3
+ __version__ = "2026.08.1"
greenproof_ml/advisor.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Species identification and care advice for the planter.
2
+
3
+ THE ONE THING TO UNDERSTAND ABOUT THIS FILE
4
+
5
+ Nothing here is a verification signal, and nothing here may become one.
6
+
7
+ The verification engine answers "is this the same tree, alive, in the right
8
+ place, not a duplicate" - and it answers it with detectors whose error rate we
9
+ have measured and published. This file answers a different question that nobody
10
+ has been answering at all: "what is wrong with this tree and what should the
11
+ planter do about it?"
12
+
13
+ Those two questions deserve different standards of evidence, so they are kept
14
+ apart at every level: a separate module THE SCORING PATH NEVER IMPORTS, a
15
+ separate endpoint, separate database columns, and a separate place in the UI
16
+ that names the model and says the word "advice".
17
+
18
+ The import direction is the load-bearing part. `pipeline.py` does not know this
19
+ file exists, so there is no code path by which a slow, failed or hallucinated
20
+ advisory call can affect a verdict. Check that property still holds before
21
+ adding any import to this module's callers:
22
+
23
+ grep -rn "advisor" ml/greenproof_ml/pipeline.py ml/greenproof_ml/scoring.py
24
+
25
+ should print nothing, permanently.
26
+
27
+ WHY AN LLM HERE, WHEN WE REFUSED ONE EVERYWHERE ELSE
28
+
29
+ The obvious alternative is a leaf-disease classifier trained on PlantVillage.
30
+ We are not doing that, for three reasons and the first is disqualifying:
31
+
32
+ 1. PlantVillage is single leaves on uniform lab backgrounds. Published
33
+ cross-domain evaluations collapse from ~99% to roughly 30-50% on real
34
+ field photographs. We have already made exactly this mistake once and
35
+ turned it into our best slide: the plant check scored a perfect AUC of
36
+ 1.000 and then flagged five of nine real check-ins as "not a plant",
37
+ because it had learned photo STYLE, not subject. Shipping a PlantVillage
38
+ classifier would be repeating a mistake we have documented.
39
+
40
+ 2. It covers fourteen crops - tomato, potato, corn, grape. None of them are
41
+ Odum, Wawa, Ofram, Ceiba, or anything else in a Ghanaian planting scheme.
42
+
43
+ 3. We have no ground truth. No agronomist has labelled our trees. This
44
+ project's entire claim is that we publish our own measured error rate, and
45
+ a classifier whose error rate we cannot measure would be the one component
46
+ contradicting that claim.
47
+
48
+ An LLM's output is hedged natural language that a person reads and judges, not
49
+ a number that authorises a payment. The bar it has to clear is therefore much
50
+ lower, and it is honest about clearing a lower bar - which is why `limitations`
51
+ below is a required field rather than an optional one.
52
+
53
+ FAILS SOFT, ALWAYS. No key, no network, a rate limit, a refusal, a malformed
54
+ response: every one of them logs and returns None. The caller writes nothing and
55
+ the check-in is untouched. This service being unreachable already costs us
56
+ nothing (scoring is asynchronous and replayable); the advisory layer inherits
57
+ that property rather than weakening it.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import base64
63
+ import io
64
+ import logging
65
+ import os
66
+ from typing import Literal
67
+
68
+ from PIL import Image
69
+ from pydantic import BaseModel, Field
70
+
71
+ log = logging.getLogger(__name__)
72
+
73
+ # Opus 5. The images are small and the call is off the critical path, so there
74
+ # is no reason to trade quality away here - this is the output a planter reads
75
+ # and acts on, and bad advice about a real tree is worse than no advice.
76
+ MODEL = "claude-opus-5"
77
+
78
+ # Generous. The call is fire-and-forget from the caller's perspective and a slow
79
+ # response costs nothing, whereas a truncated one wastes the whole request.
80
+ MAX_TOKENS = 2000
81
+
82
+ # Images are re-encoded to this before sending. The photos in storage are
83
+ # already 800px/q75 (the client downscales before upload), so this is a ceiling
84
+ # rather than a resize in the normal case, and it bounds the token cost of a
85
+ # photo that arrived by some other route.
86
+ MAX_EDGE_PX = 800
87
+ JPEG_QUALITY = 75
88
+
89
+
90
+ class PlantAssessment(BaseModel):
91
+ """What we ask Claude to return, and what we store.
92
+
93
+ Every field is chosen so that a reader can tell how much to trust it.
94
+ `species_confidence` is SELF-REPORTED and labelled as such in the UI - it is
95
+ not a measured accuracy and must never be presented as one.
96
+ """
97
+
98
+ species_common: str | None = Field(
99
+ description="Common name of the species, or null if not identifiable."
100
+ )
101
+ species_scientific: str | None = Field(
102
+ description="Binomial scientific name, or null if not identifiable."
103
+ )
104
+ species_confidence: float = Field(
105
+ ge=0.0,
106
+ le=1.0,
107
+ description="Your own confidence in the species identification, 0 to 1.",
108
+ )
109
+ health: Literal["healthy", "stressed", "declining", "cannot_tell"] = Field(
110
+ description="Overall condition of the plant as far as the photos show it."
111
+ )
112
+ observations: list[str] = Field(
113
+ description="What is actually visible in the photographs. Concrete and "
114
+ "specific: leaf colour, leaf loss, wilting, damage, the state of the "
115
+ "soil. Do not speculate beyond what the image shows."
116
+ )
117
+ actions: list[str] = Field(
118
+ description="What the planter should do, in plain language, achievable "
119
+ "by one person with no equipment and no money."
120
+ )
121
+ limitations: str = Field(
122
+ description="What these photographs could NOT tell you, and what would "
123
+ "need to be checked in person."
124
+ )
125
+
126
+
127
+ # `limitations` being required is the design, not a formality. An assessment
128
+ # that cannot say what it failed to see is indistinguishable from one that saw
129
+ # everything, and a planter cannot calibrate how much to trust it.
130
+ SYSTEM = """You are advising a smallholder tree planter in Ghana who has \
131
+ photographed a young tree (roughly 1-3 years old) they are responsible for \
132
+ keeping alive. They are paid when the tree survives, so your advice has real \
133
+ consequences for them.
134
+
135
+ You will receive one to three photographs of the same tree from the same visit: \
136
+ a wide shot of the whole tree and its surroundings, a close-up of the trunk, \
137
+ and sometimes a close-up of a leaf.
138
+
139
+ Your job is to identify the species if you can, describe the tree's condition, \
140
+ and tell the planter what to do about it.
141
+
142
+ Rules:
143
+
144
+ - Describe only what is visible. If the photographs do not show something, say \
145
+ so in `limitations` rather than guessing at it.
146
+ - Prefer species common in Ghanaian planting schemes where the image supports \
147
+ it (Odum/Milicia, Wawa/Triplochiton, Ofram/Terminalia, Mahogany/Khaya, \
148
+ Neem/Azadirachta, Ceiba, Mango/Mangifera, Cassia, Acacia, Teak/Tectona), but do \
149
+ not force a match. Return null for species rather than a bad guess, and let \
150
+ `species_confidence` reflect genuine uncertainty.
151
+ - Recommend only actions a person can take with their hands, water, mulch and \
152
+ local materials. No paid inputs, no laboratory tests, no equipment. Watering, \
153
+ mulching, weeding around the base, removing competing growth, staking, \
154
+ protecting from livestock, and clearing termite damage are the realistic \
155
+ interventions.
156
+ - If the tree looks healthy, say so plainly and give one or two things worth \
157
+ keeping up. Do not invent problems.
158
+ - Be brief. Two to four observations, two to four actions. This is read on a \
159
+ phone, outdoors, by someone standing in front of the tree.
160
+ - Never mention verification, scoring, confidence scores or payment. That is a \
161
+ different part of this system and not your concern."""
162
+
163
+
164
+ def _client():
165
+ """Constructed per call, deliberately.
166
+
167
+ The API key is read from the environment at call time rather than import
168
+ time, so the module imports cleanly on a machine that has no key - which is
169
+ every machine running the scoring path, since scoring must never acquire a
170
+ dependency on this file.
171
+ """
172
+ import anthropic # imported here so a missing SDK cannot break scoring
173
+
174
+ if not os.environ.get("ANTHROPIC_API_KEY"):
175
+ raise RuntimeError(
176
+ "ANTHROPIC_API_KEY is not set. Note that a Claude Pro subscription "
177
+ "is NOT API access - the API bills separately at console.anthropic.com."
178
+ )
179
+ return anthropic.Anthropic()
180
+
181
+
182
+ def _image_block(img: Image.Image, label: str) -> list[dict]:
183
+ """One photo as a labelled pair of content blocks.
184
+
185
+ The text label matters: without it the model cannot tell a trunk close-up
186
+ from a leaf close-up, and will describe bark as foliage.
187
+ """
188
+ im = img.convert("RGB")
189
+ im.thumbnail((MAX_EDGE_PX, MAX_EDGE_PX))
190
+
191
+ buf = io.BytesIO()
192
+ im.save(buf, format="JPEG", quality=JPEG_QUALITY)
193
+ data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
194
+
195
+ return [
196
+ {"type": "text", "text": label},
197
+ {
198
+ "type": "image",
199
+ "source": {"type": "base64", "media_type": "image/jpeg", "data": data},
200
+ },
201
+ ]
202
+
203
+
204
+ def assess(
205
+ *,
206
+ wide: Image.Image | None = None,
207
+ close: Image.Image | None = None,
208
+ leaf: Image.Image | None = None,
209
+ recorded_species: str | None = None,
210
+ ) -> PlantAssessment | None:
211
+ """Identify the species and assess the tree's health. None on any failure.
212
+
213
+ EVERY PHOTO THAT EXISTS IS SENT, and none is required. That is what lets
214
+ this run against the pilot check-ins captured before `leaf_photo` existed,
215
+ and what lets it improve on its own as leaf photos start arriving - the same
216
+ property the plant reference set has, where every verified check-in makes
217
+ the next assessment slightly better.
218
+
219
+ `recorded_species` is what the planter typed at registration. It is passed
220
+ as CONTEXT TO DISAGREE WITH, never as an answer to confirm: the whole value
221
+ of an independent identification is lost if we tell the model what to say.
222
+ """
223
+ blocks: list[dict] = []
224
+ if wide is not None:
225
+ blocks += _image_block(wide, "Wide shot: the whole tree and its surroundings.")
226
+ if close is not None:
227
+ blocks += _image_block(close, "Close-up of the trunk.")
228
+ if leaf is not None:
229
+ blocks += _image_block(leaf, "Close-up of a leaf.")
230
+
231
+ if not blocks:
232
+ log.warning("advisor.assess called with no photographs")
233
+ return None
234
+
235
+ prompt = "Identify this tree and assess its condition."
236
+ if recorded_species:
237
+ # Framed to invite contradiction. "The planter recorded X" is a claim to
238
+ # test; "this is an X" would be an instruction to agree.
239
+ prompt += (
240
+ f"\n\nThe planter recorded this tree's species as '{recorded_species}' "
241
+ "when they registered it. Treat that as an unverified claim, not as "
242
+ "the answer. If the photographs show something else, say so."
243
+ )
244
+ blocks.append({"type": "text", "text": prompt})
245
+
246
+ try:
247
+ response = _client().messages.parse(
248
+ model=MODEL,
249
+ max_tokens=MAX_TOKENS,
250
+ system=SYSTEM,
251
+ messages=[{"role": "user", "content": blocks}],
252
+ output_format=PlantAssessment,
253
+ )
254
+ except Exception: # noqa: BLE001 - advisory only, must never break a caller
255
+ log.exception("advisory assessment failed")
256
+ return None
257
+
258
+ # A refusal is a successful HTTP call with no parsed output. Guard before
259
+ # reading rather than raising out of a function documented never to raise.
260
+ parsed = getattr(response, "parsed_output", None)
261
+ if parsed is None:
262
+ log.warning(
263
+ "advisory assessment returned no parsed output (stop_reason=%s)",
264
+ getattr(response, "stop_reason", None),
265
+ )
266
+ return None
267
+
268
+ return parsed
269
+
270
+
271
+ def split(assessment: PlantAssessment) -> tuple[dict, dict]:
272
+ """One assessment -> the two columns it is stored in.
273
+
274
+ Species and advice are separated in the database because they have different
275
+ futures: species is a candidate verification signal (a species that changes
276
+ between visits is evidence of a swapped tree), and advice never will be.
277
+ Storing them together would make that separation a refactor later.
278
+ """
279
+ species = {
280
+ "common": assessment.species_common,
281
+ "scientific": assessment.species_scientific,
282
+ # Named to make its nature unmissable at every layer. This is the
283
+ # model's opinion of itself, not a measured accuracy, and calling the
284
+ # field `confidence` next to a column that genuinely IS a measured
285
+ # confidence would be actively misleading.
286
+ "self_reported_confidence": assessment.species_confidence,
287
+ "model": MODEL,
288
+ }
289
+ advice = {
290
+ "health": assessment.health,
291
+ "observations": assessment.observations,
292
+ "actions": assessment.actions,
293
+ "limitations": assessment.limitations,
294
+ "model": MODEL,
295
+ "advisory_only": True,
296
+ }
297
+ return species, advice
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # When to spend a call
302
+ # ---------------------------------------------------------------------------
303
+ #
304
+ # THE DESIGNED POLICY IS DECLINE-ONLY. The deterministic canopy signal in
305
+ # scoring.score_growth already flags a tree that has lost more than half its
306
+ # canopy since the last visit - that is the dying-tree detector, it costs
307
+ # nothing, and it is measured. Calling an LLM only when that fires is what keeps
308
+ # the per-tree cost defensible at scale:
309
+ #
310
+ # ~3 cents a call. One million trees checked four times a year is
311
+ # ~$120k on every visit, ~$6-12k on decline only.
312
+ #
313
+ # WHY IT IS CURRENTLY TRUE FOR EVERY CHECK-IN ANYWAY. The pilot is 6 trees and
314
+ # 9 check-ins, and most of those are first visits - where score_growth returns a
315
+ # neutral 0.5 because there is no previous canopy to compare against. A
316
+ # decline-only trigger would fire zero times on the data we actually have, and
317
+ # an advisory layer with nothing to advise on is not a feature.
318
+ #
319
+ # So this is a policy constant with both branches live, rather than a hardcoded
320
+ # call site. Both statements are true and both are defensible: we assess every
321
+ # visit at pilot scale, and the design for scale is decline-only.
322
+ ADVISE_ON_EVERY_CHECKIN = True
323
+
324
+ # Below this growth score, the tree is declining enough to be worth advice.
325
+ # Matches the 0.15 that score_growth assigns to >50% canopy loss.
326
+ DECLINE_GROWTH_SCORE = 0.2
327
+
328
+
329
+ def should_advise(checkin: dict) -> bool:
330
+ """Is this check-in worth spending an API call on?"""
331
+ if ADVISE_ON_EVERY_CHECKIN:
332
+ return True
333
+
334
+ growth = ((checkin.get("signals") or {}).get("scores") or {}).get("growth") or {}
335
+ score = growth.get("score")
336
+ # An unscored check-in has no decline evidence either way. Advising on it
337
+ # would quietly restore "every check-in" through the back door.
338
+ return score is not None and score <= DECLINE_GROWTH_SCORE
339
+
340
+
341
+ def advise_checkin(checkin_id: str) -> dict | None:
342
+ """Assess one check-in and store the result. None if nothing was written.
343
+
344
+ Fetches its own photos from storage, exactly as scoring does, so the request
345
+ body cannot influence what gets assessed.
346
+ """
347
+ from . import store # local: keeps the module importable without credentials
348
+
349
+ row = store.get_checkin(checkin_id)
350
+ if row is None:
351
+ raise LookupError(f"No check-in {checkin_id}")
352
+
353
+ if not should_advise(row):
354
+ log.info("skipping advice for %s: no decline detected", checkin_id)
355
+ return None
356
+
357
+ tree = store.get_tree(row["tree_id"])
358
+
359
+ def _photo(path: str | None) -> Image.Image | None:
360
+ """A missing or unreadable photo costs us that photo, never the call."""
361
+ if not path:
362
+ return None
363
+ try:
364
+ return store.download_image(path)
365
+ except Exception: # noqa: BLE001
366
+ log.warning("could not download %s", path, exc_info=True)
367
+ return None
368
+
369
+ assessment = assess(
370
+ wide=_photo(row.get("wide_photo")),
371
+ close=_photo(row.get("close_photo")),
372
+ leaf=_photo(row.get("leaf_photo")),
373
+ recorded_species=(tree or {}).get("species"),
374
+ )
375
+ if assessment is None:
376
+ return None
377
+
378
+ species, advice = split(assessment)
379
+ store.write_advice(checkin_id, species, advice)
380
+ log.info(
381
+ "advised %s -> %s (species: %s)",
382
+ checkin_id,
383
+ assessment.health,
384
+ assessment.species_common,
385
+ )
386
+ return {"species_guess": species, "advice": advice}
greenproof_ml/embed.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DINOv2 embeddings via ONNX Runtime.
2
+
3
+ Preprocessing is deliberately IDENTICAL to spike/measure.py. That file produced
4
+ the numbers we publish — rank-1 54.2%, AUC 0.77 — and if production resized or
5
+ normalised differently, those numbers would no longer describe this system. The
6
+ published figure has to be a measurement of the thing we shipped.
7
+
8
+ The one intentional difference: the spike cropped a camera-app timestamp
9
+ watermark off the bottom of every frame. App photos have no watermark, so the
10
+ crop is zero here and configurable rather than deleted, so the spike can be
11
+ re-run against production-shaped inputs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import threading
18
+
19
+ import numpy as np
20
+ from PIL import Image
21
+
22
+ MODEL_REPO = "onnx-community/dinov2-small"
23
+
24
+ # DINOv2 uses 14px patches; 224 = 16 x 14.
25
+ IMG_SIZE = 224
26
+ EMBED_DIM = 384
27
+
28
+ # ImageNet statistics — what DINOv2 was trained with.
29
+ _MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
30
+ _STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
31
+
32
+ _lock = threading.Lock()
33
+
34
+
35
+ def _model_path() -> str:
36
+ """Download the full-precision ONNX weights, skipping quantised variants.
37
+
38
+ Quantised weights would halve memory but shift every cosine similarity
39
+ slightly, which moves the thresholds we cross-validated. Not worth it at
40
+ ~90 MB on a 16 GB box.
41
+ """
42
+ from huggingface_hub import hf_hub_download, list_repo_files
43
+
44
+ onnx = [f for f in list_repo_files(MODEL_REPO) if f.endswith(".onnx")]
45
+ full = [
46
+ f
47
+ for f in onnx
48
+ if not any(q in f.lower() for q in ("quant", "int8", "uint8", "q4", "fp16", "bnb"))
49
+ ]
50
+ return hf_hub_download(MODEL_REPO, sorted(full or onnx, key=len)[0])
51
+
52
+
53
+ @functools.lru_cache(maxsize=1)
54
+ def _session():
55
+ import onnxruntime as ort
56
+
57
+ opts = ort.SessionOptions()
58
+ # Free Spaces gives 2 vCPUs. Letting ORT spawn more threads than that makes
59
+ # it slower, not faster.
60
+ opts.intra_op_num_threads = 2
61
+ opts.inter_op_num_threads = 1
62
+ return ort.InferenceSession(
63
+ _model_path(), sess_options=opts, providers=["CPUExecutionProvider"]
64
+ )
65
+
66
+
67
+ def warm() -> None:
68
+ """Force model download + graph load at startup rather than on first request."""
69
+ sess = _session()
70
+ name = sess.get_inputs()[0].name
71
+ sess.run(None, {name: np.zeros((1, 3, IMG_SIZE, IMG_SIZE), dtype=np.float32)})
72
+
73
+
74
+ def preprocess(img: Image.Image, watermark_crop: float = 0.0) -> np.ndarray:
75
+ img = img.convert("RGB")
76
+ if watermark_crop:
77
+ w, h = img.size
78
+ img = img.crop((0, 0, w, int(h * (1 - watermark_crop))))
79
+
80
+ # Resize the WHOLE frame rather than centre-cropping. For the wide shot the
81
+ # surroundings are a large part of the signal — the spike showed matching
82
+ # works partly off the background — and a centre crop throws exactly that
83
+ # away.
84
+ img = img.resize((IMG_SIZE, IMG_SIZE), Image.BICUBIC)
85
+ a = np.asarray(img, dtype=np.float32) / 255.0
86
+ return ((a - _MEAN) / _STD).transpose(2, 0, 1)
87
+
88
+
89
+ def embed(img: Image.Image) -> np.ndarray:
90
+ """L2-normalised 384-d CLS embedding. Dot product of two of these is cosine."""
91
+ x = preprocess(img)[None, ...]
92
+ with _lock: # ORT sessions are not guaranteed thread-safe for concurrent run()
93
+ out = _session().run(None, {_session().get_inputs()[0].name: x})[0]
94
+ v = out[0, 0].astype(np.float32) # CLS token summarises the image
95
+ return v / (np.linalg.norm(v) + 1e-9)
96
+
97
+
98
+ def cosine(a: np.ndarray, b: np.ndarray) -> float:
99
+ return float(np.dot(a, b))
greenproof_ml/pipeline.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scoring one check-in, end to end.
2
+
3
+ fetch row -> download both photos -> gates -> scores -> write back
4
+
5
+ Idempotent: re-running on the same check-in recomputes from the stored photos
6
+ and overwrites the result. That matters more than it sounds, because it means
7
+ every threshold change can be replayed over the entire pilot dataset without
8
+ going back into the field. The photos are the raw evidence; the verdict is
9
+ derived and disposable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import random
16
+
17
+ from . import store
18
+ from .embed import cosine, embed
19
+ from .scoring import (
20
+ Assessment,
21
+ assess,
22
+ PROXIMITY_REVIEW_M,
23
+ check_plant_plausibility,
24
+ check_proximity,
25
+ gate_duplicate,
26
+ gate_gps,
27
+ gate_travel,
28
+ score_growth,
29
+ score_liveness,
30
+ score_location,
31
+ score_scene,
32
+ )
33
+ from .signals import (
34
+ canopy_fraction,
35
+ excess_green,
36
+ haversine_m,
37
+ liveness_score,
38
+ orb_inliers,
39
+ phash,
40
+ plant_reference,
41
+ trunk_width_mm,
42
+ )
43
+
44
+ log = logging.getLogger(__name__)
45
+
46
+ # Fraction of auto-approved check-ins pulled for human spot-checking.
47
+ # Without this we would have no way to estimate the false-ACCEPT rate, which is
48
+ # the number that actually matters — a wrongly rejected genuine visit is
49
+ # annoying, a wrongly approved fraud is fatal, and nothing else in the system
50
+ # measures the second one.
51
+ AUDIT_RATE = 0.10
52
+
53
+
54
+ def score_checkin(checkin_id: str) -> Assessment:
55
+ row = store.get_checkin(checkin_id)
56
+ if row is None:
57
+ raise LookupError(f"No check-in {checkin_id}")
58
+
59
+ tree = store.get_tree(row["tree_id"])
60
+ if tree is None:
61
+ raise LookupError(f"Check-in {checkin_id} references a missing tree")
62
+
63
+ wide = store.download_image(row["wide_photo"])
64
+ close = store.download_image(row["close_photo"])
65
+
66
+ # --- fingerprints ------------------------------------------------------
67
+ wide_ph = phash(wide)
68
+ wide_emb = embed(wide)
69
+ close_emb = embed(close)
70
+
71
+ captured_at = store.parse_ts(row["captured_at"])
72
+ prev_visits = store.previous_checkins(row["tree_id"], row["captured_at"])
73
+
74
+ # --- gates -------------------------------------------------------------
75
+ gates = {}
76
+
77
+ gates["duplicate_image"] = gate_duplicate(
78
+ wide_ph, store.other_phashes(checkin_id)
79
+ )
80
+
81
+ gates["gps_radius"] = gate_gps(
82
+ tree["lat"],
83
+ tree["lng"],
84
+ row["lat"],
85
+ row["lng"],
86
+ row.get("gps_accuracy_m"),
87
+ )
88
+
89
+ prev_any = store.previous_by_submitter(
90
+ row["submitted_by"], row["captured_at"], checkin_id
91
+ )
92
+ gates["travel_speed"] = gate_travel(
93
+ row["lat"],
94
+ row["lng"],
95
+ captured_at,
96
+ (
97
+ prev_any["id"],
98
+ prev_any["lat"],
99
+ prev_any["lng"],
100
+ store.parse_ts(prev_any["captured_at"]),
101
+ )
102
+ if prev_any
103
+ else None,
104
+ )
105
+
106
+ # --- scores ------------------------------------------------------------
107
+ scores: dict[str, dict] = {}
108
+
109
+ distance = haversine_m(tree["lat"], tree["lng"], row["lat"], row["lng"])
110
+ scores["location"] = score_location(distance)
111
+
112
+ green = excess_green(wide)
113
+ scores["liveness"] = score_liveness(green, liveness_score(green))
114
+
115
+ # Scene match needs something to match AGAINST. On the very first visit
116
+ # there is nothing, so the signal is absent rather than zero — assess()
117
+ # renormalises and lowers confidence via the coverage term instead of
118
+ # treating a registration as suspicious.
119
+ if prev_visits:
120
+ best_wide = best_close = None
121
+ for p in prev_visits:
122
+ pw = store.parse_embedding(p.get("wide_embedding"))
123
+ pc = store.parse_embedding(p.get("close_embedding"))
124
+ if pw is not None:
125
+ c = cosine(wide_emb, pw)
126
+ best_wide = c if best_wide is None else max(best_wide, c)
127
+ if pc is not None:
128
+ c = cosine(close_emb, pc)
129
+ best_close = c if best_close is None else max(best_close, c)
130
+
131
+ inliers = None
132
+ try:
133
+ prev_wide = store.download_image(prev_visits[0]["wide_photo"])
134
+ inliers = orb_inliers(wide, prev_wide)
135
+ except Exception: # noqa: BLE001 - corroboration only, never fatal
136
+ log.warning("ORB comparison failed for %s", checkin_id, exc_info=True)
137
+
138
+ if best_wide is not None or best_close is not None:
139
+ scores["scene_match"] = score_scene(best_wide, best_close, inliers)
140
+
141
+ canopy_now = canopy_fraction(wide)
142
+ canopy_prev = trunk_prev = None
143
+ if prev_visits:
144
+ prev_growth = ((prev_visits[0].get("signals") or {}).get("scores") or {}).get(
145
+ "growth", {}
146
+ )
147
+ canopy_prev = prev_growth.get("canopy_frac")
148
+ trunk_prev = prev_growth.get("trunk_mm")
149
+
150
+ # Millimetres are derived HERE, from the stored photo and the stored taps —
151
+ # never from anything the client calculated. See signals.trunk_width_mm.
152
+ measurement = trunk_width_mm(close, row.get("trunk_taps"))
153
+ trunk_now = measurement.get("trunk_mm") if measurement and measurement.get("available") else None
154
+
155
+ scores["growth"] = score_growth(canopy_now, canopy_prev, trunk_now, trunk_prev)
156
+ if measurement:
157
+ scores["growth"]["measurement"] = measurement
158
+
159
+ # --- review flags ------------------------------------------------------
160
+ # Not gates: these must not auto-approve, but they are not accusations. See
161
+ # scoring.check_proximity for why proximity cannot be a gate.
162
+ review_flags = {}
163
+
164
+ # Runs on EVERY visit, not just the first. Registering a chair is the
165
+ # obvious case, but swapping one in at visit three is the case a
166
+ # first-visit-only check would wave through.
167
+ review_flags["plant_plausibility"] = check_plant_plausibility(
168
+ wide_emb, close_emb, plant_reference()
169
+ )
170
+
171
+ # Only on the tree's FIRST check-in, i.e. its registration. On every later
172
+ # visit the tree already exists and standing near a neighbour is not news —
173
+ # re-flagging it every round would bury the reviewer in the same alert.
174
+ if not prev_visits:
175
+ review_flags["tree_proximity"] = check_proximity(
176
+ tree["lat"],
177
+ tree["lng"],
178
+ store.trees_near_registered_before(
179
+ tree["id"],
180
+ tree["lat"],
181
+ tree["lng"],
182
+ tree["created_at"],
183
+ PROXIMITY_REVIEW_M,
184
+ ),
185
+ )
186
+
187
+ # --- combine and persist ----------------------------------------------
188
+ result = assess(gates, scores, review_flags=review_flags)
189
+
190
+ # Carry provenance forward. write_result replaces `signals` WHOLESALE, so a
191
+ # self-test tag written at insert time is destroyed by the very act of
192
+ # scoring. That is not cosmetic: it made six injected attack rows
193
+ # indistinguishable from real pilot data, and the only thing that still
194
+ # identified them was the storage path. Provenance is a fact about where a
195
+ # row came from, not part of the score, and must survive being scored.
196
+ provenance = (row.get("signals") or {}).get("self_test")
197
+ if provenance:
198
+ result.signals["self_test"] = provenance
199
+
200
+ audit = result.verdict == "verified" and random.random() < AUDIT_RATE
201
+
202
+ store.write_result(
203
+ checkin_id,
204
+ phash=wide_ph,
205
+ wide_embedding=wide_emb,
206
+ close_embedding=close_emb,
207
+ confidence=result.confidence,
208
+ verdict=result.verdict,
209
+ signals=result.signals,
210
+ audit_sample=audit,
211
+ )
212
+ store.update_tree_status(row["tree_id"], result.confidence, result.verdict)
213
+
214
+ log.info(
215
+ "scored %s -> %s (%d) gates=%s",
216
+ checkin_id,
217
+ result.verdict,
218
+ result.confidence,
219
+ {k: v.passed for k, v in gates.items()},
220
+ )
221
+ return result
greenproof_ml/plant_reference.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3de6378a6d4c5dc8f1543a0f51e602bb1184ab69b71bc0fd69f742e542c026dc
3
+ size 113792
greenproof_ml/scoring.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Combining the detectors into a confidence and a verdict.
2
+
3
+ TWO-STAGE BY DESIGN.
4
+
5
+ Stage 1 — GATES. Disqualifying on their own, regardless of anything else.
6
+ A duplicate photo is a duplicate photo; no amount of scene
7
+ similarity should rescue it. Gates run BEFORE scoring so a
8
+ failure is cheap and unambiguous.
9
+
10
+ Stage 2 — SCORES. Continuous 0..1 signals, combined by weights, producing a
11
+ confidence out of 100.
12
+
13
+ This ordering is the reason the system's overall accuracy is far better than the
14
+ 54% rank-1 of image matching alone: five of the six fraud types in the attack set
15
+ never reach stage 2.
16
+
17
+ THRESHOLDS AND WEIGHTS BELOW ARE PROVISIONAL. They are informed guesses that let
18
+ the pipeline run end-to-end from day one. They are replaced by weights fitted
19
+ with logistic regression under leave-one-tree-out cross-validation once real
20
+ check-in rounds exist — see calibrate.py. Reporting a number produced by
21
+ hand-tuned weights on the same data would be exactly the mistake CLAUDE.md
22
+ forbids.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime
29
+
30
+ from .signals import DUPLICATE_MAX_HAMMING, haversine_m, hamming
31
+
32
+ # --- gate parameters -------------------------------------------------------
33
+
34
+ # How far from the registered point a check-in may be.
35
+ # Built from the GPS accuracy of BOTH readings plus a slack term, rather than a
36
+ # flat radius: a ±3 m fix and a ±18 m fix genuinely deserve different tolerances,
37
+ # and a flat number would either reject good check-ins under canopy or wave
38
+ # through bad ones in the open.
39
+ GPS_SLACK_M = 15.0
40
+ GPS_MAX_RADIUS_M = 60.0
41
+
42
+ # Nobody walks a plantation at 120 km/h. Catches one account submitting
43
+ # check-ins from two places faster than a human could travel between them.
44
+ MAX_TRAVEL_KMH = 120.0
45
+
46
+ # Two TREES registered closer together than this are possibly one tree entered
47
+ # twice — the double-payment case.
48
+ #
49
+ # NOT A GATE, DELIBERATELY. A failed gate means confidence 0 and 'flagged', and
50
+ # trees genuinely do grow 3 m apart: flagging a dense grove as fraud would be
51
+ # both wrong and ruinous to the pilot numbers. Phone GPS at 5-10 m simply cannot
52
+ # tell "one tree measured twice" from "two neighbouring trees", so the honest
53
+ # response is a human look, not an accusation. It routes to REVIEW instead.
54
+ #
55
+ # PROVISIONAL. Calibrate against the pilot's nearest-neighbour distance
56
+ # distribution and set it below the 5th percentile — see tools/proximity_audit.py.
57
+ # Must stay in step with PROXIMITY_WARN_M in app/src/lib/trees.ts, which is the
58
+ # client-side warning for the same condition.
59
+ PROXIMITY_REVIEW_M = 8.0
60
+
61
+ # Cosine similarity to the nearest known plant, below which a photo does not
62
+ # look like a plant at all.
63
+ #
64
+ # THE FIRST TWO NUMBERS HERE WERE WRONG AND THE STORY IS THE POINT.
65
+ #
66
+ # Held-out spike photos against 300 COCO photographs scored AUC 1.000 with a
67
+ # threshold of 0.519 and an 8.1% false-reject rate. Applied to the 9 REAL pilot
68
+ # check-ins - different plants, different site - that same threshold flagged
69
+ # FIVE OF NINE. The holdout was other angles of the SAME 15 plants at the SAME
70
+ # shoot, so it had been measuring "was this taken at that shoot", not "is this
71
+ # a plant". An AUC of 1.000 is a warning, not a result.
72
+ #
73
+ # Re-measured against genuinely unseen trees:
74
+ #
75
+ # AUC (real pilot photos vs 300 COCO) 0.966
76
+ #
77
+ # threshold check-ins flagged non-plants accepted
78
+ # 0.250 1/9 (11%) 1.3%
79
+ # 0.300 1/9 (11%) 1.0% <- chosen
80
+ # 0.350 2/9 (22%) 0.3%
81
+ # 0.519 5/9 (56%) 0.0%
82
+ #
83
+ # 0.30 buys 99% of non-plants for one check-in in nine going to a human. Pushing
84
+ # higher trades real planters' time for a rounding error of extra safety, and a
85
+ # non-plant that slips still faces GPS, duplicate and scene-match.
86
+ #
87
+ # STILL PROVISIONAL: measured on 18 photos from 6 trees at one site. The honest
88
+ # fix is not a better threshold, it is a bigger reference set - every verified
89
+ # check-in is a known-good plant photo, so this improves as the system runs.
90
+ PLANT_SIMILARITY_MIN = 0.30
91
+
92
+ # --- score shaping ---------------------------------------------------------
93
+
94
+ LOCATION_FULL_M = 10.0 # at or under this, full marks
95
+ LOCATION_ZERO_M = 60.0 # at or over this, nothing
96
+
97
+ SCENE_COSINE_FLOOR = 0.35 # below this, no evidence of the same scene
98
+ SCENE_COSINE_CEIL = 0.75 # at or above, as good as same-scene gets
99
+ ORB_INLIER_STRONG = 25 # inliers that would count as strong agreement
100
+
101
+ # ORB/RANSAC IS MEASURED AND DISABLED. Do not switch this on without re-running
102
+ # the measurement.
103
+ #
104
+ # Tested on the 59 spike photos, 287 pairs (87 same-tree, 200 different-tree):
105
+ #
106
+ # same tree 63.2% zero inliers, mean 1.7, max 8
107
+ # different tree 75.0% zero inliers, mean 1.1, max 6
108
+ #
109
+ # The distributions overlap almost entirely. ORB finds no consistent geometry
110
+ # between two genuine photos of the same tree taken minutes apart from slightly
111
+ # different positions — foliage moves, and bark at two scales shares almost no
112
+ # repeatable keypoints. Leaving it in the score would have added up to +0.08 of
113
+ # essentially random confidence, which helps an impostor as readily as a genuine
114
+ # visit.
115
+ #
116
+ # It stays COMPUTED and recorded in `signals` so the decision can be revisited
117
+ # with real data, because the spike had no framing control. The ghost overlay
118
+ # and compass heading exist precisely to reproduce framing, and ORB may become
119
+ # viable once check-in round 1 produces aligned pairs. Re-measure then; turn it
120
+ # on only if the same/different distributions actually separate.
121
+ ORB_CONTRIBUTES_TO_SCORE = False
122
+
123
+ # --- combination -----------------------------------------------------------
124
+
125
+ # Deliberately NOT equal. Image matching is the weakest signal we measured
126
+ # (rank-1 54%, AUC 0.77, and bimodal), so it must not dominate. Location and
127
+ # liveness are cheap, deterministic and reliable — they carry more.
128
+ DEFAULT_WEIGHTS: dict[str, float] = {
129
+ "location": 0.35,
130
+ "liveness": 0.30,
131
+ "scene_match": 0.25,
132
+ "growth": 0.10,
133
+ }
134
+
135
+ VERIFY_AT = 70
136
+ REVIEW_AT = 45
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # CRITICAL FLOORS — the fix for a false accept found in testing
140
+ # ---------------------------------------------------------------------------
141
+ #
142
+ # A plain weighted sum lets strong signals COMPENSATE for a failed one. Tested
143
+ # against "a different tree photographed at the correct GPS" — attack #4, the
144
+ # one case that genuinely needs the AI — the result was:
145
+ #
146
+ # scene_match 0.00, location 1.00, liveness 1.00, growth 1.00
147
+ # -> 75/100 -> VERIFIED
148
+ #
149
+ # The system auto-approved the exact fraud it exists to catch, because
150
+ # scene_match carries only 0.25 of the weight and the other three were perfect.
151
+ # Compensation is correct for a quality score and wrong for a fraud decision.
152
+ #
153
+ # So a signal that is present and near-zero VETOES auto-approval. It does not
154
+ # flag the visit — it routes it to a human, which is the honest response to
155
+ # "this looks wrong but we are not certain". Confidence is still reported
156
+ # unchanged so the disagreement stays visible in the data.
157
+ #
158
+ # Floors are set BELOW the genuine weak case and ABOVE the fraud case, measured:
159
+ # dense same-species stand, genuine -> scene_match ~0.19 (must still pass)
160
+ # different tree entirely -> scene_match 0.00 (must be capped)
161
+ CRITICAL_FLOORS: dict[str, float] = {
162
+ # RAISED FROM 0.20 AFTER MEASUREMENT. A photograph of a brown wooden chair
163
+ # scored 0.218 and cleared the old floor by 0.018.
164
+ #
165
+ # On the 9 real pilot WIDE shots the distribution is bimodal - either 0.000
166
+ # or >= 0.557, with nothing in between - so 0.35 sits in an empty gap and
167
+ # rejects no real photo we have, while putting clear daylight above the
168
+ # chair.
169
+ #
170
+ # THIS DOES NOT SOLVE THE CHAIR PROBLEM and must not be described as if it
171
+ # does. It only catches BROWN fakes. A green wall still scores 1.00, which
172
+ # is what check_plant_plausibility exists for. Sample is 9 wide shots, so
173
+ # the "rejects nothing real" claim is thin - revisit with more rounds.
174
+ "liveness": 0.35,
175
+ "scene_match": 0.12,
176
+ }
177
+
178
+ # Auto-approval also requires enough of the signal set to have been available.
179
+ # One signal out of four is not a verification, however good that signal is.
180
+ MIN_COVERAGE_TO_VERIFY = 0.50
181
+
182
+ MODEL_NAME = "dinov2-small-onnx"
183
+ MODEL_VERSION = "2026.08.1"
184
+
185
+
186
+ @dataclass
187
+ class GateResult:
188
+ passed: bool
189
+ detail: dict
190
+
191
+
192
+ @dataclass
193
+ class Assessment:
194
+ confidence: int
195
+ verdict: str
196
+ signals: dict = field(default_factory=dict)
197
+
198
+
199
+ def _ramp(value: float, full: float, zero: float) -> float:
200
+ """1.0 at `full`, 0.0 at `zero`, linear between. Handles either direction."""
201
+ if full == zero:
202
+ return 1.0 if value <= full else 0.0
203
+ t = (value - zero) / (full - zero)
204
+ return float(min(1.0, max(0.0, t)))
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Gates
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ def gate_duplicate(this_phash: str, others: list[tuple[str, str]]) -> GateResult:
213
+ """others: (checkin_id, phash) for every OTHER check-in in the system."""
214
+ nearest_id, nearest = None, 64
215
+ for cid, ph in others:
216
+ if not ph:
217
+ continue
218
+ d = hamming(this_phash, ph)
219
+ if d < nearest:
220
+ nearest, nearest_id = d, cid
221
+
222
+ passed = nearest > DUPLICATE_MAX_HAMMING
223
+ return GateResult(
224
+ passed,
225
+ {
226
+ "passed": passed,
227
+ "nearest_phash_distance": nearest,
228
+ **({"matched_checkin": nearest_id} if not passed and nearest_id else {}),
229
+ },
230
+ )
231
+
232
+
233
+ def check_plant_plausibility(
234
+ wide_emb,
235
+ close_emb,
236
+ reference,
237
+ ) -> GateResult:
238
+ """Does either photo fail to look like a plant?
239
+
240
+ Stops a chair, a wall or a green bedsheet being registered as a tree -
241
+ which the liveness signal does not, because a green bedsheet is
242
+ vegetation-coloured and scores a perfect 1.00.
243
+
244
+ BOTH photos are checked and the WORSE one decides, so swapping a real
245
+ tree's wide shot in front of a close-up of something else does not pass.
246
+
247
+ Reference vectors are unit-normalised, so a dot product IS the cosine.
248
+ A missing reference set passes: degrade, don't break.
249
+ """
250
+ if reference is None or wide_emb is None or close_emb is None:
251
+ return GateResult(True, {"passed": True, "available": False})
252
+
253
+ sw = float(reference.dot(wide_emb).max())
254
+ sc = float(reference.dot(close_emb).max())
255
+ worst = min(sw, sc)
256
+ passed = worst >= PLANT_SIMILARITY_MIN
257
+ return GateResult(
258
+ passed,
259
+ {
260
+ "passed": passed,
261
+ "available": True,
262
+ "wide_similarity": round(sw, 3),
263
+ "close_similarity": round(sc, 3),
264
+ "threshold": PLANT_SIMILARITY_MIN,
265
+ },
266
+ )
267
+
268
+
269
+ def check_proximity(
270
+ lat: float,
271
+ lng: float,
272
+ earlier: list[tuple[str, float, float]],
273
+ ) -> GateResult:
274
+ """Is there already a tree registered at this spot?
275
+
276
+ `earlier` is (tree_id, lat, lng) for trees registered STRICTLY BEFORE this
277
+ one. The ordering matters: when one trunk becomes two rows, only the second
278
+ row is the problem. Comparing against all trees would retroactively taint
279
+ the original — and the original is the one with the honest photo history.
280
+
281
+ Returns a GateResult for shape consistency with the real gates, but it is
282
+ passed to assess() as a REVIEW FLAG, not a gate. `passed=False` here means
283
+ "a person should look", never "this is fraud".
284
+ """
285
+ nearest_id, nearest = None, float("inf")
286
+ for tid, tlat, tlng in earlier:
287
+ d = haversine_m(lat, lng, tlat, tlng)
288
+ if d < nearest:
289
+ nearest, nearest_id = d, tid
290
+
291
+ passed = nearest > PROXIMITY_REVIEW_M
292
+ detail: dict = {"passed": passed, "radius_m": PROXIMITY_REVIEW_M}
293
+ if nearest_id is not None:
294
+ detail["nearest_tree"] = nearest_id
295
+ detail["nearest_distance_m"] = round(nearest, 1)
296
+ return GateResult(passed, detail)
297
+
298
+
299
+ def gate_gps(
300
+ tree_lat: float,
301
+ tree_lng: float,
302
+ lat: float,
303
+ lng: float,
304
+ accuracy_m: float | None,
305
+ registration_accuracy_m: float | None = None,
306
+ ) -> GateResult:
307
+ dist = haversine_m(tree_lat, tree_lng, lat, lng)
308
+ allowed = min(
309
+ GPS_MAX_RADIUS_M,
310
+ GPS_SLACK_M + (accuracy_m or 0.0) + (registration_accuracy_m or 0.0),
311
+ )
312
+ passed = dist <= allowed
313
+ return GateResult(
314
+ passed,
315
+ {"passed": passed, "distance_m": round(dist, 1), "allowed_m": round(allowed, 1)},
316
+ )
317
+
318
+
319
+ def gate_travel(
320
+ lat: float,
321
+ lng: float,
322
+ captured_at: datetime,
323
+ previous: tuple[str, float, float, datetime] | None,
324
+ ) -> GateResult:
325
+ """previous: the same submitter's most recent check-in elsewhere."""
326
+ if previous is None:
327
+ return GateResult(True, {"passed": True})
328
+
329
+ pid, plat, plng, pat = previous
330
+ seconds = abs((captured_at - pat).total_seconds())
331
+ if seconds < 1:
332
+ # Two check-ins at the same instant from different places is itself
333
+ # impossible; treat as a failure rather than dividing by ~zero.
334
+ metres = haversine_m(plat, plng, lat, lng)
335
+ passed = metres < 50
336
+ return GateResult(passed, {"passed": passed, "kmh": None, "from_checkin": pid})
337
+
338
+ kmh = (haversine_m(plat, plng, lat, lng) / seconds) * 3.6
339
+ passed = kmh <= MAX_TRAVEL_KMH
340
+ return GateResult(
341
+ passed, {"passed": passed, "kmh": round(kmh, 1), "from_checkin": pid}
342
+ )
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Scores
347
+ # ---------------------------------------------------------------------------
348
+
349
+
350
+ def score_location(distance_m: float) -> dict:
351
+ return {
352
+ "score": _ramp(distance_m, LOCATION_FULL_M, LOCATION_ZERO_M),
353
+ "distance_m": round(distance_m, 1),
354
+ }
355
+
356
+
357
+ def score_liveness(green_fraction: float, mapped: float) -> dict:
358
+ return {"score": mapped, "excess_green": round(green_fraction, 4)}
359
+
360
+
361
+ def score_scene(
362
+ cosine_wide: float | None,
363
+ cosine_close: float | None,
364
+ inliers: int | None,
365
+ ) -> dict:
366
+ """Corroboration, never identity.
367
+
368
+ The wide shot is weighted above the close-up because the spike showed
369
+ matching works substantially off the surrounding scene, and young bark is
370
+ smooth and far less distinctive than mature bark — BarkNet's ~94% figures
371
+ are on MATURE bark and do not transfer to a two-year-old sapling.
372
+
373
+ ORB inliers are RECORDED BUT DO NOT AFFECT THE SCORE — see
374
+ ORB_CONTRIBUTES_TO_SCORE above for the measurement that produced that
375
+ decision. Reporting a signal we have shown to be non-discriminating would be
376
+ exactly the unfounded assertion this project exists to avoid.
377
+ """
378
+ parts: list[tuple[float, float]] = []
379
+ if cosine_wide is not None:
380
+ parts.append((_ramp(-cosine_wide, -SCENE_COSINE_CEIL, -SCENE_COSINE_FLOOR), 0.6))
381
+ if cosine_close is not None:
382
+ parts.append((_ramp(-cosine_close, -SCENE_COSINE_CEIL, -SCENE_COSINE_FLOOR), 0.4))
383
+
384
+ base = (
385
+ sum(v * w for v, w in parts) / sum(w for _, w in parts) if parts else 0.0
386
+ )
387
+
388
+ if inliers and ORB_CONTRIBUTES_TO_SCORE:
389
+ base = min(1.0, base + 0.25 * min(1.0, inliers / ORB_INLIER_STRONG))
390
+
391
+ out: dict = {"score": round(base, 4)}
392
+ if cosine_wide is not None:
393
+ out["cosine_wide"] = round(cosine_wide, 4)
394
+ if cosine_close is not None:
395
+ out["cosine_close"] = round(cosine_close, 4)
396
+ if inliers is not None:
397
+ out["orb_inliers"] = int(inliers)
398
+ return out
399
+
400
+
401
+ def score_growth(
402
+ canopy_now: float | None,
403
+ canopy_prev: float | None,
404
+ trunk_mm: float | None = None,
405
+ trunk_prev_mm: float | None = None,
406
+ ) -> dict:
407
+ """Plausibility, not measurement.
408
+
409
+ Trunk diameter barely moves in four weeks on a young tree, so this signal
410
+ cannot demonstrate growth and we do not claim it does. Its job is catching
411
+ the IMPOSSIBLE: a trunk that shrank, a seedling that became a mature tree
412
+ overnight, a canopy that vanished.
413
+
414
+ With no prior visit there is nothing to compare, so it returns a neutral 0.5
415
+ rather than 0 — a first check-in must not be penalised for being first.
416
+ """
417
+ out: dict = {}
418
+ if canopy_now is not None:
419
+ out["canopy_frac"] = round(canopy_now, 4)
420
+ if trunk_mm is not None:
421
+ out["trunk_mm"] = round(trunk_mm, 1)
422
+
423
+ if canopy_prev is None and trunk_prev_mm is None:
424
+ out["score"] = 0.5
425
+ return out
426
+
427
+ score = 1.0
428
+
429
+ if canopy_now is not None and canopy_prev is not None:
430
+ delta = canopy_now - canopy_prev
431
+ # Losing more than half the canopy in one interval is the dying-tree
432
+ # signal. Real, and exactly what we want surfaced for review.
433
+ if canopy_prev > 0.02 and delta / canopy_prev < -0.5:
434
+ score = min(score, 0.15)
435
+ # Tripling canopy in days means the framing changed or it is a
436
+ # different plant.
437
+ elif canopy_prev > 0.02 and delta / canopy_prev > 2.0:
438
+ score = min(score, 0.3)
439
+
440
+ if trunk_mm is not None and trunk_prev_mm is not None:
441
+ out["delta_mm"] = round(trunk_mm - trunk_prev_mm, 1)
442
+ # A trunk cannot shrink. Allow 3mm for measurement error.
443
+ if trunk_mm < trunk_prev_mm - 3:
444
+ score = min(score, 0.1)
445
+ # No young tree gains 30mm of diameter in a check-in interval.
446
+ elif trunk_mm > trunk_prev_mm + 30:
447
+ score = min(score, 0.2)
448
+
449
+ out["score"] = score
450
+ return out
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Combination
455
+ # ---------------------------------------------------------------------------
456
+
457
+
458
+ def assess(
459
+ gates: dict[str, GateResult],
460
+ scores: dict[str, dict],
461
+ weights: dict[str, float] | None = None,
462
+ review_flags: dict[str, GateResult] | None = None,
463
+ ) -> Assessment:
464
+ """`review_flags` are conditions that must not AUTO-APPROVE but are not
465
+ disqualifying — they demote 'verified' to 'review' and are recorded with a
466
+ reason. Separate from gates on purpose: a gate says "this is fraud", a review
467
+ flag says "a person should look at this", and collapsing the two would either
468
+ accuse honest planters or wave through the thing we wanted a human to see."""
469
+ w = dict(weights or DEFAULT_WEIGHTS)
470
+ review_flags = review_flags or {}
471
+
472
+ signals: dict = {
473
+ "gates": {k: v.detail for k, v in gates.items()},
474
+ "scores": scores,
475
+ "weights": w,
476
+ "model": {"name": MODEL_NAME, "version": MODEL_VERSION},
477
+ }
478
+ if review_flags:
479
+ signals["review_flags"] = {k: v.detail for k, v in review_flags.items()}
480
+
481
+ failed = [k for k, v in gates.items() if not v.passed]
482
+ if failed:
483
+ # Fail closed. A gate failure is not a low score, it is a
484
+ # disqualification, and reporting a partial confidence next to it would
485
+ # invite someone to override it.
486
+ signals["failed_gates"] = failed
487
+ return Assessment(0, "flagged", signals)
488
+
489
+ # Renormalise over the signals we actually have. A missing signal must lower
490
+ # confidence, never silently count as zero — "designed to degrade, not
491
+ # break" means an absent compass or a first visit routes to a human rather
492
+ # than being scored as fraud.
493
+ present = {k: v for k, v in w.items() if k in scores and "score" in scores[k]}
494
+ if not present:
495
+ return Assessment(0, "review", signals)
496
+
497
+ total_w = sum(present.values())
498
+ raw = sum(scores[k]["score"] * wt for k, wt in present.items()) / total_w
499
+
500
+ # Coverage penalty: if only half the weight was available, cap confidence
501
+ # accordingly instead of pretending a partial assessment is a full one.
502
+ coverage = total_w / sum(w.values())
503
+ confidence = int(round(100 * raw * (0.6 + 0.4 * coverage)))
504
+
505
+ signals["coverage"] = round(coverage, 3)
506
+
507
+ if confidence >= VERIFY_AT:
508
+ verdict = "verified"
509
+ elif confidence >= REVIEW_AT:
510
+ verdict = "review"
511
+ else:
512
+ verdict = "flagged"
513
+
514
+ # --- veto: no compensating away a failed critical signal ---------------
515
+ vetoes = [
516
+ name
517
+ for name, floor in CRITICAL_FLOORS.items()
518
+ if name in scores and scores[name].get("score", 1.0) < floor
519
+ ]
520
+ if coverage < MIN_COVERAGE_TO_VERIFY:
521
+ vetoes.append("coverage")
522
+ vetoes.extend(name for name, r in review_flags.items() if not r.passed)
523
+
524
+ if vetoes and verdict == "verified":
525
+ verdict = "review"
526
+ signals["auto_approval_vetoed_by"] = vetoes
527
+
528
+ return Assessment(confidence, verdict, signals)
greenproof_ml/signals.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The individual detectors.
2
+
3
+ Each is deliberately small, pure and independently testable. Five of the six
4
+ fraud types in the attack set are caught here by ordinary deterministic code —
5
+ not by the neural network — and that is the honest reason the system works.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import math
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Perceptual hash — the duplicate detector
20
+ # ---------------------------------------------------------------------------
21
+
22
+ PHASH_SIZE = 32
23
+ PHASH_LOW = 8
24
+
25
+
26
+ def phash(img: Image.Image) -> str:
27
+ """64-bit DCT perceptual hash, as 16 hex characters.
28
+
29
+ Catches: resubmitting an identical photo, an old gallery photo of the same
30
+ tree, and a photo pulled off the internet that someone else also used.
31
+
32
+ DCT-based rather than average-hash because it survives the JPEG
33
+ re-compression the phone applies on resize, while still differing across
34
+ genuinely different photos. A re-saved copy of the same shot lands within a
35
+ couple of bits; two real photos of the same tree a week apart are typically
36
+ 20+ bits apart.
37
+ """
38
+ a = np.asarray(img.convert("L").resize((PHASH_SIZE, PHASH_SIZE), Image.BICUBIC), dtype=np.float32)
39
+ d = cv2.dct(a)[:PHASH_LOW, :PHASH_LOW]
40
+
41
+ # Exclude the DC term from the median: it carries overall brightness, which
42
+ # would otherwise drag the threshold around with the weather.
43
+ flat = d.flatten()[1:]
44
+ bits = (d.flatten() > np.median(flat)).astype(np.uint8)
45
+
46
+ out = 0
47
+ for bit in bits:
48
+ out = (out << 1) | int(bit)
49
+ return f"{out:016x}"
50
+
51
+
52
+ def hamming(a: str, b: str) -> int:
53
+ return bin(int(a, 16) ^ int(b, 16)).count("1")
54
+
55
+
56
+ # Below this many differing bits, treat two photos as the same image.
57
+ # Provisional — recalibrate from the T0/round-1 distribution of genuine
58
+ # same-tree pairs, which must sit comfortably ABOVE it.
59
+ DUPLICATE_MAX_HAMMING = 6
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Liveness — is this a living plant at all?
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def excess_green(img: Image.Image) -> float:
68
+ """Fraction of the frame that reads as living vegetation.
69
+
70
+ ExG = 2G - R - B on channels normalised per-pixel, which is the standard
71
+ agronomy vegetation index for ordinary RGB cameras. Per-pixel normalisation
72
+ is what makes it survive Ghanaian midday sun and deep shade in the same
73
+ frame — an absolute green threshold would not.
74
+
75
+ Catches: photographing a dead brown stick, a plank, or the ground.
76
+ It does NOT prove the plant is the right plant. That is a different signal.
77
+ """
78
+ a = np.asarray(img.convert("RGB").resize((256, 256), Image.BILINEAR), dtype=np.float32)
79
+ total = a.sum(axis=2) + 1e-6
80
+ r, g, b = a[..., 0] / total, a[..., 1] / total, a[..., 2] / total
81
+ exg = 2 * g - r - b
82
+ # 0.05 is the conventional cut for "this pixel is vegetation".
83
+ return float((exg > 0.05).mean())
84
+
85
+
86
+ def liveness_score(frac: float) -> float:
87
+ """Map green fraction to 0..1.
88
+
89
+ A healthy young tree in a wide shot typically fills 0.25-0.6 of the frame
90
+ with vegetation; below ~0.05 there is essentially nothing living in view.
91
+ Ramped rather than stepped so a leaf-dropping tree degrades smoothly instead
92
+ of falling off a cliff — the point is to flag decline, not to fail it.
93
+ """
94
+ return float(np.clip((frac - 0.05) / (0.30 - 0.05), 0.0, 1.0))
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Plant plausibility - "is this even a plant?"
99
+ # ---------------------------------------------------------------------------
100
+ #
101
+ # WHY THIS IS NOT THE LIVENESS SIGNAL. excess_green measures how much of the
102
+ # frame is vegetation-COLOURED. Measured against 250 COCO photographs, a green
103
+ # painted wall and artificial turf both score 1.00, and the median ordinary
104
+ # outdoor photo scores 0.47 because ordinary outdoor photos contain grass. No
105
+ # threshold on green separates a tree from a chair; we tried, and raising the
106
+ # floor rejects real trees faster than it rejects furniture.
107
+ #
108
+ # So this asks a different question of the embedding we already compute: does
109
+ # this photo resemble anything in a reference set of known plants?
110
+
111
+ _REFERENCE_FILE = Path(__file__).resolve().parent / "plant_reference.npy"
112
+
113
+
114
+ @functools.lru_cache(maxsize=1)
115
+ def plant_reference() -> "np.ndarray | None":
116
+ """Unit-normalised embeddings of known plants, or None if unavailable.
117
+
118
+ None is a supported state, not an error: a missing reference file must
119
+ lower confidence and route to a human, never reject a real planter's tree.
120
+
121
+ THE REFERENCE SET IS THE LIMIT OF THIS CHECK. It is 74 photos of 15 plants
122
+ at one Ghanaian campus in one season, so it recognises "a plant
123
+ photographed the way our reference photos were". Swapping in PlantNet-300K
124
+ scored a perfect AUC and then flagged 97% of our own photos, because
125
+ PlantNet is square cropped specimens and ours are wide shots with sky and
126
+ buildings in frame - it had learned the photo STYLE, not the subject. The
127
+ set must therefore grow from our own verified check-ins, which is also why
128
+ the check gets better the longer the system runs.
129
+ """
130
+ try:
131
+ v = np.load(_REFERENCE_FILE)
132
+ return v if v.ndim == 2 and v.shape[0] else None
133
+ except Exception: # noqa: BLE001 - degrade, don't break
134
+ return None
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Scene match — ORB + RANSAC, corroborating the embedding
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def orb_inliers(a: Image.Image, b: Image.Image, max_side: int = 640) -> int:
143
+ """Count geometrically consistent keypoint matches between two photos.
144
+
145
+ Complements the DINOv2 embedding rather than duplicating it. The embedding
146
+ answers "does this look like the same kind of scene"; ORB+RANSAC answers
147
+ "are the same physical points present in a consistent geometric arrangement".
148
+ An attacker can find a visually similar tree far more easily than one that
149
+ matches point-for-point.
150
+
151
+ Returns 0 when there is no consistent geometry at all.
152
+ """
153
+
154
+ def prep(img: Image.Image) -> np.ndarray:
155
+ g = np.asarray(img.convert("L"))
156
+ h, w = g.shape
157
+ s = max_side / max(h, w)
158
+ if s < 1:
159
+ g = cv2.resize(g, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA)
160
+ return g
161
+
162
+ ga, gb = prep(a), prep(b)
163
+ orb = cv2.ORB_create(nfeatures=1500)
164
+ ka, da = orb.detectAndCompute(ga, None)
165
+ kb, db = orb.detectAndCompute(gb, None)
166
+ if da is None or db is None or len(ka) < 8 or len(kb) < 8:
167
+ return 0
168
+
169
+ matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
170
+ raw = matcher.knnMatch(da, db, k=2)
171
+
172
+ # Lowe's ratio test: keep a match only if it is clearly better than the
173
+ # runner-up. Foliage produces enormous numbers of near-identical descriptors,
174
+ # so without this the match list is almost entirely noise.
175
+ good = [m for m, n in (p for p in raw if len(p) == 2) if m.distance < 0.75 * n.distance]
176
+ if len(good) < 8:
177
+ return 0
178
+
179
+ src = np.float32([ka[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
180
+ dst = np.float32([kb[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
181
+ _, mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
182
+ return 0 if mask is None else int(mask.sum())
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # ArUco — turning a photo into millimetres
187
+ # ---------------------------------------------------------------------------
188
+
189
+ DEFAULT_MARKER_MM = 80.0
190
+
191
+
192
+ def find_aruco(img: Image.Image) -> dict | None:
193
+ """Locate the reference marker and return millimetres-per-pixel.
194
+
195
+ The marker solves two problems at once, which is why it beats holding up a
196
+ coin or a ruler:
197
+
198
+ * SCALE — its real edge length is known, so we get mm per pixel
199
+ * TILT — four corners of a known square let us see that the card was
200
+ photographed at an angle. A plain rectangle cannot give this,
201
+ and an uncorrected 30 degree tilt is a ~15% error in width.
202
+
203
+ `tilt_ratio` near 1.0 means the card was square-on. Far from 1.0 means the
204
+ measurement should be distrusted rather than silently accepted.
205
+ """
206
+ a = np.asarray(img.convert("L"))
207
+ d = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
208
+ corners, ids, _ = cv2.aruco.ArucoDetector(d, cv2.aruco.DetectorParameters()).detectMarkers(a)
209
+ if ids is None or len(corners) == 0:
210
+ return None
211
+
212
+ pts = corners[0].reshape(4, 2)
213
+ sides = [float(np.linalg.norm(pts[i] - pts[(i + 1) % 4])) for i in range(4)]
214
+ px = float(np.mean(sides))
215
+ if px < 10: # too small in frame to measure anything from
216
+ return None
217
+
218
+ return {
219
+ "marker_id": int(ids.flatten()[0]),
220
+ "side_px": px,
221
+ "mm_per_px": DEFAULT_MARKER_MM / px,
222
+ "tilt_ratio": float(min(sides) / max(sides)),
223
+ }
224
+
225
+
226
+ def trunk_width_mm(img: Image.Image, taps: dict | None) -> dict | None:
227
+ """Convert the user's two trunk-edge taps into millimetres.
228
+
229
+ THIS IS THE ONLY PLACE MILLIMETRES ARE PRODUCED, and it runs server-side on
230
+ purpose. The client sends where a finger touched; it never sends a width.
231
+ Trunk width feeds the "a trunk cannot shrink" plausibility check, so a
232
+ client-supplied width would let a forged request defeat that check.
233
+
234
+ Requires the ArUco marker to be visible in the same frame — without a known
235
+ reference length a photo has no scale at all, and a thin trunk photographed
236
+ close up is pixel-for-pixel identical to a thick one photographed far away.
237
+
238
+ Returns None when the marker is absent, which is a normal outcome: the visit
239
+ still counts, the growth signal is simply unavailable and confidence drops
240
+ via the coverage term rather than the check failing.
241
+ """
242
+ if not taps:
243
+ return None
244
+
245
+ marker = find_aruco(img)
246
+ if marker is None:
247
+ return {"available": False, "reason": "no marker detected"}
248
+
249
+ try:
250
+ lx, ly = float(taps["left"]["x"]), float(taps["left"]["y"])
251
+ rx, ry = float(taps["right"]["x"]), float(taps["right"]["y"])
252
+ except (KeyError, TypeError, ValueError):
253
+ return {"available": False, "reason": "malformed taps"}
254
+
255
+ w, h = img.size
256
+ # Euclidean, not just horizontal: people rarely tap two points at exactly
257
+ # the same height, and on a leaning trunk they should not.
258
+ dx, dy = (rx - lx) * w, (ry - ly) * h
259
+ px = math.hypot(dx, dy)
260
+
261
+ out = {
262
+ "available": True,
263
+ "trunk_px": round(px, 1),
264
+ "mm_per_px": round(marker["mm_per_px"], 5),
265
+ "marker_id": marker["marker_id"],
266
+ "tilt_ratio": round(marker["tilt_ratio"], 3),
267
+ "trunk_mm": round(px * marker["mm_per_px"], 1),
268
+ }
269
+
270
+ # A badly tilted card means the marker's apparent width is foreshortened,
271
+ # so mm-per-pixel is wrong and so is everything derived from it. Flag rather
272
+ # than silently returning a confident wrong number.
273
+ if marker["tilt_ratio"] < 0.80:
274
+ out["suspect"] = "marker photographed at a steep angle; hold it flat"
275
+
276
+ return out
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Canopy — the signal that actually moves in four weeks
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def canopy_fraction(img: Image.Image) -> float:
285
+ """Share of the wide shot occupied by vegetation.
286
+
287
+ Unlike trunk diameter, this changes measurably in weeks: a stressed tree
288
+ drops leaves, and July-August is the rainy season so a healthy tree is
289
+ putting on leaf. This is the dying-tree detector, and it is why the pilot
290
+ deliberately includes struggling trees.
291
+
292
+ Only comparable BETWEEN VISITS when framing is reproduced — which is what
293
+ the compass heading and the ghost overlay exist to achieve.
294
+ """
295
+ return excess_green(img)
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Travel plausibility
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
304
+ r = 6_371_000.0
305
+ p1, p2 = math.radians(lat1), math.radians(lat2)
306
+ dp = math.radians(lat2 - lat1)
307
+ dl = math.radians(lng2 - lng1)
308
+ a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
309
+ return 2 * r * math.asin(math.sqrt(a))
greenproof_ml/store.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """All database and storage access, in one place.
2
+
3
+ Runs with the SERVICE ROLE key, which bypasses RLS and the column grants. That
4
+ is the whole security model in one sentence: the browser physically cannot write
5
+ `confidence`, `verdict` or `signals` because those columns are not granted to
6
+ `authenticated`, and this service is the only thing holding a key that can.
7
+
8
+ The service key must never reach the frontend. It lives only in the Hugging Face
9
+ Space's secrets.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import math
16
+ import os
17
+ from datetime import datetime, timezone
18
+
19
+ import numpy as np
20
+ from PIL import Image
21
+ from supabase import Client, create_client
22
+
23
+ BUCKET = "tree-photos"
24
+
25
+
26
+ def _client() -> Client:
27
+ url = os.environ.get("SUPABASE_URL")
28
+ key = os.environ.get("SUPABASE_SERVICE_KEY")
29
+ if not url or not key:
30
+ raise RuntimeError(
31
+ "SUPABASE_URL and SUPABASE_SERVICE_KEY must be set. "
32
+ "On Hugging Face Spaces these go in Settings -> Variables and secrets."
33
+ )
34
+ return create_client(url, key)
35
+
36
+
37
+ _db: Client | None = None
38
+
39
+
40
+ def db() -> Client:
41
+ global _db
42
+ if _db is None:
43
+ _db = _client()
44
+ return _db
45
+
46
+
47
+ def parse_ts(value: str) -> datetime:
48
+ """Postgres timestamptz -> aware datetime.
49
+
50
+ Postgres emits '+00:00' or a 6-digit fractional second; Python's fromisoformat
51
+ is fussy about 'Z' on older versions. Normalising here stops a timezone bug
52
+ from silently turning the travel-speed gate into nonsense.
53
+ """
54
+ v = value.replace("Z", "+00:00")
55
+ dt = datetime.fromisoformat(v)
56
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
57
+
58
+
59
+ def vector_literal(v: np.ndarray) -> str:
60
+ """pgvector wants '[0.1,0.2,...]' as text, not a JSON array."""
61
+ return "[" + ",".join(f"{x:.6f}" for x in v.tolist()) + "]"
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Reads
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def get_checkin(checkin_id: str) -> dict | None:
70
+ r = db().table("checkins").select("*").eq("id", checkin_id).limit(1).execute()
71
+ return r.data[0] if r.data else None
72
+
73
+
74
+ def get_tree(tree_id: str) -> dict | None:
75
+ r = db().table("trees").select("*").eq("id", tree_id).limit(1).execute()
76
+ return r.data[0] if r.data else None
77
+
78
+
79
+ def list_pending(limit: int = 50) -> list[dict]:
80
+ r = (
81
+ db()
82
+ .table("checkins")
83
+ .select("id")
84
+ .eq("verdict", "pending")
85
+ .order("server_received_at", desc=False)
86
+ .limit(limit)
87
+ .execute()
88
+ )
89
+ return r.data or []
90
+
91
+
92
+ def other_phashes(exclude_checkin_id: str) -> list[tuple[str, str]]:
93
+ """Every other check-in's pHash, for the duplicate gate.
94
+
95
+ Deliberately global, not scoped to this tree: submitting tree 7's photo as
96
+ tree 12's check-in is one of the attacks, and scoping the search to tree 12
97
+ would miss it entirely.
98
+ """
99
+ r = (
100
+ db()
101
+ .table("checkins")
102
+ .select("id, phash")
103
+ .not_.is_("phash", "null")
104
+ .neq("id", exclude_checkin_id)
105
+ .execute()
106
+ )
107
+ return [(row["id"], row["phash"]) for row in (r.data or []) if row.get("phash")]
108
+
109
+
110
+ def trees_near_registered_before(
111
+ tree_id: str,
112
+ lat: float,
113
+ lng: float,
114
+ before_iso: str,
115
+ radius_m: float,
116
+ ) -> list[tuple[str, float, float]]:
117
+ """Trees within `radius_m` that were registered BEFORE this one.
118
+
119
+ Across all owners: one physical tree registered by two different people is
120
+ the double-payment case, and scoping this to the owner would miss exactly
121
+ that.
122
+
123
+ A bounding box does the elimination in Postgres and the true distance is
124
+ measured here. PostGIS is available and `trees.location` is indexed, but
125
+ reaching it needs a SQL function; the box uses the plain lat/lng columns and
126
+ is correct because the caller re-measures. At pilot scale the difference is
127
+ unmeasurable — the box is there so this does not become a full table scan
128
+ when the grove is 40,000 trees rather than 40.
129
+ """
130
+ d_lat = radius_m / 111_320.0
131
+ d_lng = radius_m / max(111_320.0 * math.cos(math.radians(lat)), 1e-6)
132
+
133
+ r = (
134
+ db()
135
+ .table("trees")
136
+ .select("id, lat, lng")
137
+ .gte("lat", lat - d_lat)
138
+ .lte("lat", lat + d_lat)
139
+ .gte("lng", lng - d_lng)
140
+ .lte("lng", lng + d_lng)
141
+ .lt("created_at", before_iso)
142
+ .neq("id", tree_id)
143
+ .execute()
144
+ )
145
+ return [(row["id"], row["lat"], row["lng"]) for row in (r.data or [])]
146
+
147
+
148
+ def previous_checkins(tree_id: str, before_iso: str) -> list[dict]:
149
+ """Earlier visits to this tree, newest first, with embeddings."""
150
+ r = (
151
+ db()
152
+ .table("checkins")
153
+ .select(
154
+ "id, wide_photo, close_photo, captured_at, wide_embedding, "
155
+ "close_embedding, signals, verdict"
156
+ )
157
+ .eq("tree_id", tree_id)
158
+ .lt("captured_at", before_iso)
159
+ .order("captured_at", desc=True)
160
+ .execute()
161
+ )
162
+ return r.data or []
163
+
164
+
165
+ def previous_by_submitter(
166
+ submitter_id: str, before_iso: str, exclude_checkin_id: str
167
+ ) -> dict | None:
168
+ """The submitter's most recent check-in anywhere — for the travel gate."""
169
+ r = (
170
+ db()
171
+ .table("checkins")
172
+ .select("id, lat, lng, captured_at")
173
+ .eq("submitted_by", submitter_id)
174
+ .lt("captured_at", before_iso)
175
+ .neq("id", exclude_checkin_id)
176
+ .order("captured_at", desc=True)
177
+ .limit(1)
178
+ .execute()
179
+ )
180
+ return r.data[0] if r.data else None
181
+
182
+
183
+ def download_image(path: str) -> Image.Image:
184
+ raw = db().storage.from_(BUCKET).download(path)
185
+ return Image.open(io.BytesIO(raw))
186
+
187
+
188
+ def parse_embedding(value) -> np.ndarray | None:
189
+ """pgvector comes back as either a string literal or a list, depending on
190
+ the PostgREST version. Handle both rather than discovering it at 2am."""
191
+ if value is None:
192
+ return None
193
+ if isinstance(value, str):
194
+ value = value.strip().strip("[]")
195
+ if not value:
196
+ return None
197
+ return np.fromstring(value, sep=",", dtype=np.float32)
198
+ return np.asarray(value, dtype=np.float32)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Writes — only this process may perform them
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def write_result(
207
+ checkin_id: str,
208
+ *,
209
+ phash: str,
210
+ wide_embedding: np.ndarray,
211
+ close_embedding: np.ndarray,
212
+ confidence: int,
213
+ verdict: str,
214
+ signals: dict,
215
+ audit_sample: bool = False,
216
+ ) -> None:
217
+ db().table("checkins").update(
218
+ {
219
+ "phash": phash,
220
+ "wide_embedding": vector_literal(wide_embedding),
221
+ "close_embedding": vector_literal(close_embedding),
222
+ "confidence": confidence,
223
+ "verdict": verdict,
224
+ "signals": signals,
225
+ "audit_sample": audit_sample,
226
+ }
227
+ ).eq("id", checkin_id).execute()
228
+
229
+
230
+ def write_advice(checkin_id: str, species_guess: dict, advice: dict) -> None:
231
+ """Store the advisory assessment.
232
+
233
+ Writes ONLY the two advisory columns. It does not touch `confidence`,
234
+ `verdict` or `signals`, and that separation is the whole point: advice is
235
+ generated by a component whose error rate we have not measured, so it must
236
+ not be able to move a number we publish.
237
+
238
+ Note this is a separate write from write_result rather than a parameter on
239
+ it. Scoring is replayable and re-run whenever a threshold changes; advice is
240
+ a paid external call that should survive a re-score untouched. Folding the
241
+ two together would either destroy advice on every recalibration or make
242
+ recalibration cost money.
243
+ """
244
+ db().table("checkins").update(
245
+ {"species_guess": species_guess, "advice": advice}
246
+ ).eq("id", checkin_id).execute()
247
+
248
+
249
+ def list_checkins_without_advice(limit: int = 100) -> list[dict]:
250
+ """Check-ins that have never been assessed, oldest first."""
251
+ r = (
252
+ db()
253
+ .table("checkins")
254
+ .select("id")
255
+ .is_("advice", "null")
256
+ .order("server_received_at", desc=False)
257
+ .limit(limit)
258
+ .execute()
259
+ )
260
+ return r.data or []
261
+
262
+
263
+ def update_tree_status(tree_id: str, confidence: int, verdict: str) -> None:
264
+ """Roll the latest verdict up onto the tree.
265
+
266
+ 'alive' only on a verified check-in. A tree never becomes 'dead'
267
+ automatically — that is a human decision, because being wrong about it stops
268
+ someone's payment.
269
+ """
270
+ status = {
271
+ "verified": "alive",
272
+ "review": "pending",
273
+ "flagged": "flagged",
274
+ }.get(verdict, "pending")
275
+
276
+ db().table("trees").update(
277
+ {"status": status, "current_confidence": confidence}
278
+ ).eq("id", tree_id).execute()
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pinned loosely — Hugging Face Spaces rebuilds on push and a surprise major
2
+ # version on pitch week is not a risk worth taking for a few KB of convenience.
3
+
4
+ fastapi>=0.115,<1
5
+ uvicorn[standard]>=0.32,<1
6
+ pydantic>=2.9,<3
7
+
8
+ # onnxruntime, NOT torch. Torch pulls ~2 GB and would make cold starts on the
9
+ # free Spaces tier unusable. Torch stays a dev-only dependency for the spike.
10
+ onnxruntime>=1.19,<2
11
+ huggingface_hub>=0.26,<1
12
+
13
+ numpy>=1.26,<3
14
+ pillow>=10.4,<12
15
+
16
+ # contrib, not plain opencv — cv2.aruco lives in contrib and the ArUco marker is
17
+ # how a photo becomes a measurement in millimetres.
18
+ opencv-contrib-python-headless>=4.10,<5
19
+
20
+ supabase>=2.9,<3
21
+ httpx>=0.27,<1
22
+
23
+ # Advisory layer only: species identification and care advice for the planter.
24
+ # NOT on the scoring path - pipeline.py never imports the advisor, so a slow or
25
+ # failed API call cannot affect a verdict.
26
+ anthropic>=0.40