DiaDNA ReID · reid.diadna.cloud
Rendered from API.md, the single source of truth in the
repository. Shareable by URL; not search-indexed. The live machine-readable
contract is at /api/v1.
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?
https://reid.diadna.cloudX-Api-Key header (per-client keys, issued by the operator)Every example below was executed against the live deployment; the responses are verbatim.
# 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"
{
"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.
POST /api/v1/verify-matchAccepts 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.
| 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.
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).NaN/Infinity).Internally each spectrum is interpolated onto the service's reference axis: 671 points, 492.23 → 5988.79 cm⁻¹, 8.2038 cm⁻¹ step.
| 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 |
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.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.
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.
p_match_5050 correctlyThe 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.
Use the verdict for automation and the probability for routing, not the verdict alone:
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.
| 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.
warningswarnings is non-fatal but never noise. The full set currently emitted:
reject_target is not being delivered;
see §4b. Fires on every call until your population is approved at that rung.expected_genuine_accept — accompanies the above; the figure
shown is carried from another population.only 1 rough scans — below the validated intake protocol (>=3 …).zip.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.
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.
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.
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.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.
member_scoresIndependently 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.
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:
{
"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.
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.
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:
export ADMIN=<admin token>
# 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.
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.
Read this before putting a number in front of a customer.
NO_MATCH means this claim is not
supported, not "this polish came from no rough we hold". Absence cannot
be established from a score.p_match_5050 is valid at a 50:50 prior (§4).| 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.
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.
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.
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_ids 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.
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: <invite 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 |
# 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).