How a model ships here

One model, three destinations, one conversion framework. One drawing, eight edges, a contract on each. Where a stage is allowed to change something, it says so. Where nothing checks, it says that too.

This is the path for rfdetr-s-litter, the detector in the request path today. Read the diagram top to bottom: the spine is what serves, the right-hand branch is where conversions are built and measured, and the mark between them is the edge that does not exist yet.

§ 01 · Why there is more than one target

A conversion framework inside a litter app reads as over-engineering right up until you count the places this one set of weights has to run. There are three. They do not share a budget, and none of them is a hypothetical — each is written down somewhere in the repositories as a decision already taken.

A Lambda — today

120,039,167 bytes of fp32 ONNX, pulled from S3 on a cold start and held for the container's lifetime, under onnxruntime-node on an arm64 Lambda. Two functions are pinned to that one key. Nothing on this path declares a size ceiling: the bytes are paid for in cold starts, not refused. Until this month it was the only runtime with a record in the registry; a TensorRT target now has two. Its own record's measuredOnTarget is still false.

The robot — next

CrustBot's board is the 1 GB Raspberry Pi 5 on /benchmarks, and as of 2026-09-08 it has run this model: 745 ms p50 int8, 1143 ms p50 fp32, both inside 1 GB, fp32 with no swap touched. So the fp32 graph fits, against expectation, and int8 buys latency rather than admission. A deployable frame rate is still gated on a distilled or quantization-aware-trained student, and no accuracy or thermal number for this model exists on that board.

A browser on a phone — open

The first capture spec ran the detector in the browser with onnxruntime-web, and V1 pivoted away from it: about 20 MB of runtime plus weights made a first-visit cold cache 10–20 s on cellular, and the ceiling it implied was roughly 5 MB of model. It was deferred as an opt-in offline fallback rather than deleted, and the three browser-served yolo11n descriptors in § 04 still carry that ceiling as a declared 6 MB budget, checked per row.

Three destinations, three budgets, one set of weights. That is the whole argument for the machinery below: "convert the model" is not one operation performed once, and the difference between a framework and a drawer of one-off scripts is whether the second destination costs a dict entry or a second script.

§ 02 · The DAG
[1] checkpoint_best_total.pth [2] export.export_rfdetr [3] rfdetr-s-litter.onnx [4] make_fp16.py / make_int8.py [5] export.parity [6] s3://…-models/litter-detector/ [7] loadModel() → ORT session [8] postprocessDetr() [9] review queue data.yaml · training_config.json opset 17 · 512×512 · fp32 graph .meta.json · .onnx.gz v2.0.1-fp32 — the reference candidate only — never uploaded raw tensors, then decoded detections at the 0.4 operating point v2.0.1-fp32/rfdetr-s-litter.onnx + rfdetr-s-litter.meta.json inference-on-upload · cleanup-diff cached per container lifetime argmax 0..42 · sigmoid · top-k 100 a human confirms every suggestion C1 C2 C3 · promote manual copy C4 · candidate C5 C6 C7 C8 the edge that does not exist [5] reports, then exits 0

Nine nodes, eight edges, and one absence. Every box is a file or a process that exists in the repository today; the dashed stub is the only thing on the drawing that is not built.

§ 03 · The contract on each edge

Six facets per edge — tensor schema and shapes, dtype and precision, opset, class order, coordinate convention, and who owns it when it changes. Where a facet does not apply, or applies but is not checked, that is written down rather than omitted.

C1 · checkpoint → exporter

Payload
checkpoint_best_total.pth, its training_config.json, and the training data.yaml.
Schema
n/a — a torch state dict, not a tensor interface.
Dtype
fp32 weights. Precision is not yet a decision on this edge.
Opset
n/a.
Class order
This is the contract. data.yaml defines the canonical 43-leaf order; the checkpoint's class_names must equal it exactly, element for element. Any difference means logit column i is not class i, and the exporter raises.
Coordinates
n/a.
Owner
Whoever trains. A retrain on a reordered dataset breaks this edge, and the exporter is the only thing in the system that would notice. It degrades to a warning when training_config.json is absent, and --skip-class-check turns it off outright.

C2 · exporter → artifact set

Payload
.onnx, .onnx.gz, and .meta.json, written together in one call.
Schema
input [1, 3, 512, 512]; outputs dets [1, 300, 4] and labels [1, 300, 44] — 43 classes plus one trailing background column.
Dtype
float32 throughout. The precision is decided here, by parsing the suffix off the --version string; -int8 is refused for want of a calibration pass.
Opset
17, pinned by the exporter and passed straight into RFDETRSmall.export.
Class order
Written into the sidecar's classes array in canonical order. The sidecar is the only artifact that carries the mapping.
Coordinates
cx, cy, w, h normalized to [0, 1] of the model input. Not converted here.
Owner
The exporter. Resolution, opset, normalization and class list are emitted in the same call as the graph, so they cannot drift from each other — that co-emission is the whole reason the sidecar exists.

C3 · artifact set → S3 (promote)

Payload
Two objects copied under one versioned prefix.
Schema
Unchecked. S3 stores bytes.
Dtype
Carried by the prefix string and nothing else — v2.0.1-fp32/. No process compares the name to the graph.
Opset
Unchecked.
Class order
Travels inside the sidecar object. Unchecked.
Coordinates
Unchecked.
Owner
Whoever uploads. This is the widest edge on the page: a manual copy, and the last point at which a wrong artifact is cheap to catch. Everything downstream trusts it completely.

C4 · artifact set → conversion

Payload
The fp32 graph, read only. Both scripts refuse to convert in place and write to a separate output path.
Schema
Unchanged by both converters — same input name and shape, same two outputs, same 300 queries.
Dtype
This is what the edge changes. fp16: convert_float_to_float16(keep_io_types=True), so the IO boundary stays float32 and no consumer has to know. int8: quantize_dynamic — weights quantized ahead of time, activation scales computed at run time, IO untouched.
Opset
Unchanged (17).
Class order
Untouched. A precision conversion cannot permute the head, which is why C1 is the only place order is enforced.
Coordinates
Untouched.
Owner
The conversion scripts. Both construct an ONNX Runtime session before declaring success — the cheapest possible check, and the one whose absence let a fp16 graph that onnx.checker accepts and ORT refuses sit unnoticed in the exporter's default path.

C5 · conversion → parity

Payload
Reference artifact, candidate artifact, the sidecar, and a fixture manifest of real images. No random tensors.
Schema
Both graphs must accept the sidecar's inputShape and produce comparable output tensors; comparison is elementwise, then query by query.
Dtype
The pair under test. The harness reports divergence at three tolerance pairs as buckets, not gates.
Opset
Not compared.
Class order
Read once from the sidecar and applied to both artifacts, so a class flip is attributable to the conversion and not to the decode.
Coordinates
Decode mirrors the backend exactly: argmax over columns 0–42, sigmoid of the winner, threshold, top-k 100, no NMS.
Owner
The harness — see § 05 for what it does and does not do at the end of a run.

C6 · S3 → runtime session

Payload
Model bytes and sidecar, fetched together on cold start and cached for the container lifetime, keyed on the sidecar's version.
Schema
Not validated. ONNX Runtime fails the session build on a malformed graph, and that is the only structural check on this edge.
Dtype
Not checked at all. The serving ModelMeta interface has no precision field. An int8 artifact dropped at this key would load, serve, and leave no record that precision had changed.
Opset
Not checked. ORT would reject an unsupported one, which is a runtime failure rather than a release check.
Class order
Taken on trust from the sidecar.
Coordinates
n/a.
Owner
The CDK stack. The key is a string literal, repeated at two places for two Lambdas; changing the served model means changing both and redeploying.

C7 · session → decode

Payload
Two flattened Float32Arrays, with query count and channel count passed alongside.
Schema
[1, 300, 4] and [1, 300, 44].
Dtype
float32 in both directions — because keep_io_types preserved it upstream, not because anything here asserts it.
Opset
n/a.
Class order
Arity is checked; order is not. Decode raises unless the channel count is classCount or classCount + 1. A permutation of the same 43 columns passes silently and produces confident, well-formed, wrong labels.
Coordinates
Converted here: cx, cy, w, h → x_min, y_min, x_max, y_max, clamped to [0, 1]. Valid against the original image only because the resize is a squash rather than a letterbox.
Owner
The backend decode. It is the single place that knows the 44th column is background.

C8 · decode → review queue

Payload
Detection[] — class label, confidence, box.
Schema
At most 100 per image, sorted by confidence.
Dtype
n/a — JSON.
Opset
n/a.
Class order
Already resolved to strings. Past this edge an index error is unrecoverable and invisible.
Coordinates
Normalized x_min, y_min, x_max, y_max in [0, 1] against the original image.
Owner
The product. This edge is where the 0.4 operating point is justified: a human confirms every suggestion, so a false positive costs one tap and a miss costs a record that never exists.
§ 04 · One source, many targets

Node [4] on the drawing is two scripts. It is now one function behind a descriptor. A target is data: export.precision.TARGETS declares seven of them across two architectures, each a dict entry naming a precision, an IO boundary, a mechanism, an opset, an input shape and a size budget. Adding a target is an entry in that dict — not a new script, not a new branch.

The table is one run of scripts/build_matrix.py over the rfdetr descriptors, from one source artifact: 120,039,167 bytes, sha a59fc4174a77. Every row came out of the same three calls — get_target → plan → convert — differing only by the descriptor they were handed.

Target Precision IO Mechanism Bytes % of source sha256 In the registry
rfdetr-s-512-fp32 fp32 fp32 — (source) 120,039,167 100.0% a59fc4174a77 v2.0.1-fp32 — byte-identical
rfdetr-s-512-fp16 fp16 fp32 onnx-graph-fp16 63,068,912 52.5% 29f91b5fa1a1 not registered — ORT refuses to load it
rfdetr-s-512-fp16-repaired fp16 fp32 onnx-graph-fp16 63,068,681 52.5% ccdddc438a3f v2.0.1-fp16 — byte-identical
rfdetr-s-512-int8-dynamic int8 fp32 ort-dynamic-quant 35,973,010 30.0% 5d478866ab42 v2.0.1-int8 — byte-identical

3 of the 4 rows reproduce, byte for byte, an artifact already in the registry — the join above is on the artifact hash, so if a rebuild stopped matching a release, this column would say so rather than the page needing an edit. The fourth, 29f91b5fa1a1, is unregistered on purpose: it is the fp16 graph the RF-DETR exporter's own default path writes, and ONNX Runtime refuses to load it. It is kept as a descriptor rather than deleted, because deleting it would hide the fact that the default invocation of that exporter still produces it.

The two fp16 rows are the same conversion and differ by 231 bytes. That difference is the repair — two degenerate Cast nodes removed and 35 Cast(to=FLOAT) nodes retyped, after which ORT builds a session. The gap between an artifact that loads and one that does not can be 231 bytes out of 63 million, which is an argument for addressing artifacts by hash rather than by name.

The second runtime now exists

Every row above is an ONNX graph. As of 2026-09-08 the same model also has two TensorRT engines, built by export.trt_build on an RTX 4070 and differing only in whether the TF32 builder flag is set — 118,940,300 bytes, sha 3b1e7817f7bd with it on, and 118,556,796 bytes, sha 9eb7a83f26fc with it off. Both are registered. The registry now holds one model across two runtimes, onnxruntime and tensorrt — which is the claim this section makes, demonstrated somewhere other than a dict entry.

They are not rows in the table above, and the reason is worth more than their absence. They were built from a different set of bytes. The matrix's source is sha a59fc417; the engines' recorded source is c055e87c, a re-export of the same checkpoint from the same machine and virtualenv five weeks later. Same exporter, same opset, same producer, 1,573 nodes against 1,570, bit-identical weights — and a different file. The three Cast nodes that differ are roughly 250 bytes and decide whether the fp16 conversion loads at all, while the ~350 KB that dominates the byte delta is moved file paths and shape annotations that change nothing. Byte size and behavioural risk run opposite ways here, which is the argument for content-addressing an artifact rather than trusting the recipe that produced it. Both stories are in Same recipe, different bytes.

What it cannot build, it says A row the script cannot build is reported as blocked with the reason, and one the planner refuses as refused — neither is dropped. A matrix that silently omitted what it could not build would be lying by construction.
Why engines are not descriptors A TensorRT plan is not produced by get_target → plan → convert; it comes out of a builder that takes an ONNX graph as input, so it is a consumer of this matrix rather than a row in it. It also cannot be hashed into identity the way a row can: one graph and one builder config produced three different plans and three different sizes, none bit-identical. The registry keys those on the recipe and the destination instead.
The claim, tested against a real gap yolo11n-640-fp16-io-fp32 describes an artifact ultralytics cannot produce: fp16 compute behind an fp32 boundary, because half=True moves the boundary with the interior. Reaching it took one field on one dict entry — no new script, no new branch. It is declared and not measured, and nothing has shipped from it.
What the matrix does not say That any of these artifacts makes the same decisions. It builds and it hashes. Whether the smaller file agrees with the reference is § 05, and the answer there is not reassuring.
§ 05 · Where the gate sits, and what it does

Node [5] is the parity harness. It sits on the branch, not on the spine. It reads the reference and the candidate, feeds both the identical preprocessed tensor, diffs every output element, then decodes both with the backend's own decode and counts how many detections cross the sidecar's threshold differently.

Then it prints a report and returns 0. That is the current state, stated plainly: it is an instrument, not a gate. Nothing in the drawing runs from [5] back to C3, and nothing refuses a promotion.

Default runreports raw-tensor divergence, a threshold sweep, per-image flips, and latency. Exit code 0 regardless of what it found.
Tolerancesthree pairs reported as buckets. No baked pass/fail tolerance — a tolerance is a judgement about acceptable risk on a particular input distribution, not a property of the conversion, and hard-coding one would launder a guess into a gate.
The gating flag--baseline <prior report> exists and exits non-zero on regression against that report, within a margin. It asserts no regression, which is a claim the harness can actually support.
Wired into CIA workflow exists. It has never run against real artifacts. .github/workflows/parity-gate.yml runs the harness against a stored baseline on a tag or a manual dispatch. Its inputs — the models bucket, an OIDC role, and the fixture corpus — are not configured, so it has produced no result, and nothing invokes it on a promote. The drawing is unchanged.
It fails closedMissing credentials or a missing fixture corpus make that job exit non-zero rather than skip, and it re-derives the 200-image manifest and refuses a corpus that yields a different count. A gate that goes green because its inputs were absent is worse than no gate — it produces a passing check with no evidence behind it.
What does run, on every push.github/workflows/ci.yml, on pushes to main and feat/** and on every pull request: the suite (294 pass, 3 skipped), a check that greps for a pytest.raises against each named refusal so deleting an assertion turns CI red, and a check that re-hashes every release record from its identity block. It deliberately does not hash the 36–120 MB artifacts; that belongs to the release gate.
Missing to close ita threshold placed by a human on this evidence. The harness deliberately refuses to pick one, because whether 112 flips and 19 mislabels per 200 images is acceptable turns on a person confirming every suggestion downstream — a product judgement, not a numerical fact.

What the instrument found the first time it was pointed at a real conversion, and why the cheap version of this check would have shipped the wrong artifact, is in What a conversion gate is for. The short version: an int8 candidate 30% of the size and 1.85× faster returned exactly the same detection count as the reference, and a quarter of the corpus came back different.

§ 06 · What the pipeline does not carry

Four gaps, in the order they would bite. None of them is hypothetical; each is a property of the code as it stands.

  • The serving contract has no precision field. ModelMeta carries version, model name, classes, class count, input shape, two thresholds, architecture and normalization — and no precision. Precision lives only in the version string and the S3 prefix, and the loader never looks at either. An int8 artifact at the fp32 key would serve, and nothing would record that it had changed.
  • There is nowhere to put a parity result. The sidecar has no field for the fixture set, the runtime, the execution provider, or the measured divergence. Today that evidence is a JSON file next to the artifact, joined to it by filename. It belongs on the release record — which is what the model card is a first attempt at.
  • "fp16" names two different artifacts. The YOLO exporter casts during export, so its graph takes float16 in and out. The RF-DETR exporter converts after export with keep_io_types=True, so its graph keeps a float32 boundary. Both stamp -fp16 into the version string, and nothing downstream can tell them apart from the name. The descriptors in § 04 now carry that boundary as its own field — it is the IO column — but the version string that reaches S3 and the sidecar still does not.
  • One inert field is published as advice. iouThresholdRecommended: 0.45 is carried in every RF-DETR sidecar and consumed by nothing — the DETR path runs no NMS. It exists so the sidecar satisfies a schema shared with the YOLO exporter. A field that looks like a tuning knob and does nothing is worse than an absent one.

The next lever is not a converter flag. Six post-training recipes hit the same wall, so closing the gap means quantization-aware training or a distilled student — which is also what the Pi 5 edge path, the second of the three destinations in § 01, is waiting on.