DiaDNA ReID · reid.diadna.cloud

API documentation

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.

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?

Every example below was executed against the live deployment; the responses are verbatim.


1. Quick start

# 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.


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.

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
}

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:

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.

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.


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:

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:

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:

{
  "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.

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.

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)

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.

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.


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: <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).