# DiaDNA ReID — verify-match API v1 · developer guide Machine-to-machine verification for the **match case**: given one polish scan set and one claimed, previously scanned rough scan set — *did this polish come out of this rough?* - **Base URL**: `https://reid.diadna.cloud` - **Auth**: `X-Api-Key` header (per-client keys, issued by the operator) - **One claim per request**, typical latency 1–2 s - **Scope**: 8 cm⁻¹-class (RES 16) populations. Read [Scope and limits](#8-scope-and-limits) before quoting any accuracy figure to a customer. Every example below was executed against the live deployment; the responses are verbatim. --- ## 1. Quick start ```bash # 1. operator issues you a key (see §7) → reidapi-... export KEY=reidapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 2. verify a claim: the stone's stored rough scans + the arriving polish scans curl -X POST https://reid.diadna.cloud/api/v1/verify-match \ -H "X-Api-Key: $KEY" \ -F "rough_files=@rough_scans.zip" \ -F "polish_files=@polish_scans.zip" \ -F "reject_target=0.98" ``` ```json { "mode": "verify-match", "verdict": "NO_MATCH", "fused_score": 0.5605, "scorer": "raw", "member_scores": {"D2W4": 0.6523, "TD_D2": 0.5165}, "member_cosines": {"D2W4": 0.6523, "TD_D2": 0.5165}, "threshold": 0.6183, "threshold_source": "population-calibrated (1000 claims, 969 roughs, approved 2026-08-26)", "reject_target": 0.96, "expected_genuine_accept": 0.9709, "p_match_5050": 0.7576, "n_rough_scans": 6, "n_polish_scans": 6, "rough_sharpness": 0.0394, "polish_sharpness": 0.0542, "rough_detail_residual": -0.0207, "polish_detail_residual": -0.0941, "scope": "single-pair match case; one rough scan set against one claimed polish scan set; probability valid at a 50:50 claim mix", "warnings": [] } ``` Field order is not guaranteed; `scorer`, `member_cosines`, `threshold_source` and the two `*_detail_residual` fields are always present. An **uncalibrated** population differs in three ways: `threshold_source` starts `shipped-default`, `threshold` is the carried-over value, and `warnings` is never empty — it carries at least the two entries described in §4b. `GET /api/v1` (no auth) returns a machine-readable summary of this contract — useful as a liveness/version probe. --- ## 2. Endpoint reference ### `POST /api/v1/verify-match` Accepts **either** multipart file upload **or** a JSON body. Pick whichever matches your integration; both were verified to produce byte-identical scores for the same spectra. #### Option A — multipart (recommended if you have scan files) | field | required | notes | |---|---|---| | `rough_files` | yes | repeatable. `.dpt`, `.csv`, `.txt`, Bruker OPUS `.0`, or a `.zip` containing them | | `polish_files` | yes | repeatable, same formats | | `reject_target` | no | default `0.98`; see §4 | `.dpt`/`.csv`/`.txt` are two-column `wavenumber,absorbance`. Bruker OPUS `.0` files are read directly (preferred: the `.dpt` export is a manual OPUS step that carries no metadata and has silently duplicated scans in a delivery). Only OPUS *Extract* files carry a processed spectrum; an original `Video Wizard Measurement` file holds the interferogram and is rejected with a reason naming the fix. Zips are expanded server-side; `__MACOSX`/`._` entries are ignored. All scans on one side are pooled into that side's record — see §5 on stone IDs. #### Option B — JSON (recommended if you already parse spectra) ``` POST /api/v1/verify-match Content-Type: application/json X-Api-Key: reidapi-... { "rough": {"wavenumbers": [5995.22, 5991.12, ...], "spectra": [[1.366, 1.376, ...], [1.371, ...], ...]}, "polish": {"wavenumbers": [...], "spectra": [[...], ...]}, "reject_target": 0.98 } ``` - `wavenumbers`: 1-D array, **≥ 50 values**, either ascending or descending (the server sorts). One axis per side; both sides may use different axes. - `spectra`: 2-D array `[k × N]` where `N == len(wavenumbers)` and **1 ≤ k ≤ 64** (k = number of scans on that side). - All values must be finite (no `NaN`/`Infinity`). - **Range matters** — see the coverage pitfall in §6. Internally each spectrum is interpolated onto the service's reference axis: **671 points, 492.23 → 5988.79 cm⁻¹, 8.2038 cm⁻¹ step.** --- ## 3. Response fields | field | meaning | |---|---| | `verdict` | `MATCH` or `NO_MATCH` — the automated decision at your chosen operating point | | `fused_score` | mean cosine similarity across the fusion encoders; the quantity thresholded | | `member_scores` | the individual encoder scores (diagnostic; see §6). **Do not hardcode the member names or their count** — the fusion changed from three encoders to two on 2026-09-04 and may change again. Treat this object as an opaque diagnostic map; `fused_score` is the quantity with a defined meaning | | `threshold` | the fused-score bar applied — **population-calibrated when your population has an approved ladder, otherwise a carried-over default (see §4b)** | | `threshold_source` | which of those two you got, and how the calibrated one was derived | | `reject_target` | the operating point actually used | | `expected_genuine_accept` | expected share of **honest** claims confirmed at this operating point. **Not a measured rate** — it is estimated without labels by separating your claim-score distribution into genuine and wrong components (§4c). `null` when your bank is too small or too mixed to estimate | | `p_match_5050` | calibrated probability the claim is true — **assumes a 50:50 prior** (§4) | | `n_rough_scans` / `n_polish_scans` | scans actually used per side | | `rough_sharpness` / `polish_sharpness` | spectral-detail gauge per side (§6) | | `rough_detail_residual` / `polish_detail_residual` | how far each side's noise character departs from directly-acquired data. Honest range roughly −0.23 to +0.31; see §6b | | `scorer` | which scoring rule produced `fused_score` (currently always `raw`) | | `member_cosines` | raw per-encoder cosines before any normalisation (diagnostic) | | `warnings` | non-fatal integration or data-quality problems — **always log these** | | `scope` | the calibration scope string; keep it with any stored result | --- ## 4. Choosing an operating point `reject_target` is the fraction of **wrong-rough claims** the threshold rejects. Tightening it rejects more false claims but confirms fewer genuine ones. Measured on the td 8 cm⁻¹ population: | `reject_target` | wrong claims rejected | genuine confirmed (td) | genuine confirmed (range measured across three client populations) | |---|---|---|---| | `0.99` | 99% | 86.0% | 62–74% | | `0.98` (default) | 98% | 92.7% | 79–89% | | `0.97` | 97% | 95.4% | 86–95% | | **`0.96`** | **96%** | **96.8%** | **91–97%** | | `0.95` | 95% | 97.4% | 94–99% | | `0.94` *(experimental)* | 94% | 98.0% | 92–95% | | `0.93` *(experimental)* | 93% | 98.0% | 93–97% | | `0.92` *(experimental)* | 92% | 98.0% | 93–97% | | `0.91` *(experimental)* | 91% | 98.1% | — | | `0.90` *(experimental)* | 90% | 98.1% | — | Values from `0.90` to `0.99` in `0.01` steps are accepted; anything else returns `400`. **The five rungs below `0.95` are EXPERIMENTAL** (added 2026-09-04). They are available for measurement, not for quotation: a response at one of them carries `experimental: true` and a warning to that effect. Two further cautions: - `0.91` and `0.90` **cannot be population-calibrated at all.** Their thresholds do not settle at any bank size we have been able to measure, so they always run on the carried-over default — which, per §4b, under-rejects by 6–14 points. A measurement at those two rungs describes the default's error, not the rung. - On a *calibrated* population the ladder is exact. Measured on live traffic with 20 random wrong roughs per banked claim, every population landed within 0.1 points of its label at every rung from 0.96 to 0.90, and loosening 0.96 → 0.90 cost precisely the 6 points advertised — no more. **Read the right-hand column, not the td column.** The td figures come from the calibration population. Six batches across three real 8 cm⁻¹ client populations read materially lower, and the spread between populations is not noise: it tracks how alike the stones in a parcel are, which is a property of the goods, not of the instrument. Two consequences for you: do not quote the td number to an end customer, and expect your own population's figure to settle somewhere in the range once it has its own calibration. **`0.96` is the recommended default** rather than `0.98`. The cost of each extra point of rejection accelerates — the 97→98 step costs two to three times what 95→96 costs — so `0.98` buys its extra security out of a disproportionate number of honest claims sent to review. ### 4b. Whether your population is calibrated — and what it costs if not Absolute thresholds are population-specific. Until your population has enough of its own claims to derive a ladder, you are scored against a threshold carried over from the calibration population, and **that threshold does not deliver its stated `reject_target`**. Measured on three client populations, a carried-over default rejected this many percentage points FEWER wrong claims than its label: | `reject_target` | shortfall on an uncalibrated population | |---|---| | `0.99` | 3.1–3.7 pts | | `0.98` | 3.9–5.2 pts | | `0.97` | 4.6–6.5 pts | | `0.96` | 4.1–9.5 pts | | `0.95` | 4.4–10.4 pts | | `0.94` | 4.9–12.0 pts | | `0.92` | 5.7–15.0 pts | | `0.90` | 6.2–17.8 pts | So a client reading "96% of wrong claims rejected" on an uncalibrated population is actually getting 86–92%. **The shortfall widens sharply as the target loosens** — re-measured 2026-09-04 across four populations, the worst case goes from 9.5 points at `0.96` to 17.8 at `0.90`. In per-1,000 terms: on an uncalibrated population, random wrong claims accepted rises from 138 per 1,000 at `0.96` to 277 per 1,000 at `0.90`, against 40 → 101 on a calibrated one. **The loose rungs are more dangerous uncalibrated than the strict ones, not less.** Two fields tell you which state you are in: `threshold_source` says `shipped-default …` or `population-calibrated …`, and while you are on the default you receive an explicit warning to that effect on every call. Your submitted claims accumulate towards your own calibration automatically — nothing is required from you — and an operator approves it explicitly. Strict rungs need less data than loose ones — measured, about 40 claims for `0.99`, 75 for `0.98`, 150 for `0.97`, 200 for `0.96`, 400 for `0.95`, 800 for `0.94` and 1,200 for `0.93`/`0.92`, while `0.91` and `0.90` never settle — so you may be calibrated at one rung and still on the default at another; the warning names which. ### Using `p_match_5050` correctly The probability is calibrated against a **50:50 mix** of true and false claims (isotonic). Two limits to know. The often-quoted "ECE 0.0002" is an *in-sample* fit residual on the calibration population; measured out of sample on three client populations the 50:50 calibration error is **0.043–0.060**, i.e. two orders of magnitude larger. And the probability is **not** recalibrated when your population has its own approved threshold — the verdict moves with your population, `p_match_5050` does not. In practice all currently approved thresholds sit high on the isotonic curve, so a genuine claim can be returned `NO_MATCH` with a `p_match_5050` above 0.99. **Decide on `verdict`, not on the probability.** If your real claim mix is skewed — say 1 false claim in 20 — the displayed number **overstates** the chance a claim is false. Convert with Bayes on the odds: ``` odds_5050 = p / (1 - p) odds_real = odds_5050 * (prior_true / prior_false) / (0.5 / 0.5) p_real = odds_real / (1 + odds_real) ``` A prior-adjusted variant is planned once field telemetry establishes the real mix — ask the operator before hardcoding your own prior. ### Recommended decision pattern Use the verdict for automation and the probability for routing, not the verdict alone: ```python if r["verdict"] == "MATCH": accept() # ~98% of wrong claims already excluded elif r["p_match_5050"] >= 0.60: queue_for_human_review(r) # borderline genuine claims live here else: reject() ``` Borderline **genuine** claims typically land at P ≈ 70–85% with a `NO_MATCH` verdict — that band is the review queue, not a rejection. --- ## 5. Data requirements | requirement | value | why | |---|---|---| | Rough scans | **≥ 3, 6 recommended** | 6 is the validated intake protocol; a single scan costs ~6 acceptance points. Below 3 the API warns. | | Polish scans | 1 is sufficient | more adds ~0.6 points; send what you have | | Rough acquisition | rotate/re-seat the stone between scans | zoned stones need the record to average multiple orientations | | Instrument class | RES 16 (8 cm⁻¹), atmospheric compensation ON, fresh backgrounds | matches the calibration population | | Stones per side | exactly one | multiple IDs are pooled with a warning | | Wavenumber coverage | ≥ 492–5989 cm⁻¹ | outside this the server edge-fills and warns (§6) | **One stone per side.** If the uploaded files contain scans from several stone IDs, everything on that side is pooled into a single record and you get: ``` "3 rough / 1 polish stone ids detected — pooling all scans per side" ``` That is almost always an integration bug (wrong folder, mixed tray) — treat the warning as an error in your own code. --- ## 6. Warnings, pitfalls, and diagnostics ### Always log `warnings` `warnings` is non-fatal but never noise. The full set currently emitted: - **Uncalibrated population** — your `reject_target` is not being delivered; see §4b. Fires on every call until your population is approved at that rung. - **Unvalidated `expected_genuine_accept`** — accompanies the above; the figure shown is carried from another population. - **Too few rough scans** — `only 1 rough scans — below the validated intake protocol (>=3 …)` - **Duplicate rough scans** — byte-identical copies do not carry independent information, so six copies of one scan are not six scans. - **Multiple stone IDs pooled** — see §5. - **Insufficient wavenumber coverage** — the server interpolates and edge-fills a partial axis rather than erroring. Applies to file uploads *and* JSON, and to spectra inside a `.zip`. - **Off-manifold input** — the noise character of your spectra does not match directly-acquired data. See §6b; this one is worth an alert, not a log line. - **Locally polynomial input** — the signature of Savitzky-Golay or similar smoothing. Also §6b. A tolerance of one axis step is allowed on the coverage check, because every real instrument export stops a fraction short of the reference axis maximum; without it the warning fired on 100% of genuine traffic and told you nothing. ### Pitfall: truncated wavenumber range (measured) Submitting a partial axis does **not** error — the server interpolates and edge-fills the missing region. Measured impact: a 400–4000 cm⁻¹ submission of the same stone scored **0.5374 instead of 0.5605 (−0.023)**, which is comparable to the gap between adjacent threshold rungs. Always send the full instrument range. The warning above now flags it; do not ignore it. ### Diagnostic: sharpness gauges `rough_sharpness` / `polish_sharpness` measure spectral detail. Healthy 8 cm⁻¹-class acquisition sits around **0.040–0.065**. Persistent values well below that indicate a resolution or focus problem on that instrument (the program traced exactly this to units misconfigured at RES 24); values far above suggest a different instrument class than the calibration population. Track them per machine — they are an early warning, not a per-claim gate. ### 6b. Input integrity — send the instrument's unprocessed export This is the one section where the server will actively **refuse** your request, so it is worth reading even if you skip the rest. Removing detail from a spectrum does not merely lose signal — it **raises** the score for WRONG claims. Because you supply both sides of a claim, that makes processed input a security problem rather than a quality one. Measured at a real approved threshold, wrong-claim acceptance rises from 0.8% on unprocessed scans to 11% under a 7-point moving average and 22% under a 9-point one; two *different* stones reduced to featureless traces score a perfect 1.0. Counter-intuitively, **genuinely low-resolution scans are fine.** Stratifying 2,921 real claims by their own detail level, the lowest decile separated *better* than the highest. The problem is specifically detail that was *removed*, which is why the server does not gate on the sharpness number. What it gates on instead is whether your spectra look directly acquired: - `rough_detail_residual` / `polish_detail_residual` — noise character against detail level. Honest data sits between about **−0.23 and +0.31**. Outside that you get a warning; below **−0.50** the claim is **refused with a `400`**. - A second check flags input that is unusually well fitted by a short local polynomial — the signature of Savitzky-Golay smoothing. **So: do not smooth, filter, baseline-correct, resample or re-export through another package.** Send what the instrument wrote. If your pipeline does any of this by default, turn it off for the copy you submit — otherwise you will see warnings, and eventually refusals, on honest claims. **Known limitation, stated plainly.** Light Savitzky-Golay smoothing (9–15 points) is detected 82–90% of the time but is **not refused**, because honest data overlaps that region and refusing would reject real claims. What slips through is worth roughly 2–16% wrong-claim acceptance against a 2% label. If you need a hard guarantee against a claimant who processes their own input, this endpoint does not yet provide one — talk to the operator. ### Diagnostic: `member_scores` Independently trained encoders vote, and `fused_score` is their mean. Broad agreement means a confident result; one member far from the others usually indicates unusual acquisition rather than a different answer. Useful in bug reports — **do not build logic on individual members, or on how many there are**, as membership changes with calibration. It went from three encoders to two on 2026-09-04, which is exactly the kind of change this warning is for. ### Not covered: adversarially chosen claims The accuracy figures describe **random wrong claims** (mixed-up barcode, swapped parcel). They do **not** cover a claim chosen to look plausible — probing one polish against many roughs to find a pass. **This is now flagged explicitly, and flagged on the access pattern rather than on the outcome.** Submitting one polish scan set against two or more claimed roughs is recorded as a `probing` finding whether or not any attempt was confirmed, because the published rates assume one claim per stone and a retried claim is selecting for a pass. If more than one rough is confirmed for the same polish, that is recorded separately as `multi_pass` — at most one can be the true source, so a confirmation there is wrong by construction. The two are counted independently and an operator reviews both. If you have a legitimate need to evaluate against a panel of your own roughs, **ask the operator for a test key**. Traffic on a test key is expected to look like this and is reported separately; the same traffic on a production key is a breach of the operating rule your rates depend on. **Stone references are retained.** When you submit files, the stone identifier parsed from the filenames (the `base-part` portion) is stored alongside the call so that this review can name the stones involved instead of showing opaque digests. No spectra are ever retained — only the derived 256-d vectors and these references. **If you use the JSON path, send `stone_id`.** JSON carries no filenames, so without it every one of your calls is reviewed as an opaque digest — and if a pattern does need discussing, neither side can tell which stones it concerns. Add it to either side; it is optional, free-form, trimmed and capped at 64 characters, and it changes nothing about scoring: ```json { "rough": {"stone_id": "11164-31", "wavenumbers": [...], "spectra": [[...]]}, "polish": {"stone_id": "11164-31.2", "wavenumbers": [...], "spectra": [[...]]}, "reject_target": 0.96 } ``` Measured cost of the pattern: a random wrong claim is rejected ~98% of the time, but the best of 29 chosen decoys only 59–68% of the time. That access pattern is logged and flagged operator-side (the detector groups on a content digest that is now order-invariant, so reshuffling the uploaded files no longer hides it), and a pass obtained that way falls outside the promise. Do not build a "try all roughs" loop against this endpoint; if you need catalog search rather than claim verification, ask the operator — that is a different product path. --- ## 7. Key management (operator) ### Replay credential (operator) Re-submitting scans that were already claimed — to backfill a shadow encoder candidate — is done with the normal endpoint plus a replay header. It suppresses the claim-history write (a replay is not a new commercial claim) and tags telemetry `verify_match_replay`, while still banking embeddings. ```bash curl -s https://reid.diadna.cloud/api/v1/verify-match \ -H "X-Api-Key: $CLIENT_KEY" \ -H "X-Reid-Replay-Key: $REPLAY_TOKEN" \ -H "X-Reid-Replay: 1" \ -F rough=@... -F polish=@... ``` `X-Reid-Admin` also authorises it. The replay-scoped token authorises replay and nothing else — it returns 401 on every other operator endpoint — so it is what an admin panel should hold. Without either credential the replay flag is ignored and the response says so in `warnings`. Replaying the same scan twice writes one record, so batches are safe to re-run. Keys are named per machine or integration, so every claim is attributed in telemetry. Manage them in the ReID Lab under **API claims** (UI), or via the admin API: ```bash export ADMIN= # create — the full key is returned ONCE curl -X POST https://reid.diadna.cloud/api/v1/keys \ -H "X-Reid-Admin: $ADMIN" -F "name=intake-machine-7" # → {"name":"intake-machine-7","key":"reidapi-...","note":"store this key now — it is not shown again in full"} # list (prefixes only) curl https://reid.diadna.cloud/api/v1/keys -H "X-Reid-Admin: $ADMIN" # revoke by key prefix — access stops immediately, history stays attributed curl -X DELETE https://reid.diadna.cloud/api/v1/keys/reidapi-5d7460fa \ -H "X-Reid-Admin: $ADMIN" ``` Duplicate names are rejected. Rotate a key by creating the replacement, switching the caller, then revoking the old one. ### Telemetry and review Every call logs scores, verdict, scan counts, sharpness, and 16-hex content hashes of the scan sets — **never the spectra themselves**. Operators review this in the ReID Lab: **API claims** (per-claim ledger, filters, shopping flags) and **Telemetry** (volumes, score-drift against the calibration baseline, hygiene). Admin endpoints: `GET /api/v1/claims`, `GET /api/telemetry/summary`, `GET /api/telemetry/events`. --- ## 8. Scope and limits Read this before putting a number in front of a customer. 1. **Population-specific.** The calibration is the td 8 cm⁻¹ production population. A different population can behave very differently — a RES-8/4 cm⁻¹ client population measured at chance level against look-alike claims. Do not reuse these figures for another instrument class or a materially different stone population without asking. 2. **Tune-grade, not yet sealed.** Thresholds and expected rates come from a train-clean but re-used calibration surface. A sealed field parcel is in collection to re-stamp them; treat current numbers as accurate-but- provisional, and re-read this section after the re-stamp. 3. **Random impostors only.** See "adversarially chosen claims" in §6. 4. **Cannot certify absence.** A `NO_MATCH` means *this claim is not supported*, not "this polish came from no rough we hold". Absence cannot be established from a score. 5. **Probability prior.** `p_match_5050` is valid at a 50:50 prior (§4). 6. **Enrollment note.** Rough-only intake performs at the cold-start level by design — a measured null showed that adding rough scans to the model's training set does not help first-contact accuracy. Nothing you do at submission time changes this; it is not a misconfiguration. --- ## 9. Errors | status | example `detail` | cause | |---|---|---| | `401` | `valid X-Api-Key header required` | missing, unknown, or revoked key | | `400` | `reject_target must be one of ['0.99', '0.98', '0.97', '0.96', '0.95']` | unsupported operating point | | `400` | `no parseable spectra on one side` | empty/unreadable upload, wrong field name, or a zip with no spectra | | `422` | `rough: wavenumbers must be a 1-D list of >=50 values` | malformed JSON side | | `422` | `polish: expected {"wavenumbers": [N], "spectra": [[N floats], ...]}` | missing or wrongly shaped JSON side | | `400` | `… non-finite value(s) (nan/inf) in the data` | a scan contains `nan`/`inf`; fix the export | | `400` | `… scan(s) are flat (zero variance) and carry no identity` | a featureless trace — such input matches ANY stone | | `400` | `… do not match the noise character of directly acquired data …` | input was smoothed or filtered; see §6b | | `400` | `body is not valid JSON` / `JSON body must be an object …` | malformed request body | | `500` | `internal error (…); this request will fail again unchanged` | server fault — do **not** retry, report it | | `400` | *(policy/asset message)* | server-side calibration problem — contact the operator, do not retry | All errors return `{"detail": "..."}`. Validation happens rough side first, so fix the reported side and resubmit. `400`/`422` are deterministic — retrying an identical request will not help. Retry only on `5xx` or network failure, with backoff; requests are stateless and safe to repeat. --- ## 10. Worked integration (Python) ```python import requests BASE = "https://reid.diadna.cloud" KEY = "reidapi-..." # from the operator; keep out of source control def verify_claim(rough_paths, polish_paths, reject_target=0.98, timeout=30): """Verify that a polish scan set came from a claimed rough scan set.""" files = ([("rough_files", (p.name, open(p, "rb"))) for p in rough_paths] + [("polish_files", (p.name, open(p, "rb"))) for p in polish_paths]) r = requests.post(f"{BASE}/api/v1/verify-match", headers={"X-Api-Key": KEY}, files=files, data={"reject_target": reject_target}, timeout=timeout) if r.status_code in (400, 401, 422): raise ValueError(f"{r.status_code}: {r.json()['detail']}") # our bug r.raise_for_status() # theirs → retry out = r.json() for w in out["warnings"]: # never silently drop these log.warning("verify-match: %s", w) if not 0.040 <= out["rough_sharpness"] <= 0.070: log.warning("rough sharpness %.4f outside the healthy band — check the " "instrument", out["rough_sharpness"]) return out res = verify_claim(rough_paths, polish_paths) if res["verdict"] == "MATCH": accept(res) elif res["p_match_5050"] >= 0.60: review(res) # borderline genuine claims else: reject(res) ``` Store, alongside your own record: `fused_score`, `verdict`, `reject_target`, `p_match_5050`, both sharpness values, `warnings`, and `scope`. Keeping `scope` and `reject_target` means a result stays interpretable after a future recalibration; keeping the raw scores means old claims can be re-decided at a new operating point without re-scanning the stone. --- *Contract version: v1. The response may gain fields; existing field names and semantics will not change without a new version path. Questions, key requests, or a population outside §8 → the operator.* ## 11. POST /api/v1/expected — tell us the ground truth afterwards Clients run deliberate false claims to check the system. A test is only a test if it is blind at submission time, so we do **not** ask you to flag tests up front or to use a separate key — either would tell us in advance. Instead, after you have our verdicts, you tell us what each claim actually was. Every `/api/v1/verify-match` response now carries a **`claim_id`**. Keep it with your record of the claim; it is the only thing needed to label later. ```bash curl -X POST https://reid.diadna.cloud/api/v1/expected \ -H "X-Api-Key: $KEY" -H 'Content-Type: application/json' \ -d '[{"claim_id":"23c330a6beedb88d","expected":"match","stone_id":"KG-1042"}, {"claim_id":"83a6d1d9fc17d0c0","expected":"no_match"}]' ``` | field | required | meaning | |---|---|---| | `claim_id` | yes | from the verify-match response | | `expected` | yes | `match` if the pair really is the same stone, `no_match` if not | | `stone_id` | no | your own reference, echoed back in reports | | `note` | no | free text, 280 chars | Up to 5,000 labels per call. A JSON object `{"items": [...]}` is also accepted. **Per-item status:** `accepted` (new), `duplicate` (same label already held), `amended` (label changed — both versions are retained), `rejected` (unknown `claim_id`, or `expected` not one of the two values). The response also tells you our verdict for each claim and whether your label agrees with it. `claim_id`s are scoped to the key that made the claim, so one client can never label another's traffic. **Labels are advisory.** They are recorded and reported, and they never move a threshold: security levels change only through explicit operator approval. What labels *do* is turn our accuracy reporting from inference into measurement — an honest-claim rate we can count rather than estimate, and true accept/reject rates from your live traffic. --- ## 12. LUMOS declared-lot API (separate product) A **different product on the same host**: declared-lot membership for the Bruker LUMOS II FTIR *microscope*, answering "are these the stones of this lot?" rather than "does this polish come from this rough". It shares no encoder weights with the endpoints above, and a LUMOS lot and a polish catalog cannot be scored against each other — the encoder pin enforces it. **Auth differs.** These endpoints use the lab workspace header `X-Reid-Token: `, not `X-Api-Key`. Ask the operator for a token. | endpoint | purpose | |---|---| | `GET /api/lumos/bounds` | what the tab is allowed to claim, with every exclusion | | `POST /api/lumos/lots` | enrol a declared lot (the records), ≥3 scans per stone | | `GET /api/lumos/lots` | list enrolled lots | | `POST /api/lumos/lots/{id}/check` | check a returning lot against its records | | `POST /api/lumos/lots/{id}/rehearse` | rehearse a substitution on your own lot | ```bash # enrol, then check the returning lot curl -X POST https://reid.diadna.cloud/api/lumos/lots \ -H "X-Reid-Token: $TOKEN" -F "name=LOT-89385" -F "files=@stone1-101.dpt" ... curl -X POST https://reid.diadna.cloud/api/lumos/lots/$LOT/check \ -H "X-Reid-Token: $TOKEN" -F "files=@returning1-104.dpt" ... ``` **There is no threshold dial.** Scoring is fixed at the one operating point the bounds were measured at — lot mode, twin-aware bar, FAR 10%, three enrolment and three verification scans. Exposing a dial would invite operating points nothing has been measured at. **Measured bounds** (lot 10 STAMPED by a sealed one-shot on 2026-09-10; lots 20–60 pilot grade on held-out stones; `GET /api/lumos/bounds` returns these and is the single source of truth): | bound | value | |---|---| | Genuine lot members confirmed | ≥96.2% at 20% substitution on every measured population and lot size (deployed path); stamped lot 10: 99.3% [98.0] shaken / 100% [100] table-up | | Unrelated substitutes caught, per stone | population-specific: shaken 91.2-95.8% (CI floor 88.7); table-up 79.5-87.4% (CI floor 76.7); culet-focus session 53.9-79.8% (CI floor 48.7); stamped lot 10: 93.2% [91.0] / 91.2% [88.0] | | Lot size range | 10–60 stones | | Substitution range | 0–40% | **Not covered, and stated plainly (2026-09-10 audit):** stones cut from the same rough as a lot member are caught only ~57–76% of the time — a single sibling swap is missed about one time in four. A never-enrolled stone cannot be certified absent from a lot. Results are population-specific: on the newest, protocol-correct parcel (never trained on) unrelated substitutes are caught only 54–80%, falling with lot size, because those goods look far more alike to the instrument; a 10-stone pre-flight on the customer's goods sets the achievable operating point. Stone sizes above ~0.08 ct are unmeasured. Stones returning mounted in jewellery are out of scope, still to be experimentally established. The full paper with every measured cell is `reruns/lumos_paper/paper/` (also published as an artifact page and PDF).