Skip to content

🛡️ Phase 13 — Contributor admission, augmentation guard and keypoint parity, v0.6.0

WP-145 — a boundary is cheaper to write than to discover

ADR-005 records an agreement between two repositories before either acts on it. No code moves here, and that is the point: every row in Phase 14 deletes a local implementation, and a deletion argued one call site at a time produces a boundary nobody ever stated and everybody assumed differently.

What the boundary actually divides is convention from policy. The first draft of the split was a list — fuse gets warping, letterboxing, the instance filter; this project keeps Targets, tiling, the recipe. A list settles the cases it enumerates and nothing else, and the cases that matter are the ones nobody thought of. The rule underneath it does settle them: fuse owns how a matrix is built, composed and sampled, and this project owns which numbers go into it. Read forward, that rule says a specification pushing an R1 Table S3 constant upstream is written wrong, and it says so without anyone having to have anticipated that specification.

Three capabilities argued their way to staying local, and the arguments are not interchangeable. Multi-image assemblies stay because they do not resample at all — mosaic pastes, mixup blends, copy-paste composites — so a package whose whole subject is collapsing repeated interpolation gains nothing by holding them. Polygon rings stay for an unrelated reason: they are ragged, clipping one at a canvas edge adds vertices, and every target the upstream package transports today is fixed-shape, so moving them would force a padded-tensor or list-of-tensors design onto the package to serve one consumer. Rotated boxes move, on the mirror of the ring argument — five numbers, fixed-shape, batch-leading, exactly the shape already transported, and oriented detection has its own literature rather than being a YOLO idiom. Three capabilities, three different reasons, and a list would have recorded only the verdicts.

The rings decision came with a condition that is worth more than the decision. Rings stay local because the composed matrix the upstream pipeline already returns is the same mapping it applied to the image, so applying that matrix to rings here makes the alignment invariant — a derived box equals a separately transported box on an unclipped instance — hold by construction rather than by agreement between two implementations. That holds only while the geometric run is a single fused segment. A pointwise barrier splitting it hands back one segment's matrix instead of the chain's, and the rings would then be warped by a partial transform with no error, no exception and no visible symptom beyond geometry that is quietly wrong. It is written into Phase 14 as a tier-C invariant for that reason: the failure mode is silence, and silence is what an invariant is for.

The battle-test contract is stated because neither side would otherwise hold it. This project's smoke tiers, goldens and frozen expectations are the real-project evidence the upstream package is tested against, and that evidence stays here. Written down, the exchange is symmetric — an engine with its own unit acceptance against four tasks, two datasets and published-number fidelity gates exercising it end to end. Unwritten, it is the thing each side assumes the other is doing.

Condition 1 of four is not this project's to satisfy, and is recorded as unconfirmed. A runtime dependency needs real SemVer releases on PyPI, because a git-URL requirement is legal in a dependency group and illegal in [project].dependencies — so a SHA pin cannot ship. That is an upstream commitment; this side can state the requirement and cannot meet it. An ADR that recorded it as settled would be recording a hope. The consequence of a refusal is bounded and worth saying: the augmentation stack stays local, which is where it is today, rather than the project becoming unpublishable.

The floor moved with the register. audit_docs_present.py ratchets on the decision count and asserts each ADR section by name, and both are deliberate edits rather than chores — the count catches a deleted last row that contiguity alone would find perfectly contiguous, and the name list is what makes a new ADR enforced rather than merely present. The floor had been left at D17 when D18 landed, so raising it to D19 here catches up one release of drift as well.

WP-146 — the pin was right and the register was wrong

This row was written expecting to advance a pin. It advanced nothing. pyproject.toml has read fedde0c1a482ac1add313fd823f6bc18e7b47436 since WP-132, and WP-132's own commit trailer cites that same commit; what WP-132 wrote into docs/PROVENANCE.md was 0a0cc64072b854936f3a15c36c21b5ed37012891, one upstream commit behind the thing it installed. The register and the manifest disagreed for eight work packages and one release, and nothing could have noticed: no gate reads the register, and both hashes name real commits of the right repository.

A pin bump that spans two upstream commits needs both named. WP-132 wanted SymbolShape for the pose overfit gate's kite slice, and SymbolShape is 0a0cc640 — so the register's prose is about the right feature while its hash is about the wrong commit, which is the failure mode that makes this kind of drift survive review. Reading either field alone gives a coherent story. Reading them together against pyproject.toml is what does not.

What LetterShape did to the fixtures: nothing, measured. The generator was installed at 0a0cc640, both generator-derived producers were run, and every value came back identical to the frozen goldens — 90 detection annotations, 895 segmentation points, 55131.455 box-area sum, 61 oriented annotations, and the ten data_pipeline_metrics values including the image mean and std sums. The generator was then restored to fedde0c1 and the producers re-run to confirm the restoration. So the whole of WP-132's re-freeze is attributable to 0a0cc640 — the obtuse-scalene TRIANGLE and the area-centroid re-centring, exactly as its register prose says — and none of it to the fourth shape family. No golden moves here, which is the correct outcome for a reconciliation row and would have been a defect in a bump row.

Neutrality was worth measuring rather than arguing. fedde0c1 widens the Shape union to 49 members and changes how class_id_of resolves ids against a narrowed category list, both of which are exactly the kind of change that reaches a fixture's category block. The argument for neutrality — that this project draws only DEFAULT_SHAPES, whose ids are a stable prefix — is a good argument and is not evidence. Installing the other commit and reading the numbers costs two installs.

The fixture module was describing behaviour it does not have. tests/fixtures/synthetic.py claimed the emitted COCO categories list "still names every animal fuse-augmentations ships even though none is ever drawn here". It does not, and did not at any pin this project has used: the detection fixture emits four categories, square, rectangle, triangle, circle. The claim is corrected here rather than left for a later row, because this row's whole subject is a register describing R21 wrongly, and a second document describing R21 wrongly a directory away is the same defect. Its cause is not LetterShape — the golden frozen at 74af9855 already carried four categories — so this is a claim that was never checked rather than one that went stale, which is the more useful thing for a later reader to know.

The general lesson is narrow and worth keeping: a register row carrying a hash is a manifest with no gate behind it, and the hash is the field that drifts, because prose gets re-read and a forty-character hex string does not. Where the same fact lives in two files and only one is executable, the executable one is the one to trust and the other is the one to check.

WP-147 — a seam is a move, not a rewrite

Every stochastic transform did two separable things inside one __call__: it drew parameters from a generator, then applied them. Only the second is the contract that has to survive the augmentation stack moving upstream, because the upstream sampler will not draw the same numbers from the same seed — different call order, different distributions, different consumption of the stream. Freezing an expectation against a seed would therefore break at the swap for reasons that have nothing to do with correctness, and the only available repair would be a re-freeze at exactly the moment the guard was supposed to hold. So the seam comes first, and everything frozen afterwards is keyed to stated parameters.

Seven transforms now carry it: RandomAffine (which had AffineParams and a private _sample already, and is the precedent the other six are modelled on), FusedAffineLetterbox, HSVJitter with a new HSVParams, HorizontalFlip with FlipParams, MosaicAssembly with MosaicParams, Mixup with MixupParams and CopyPaste with CopyPasteParams. Each gains a public sample() that is the only method touching generator, and a public apply() that draws nothing; __call__ is now literally self.apply(inputs, self.sample(...)). Letterbox needed nothing — it is deterministic and its forward_affine / warp_targets / inverse_map have been public since WP-009.

The draw code was moved, not rewritten. That is the whole discipline of this package. Every sample() body is the original draw sequence lifted verbatim: same helper, same order, same count. The guard for it is a temporary same-codebase equivalence test in tests/data/test_aug_seam.py — record the generator state, run __call__, rewind, run sample() then apply(), assert the two agree bit for bit on the image and on every target modality. Rewinding rather than re-seeding is deliberate: a path that consumed a different number of draws fails immediately instead of drifting into some later transform's numbers. That file is old-against-old inside one repository with no upstream package involved, so it is not the old-versus-new comparison the plan rules out, and WP-149 deletes it once the frozen tiers make it redundant.

Two params types are two-valued on purpose. MixupParams.lam is float | None and CopyPasteParams.selected is a possibly-empty tuple, because for both transforms not acting is a sampled outcome rather than an absence of parameters. A MixupParams that could only express a blend would make the pass-through branch unreachable through the seam, and the pass-through is the branch that must consume the trigger uniform and nothing else — consuming a blend factor there would desynchronise every later draw in the pipeline. Both branches are pinned by their own test.

CopyPaste was the one transform whose loop genuinely interleaved. It drew per candidate and pasted inside the same loop, so splitting it meant establishing that the draw count depends on the candidate count and the max_paste cap alone, never on pixels. It does, so sample(candidate_count) reproduces the loop's stream exactly and apply rasterises the named rings afterwards. The cap is what makes this worth a test of its own: a seam that drew once per candidate regardless of the cap would consume more of the stream than the original and pass every assertion about the pasted image.

One ordering did change, and only on an error path. Validation that used to sit between the draw and the application now sits after both — RandomAffine's rotated-pairing check, Mixup's and CopyPaste's same-size-images check. For any input that does not raise, the stream and the result are identical, which is what the equivalence test asserts. For an input that raises, the generator has advanced where it previously had not. That is a real difference and worth writing down rather than discovering later; it is accepted because a raising sample is an aborted sample, and no caller resumes a pipeline from the RNG state left behind by an exception.

The last_params / last_matrix / last_gains / last_center / last_lam / last_flipped attributes all still exist and are set by apply, not by sample. Putting them on the application half keeps them meaning what they always meant — what the most recent call did — including when a caller supplies parameters instead of drawing them.

WP-148 — the tier that proves rather than pins

tests/data/test_aug_contract.py holds twenty-two cases across two tiers and not one characterization value, which is the property that makes it reviewable: every expectation in it is derivable on paper from the transform's own definition, so a reader checks the arithmetic rather than trusting that a previous run printed this.

Tier A is the only tier that catches a bug rather than a change. A frozen output pins whatever the code does today, including whatever it does wrong; a derived expectation says what the code should do. Every case that geometry allows was written into this tier rather than left for tier B: an identity affine returns its input, an integer-pixel translation is an exact shifted copy, a quarter turn sends the box [1,2,3,6] to [2,1,6,3], a scale of two about the centre sends [2,2,4,4] to [0,0,4,4], a 10×20 source letterboxes at ratio 0.8 with four rows of padding, forward-then-inverse returns the points exactly, two mirrors compose to the identity including the keypoint permutation, a stated mosaic centre puts one box at four derived offsets, mixup at lam=0.25 on constants is exactly 0.25.

The quarter-turn case is worth the arithmetic it took. On an eight-pixel canvas the centre is (4, 4), and the rotation maps (x, y) to (4 - (y - 4), 4 + (x - 4)), so the corners (1, 2) and (3, 6) land on (6, 1) and (2, 3) and the envelope is [2, 1, 6, 3]. The image under the same rotation is not torch.rot90 of the input — it is rot90 in the other direction, rolled by one column, because an even canvas has its centre on a pixel corner rather than a pixel centre. That asymmetry is real and correct and would have been easy to write down wrongly as an expectation; the box, whose mapping is a matrix and not a resampling, is the honest thing to assert. Pixels stay in the cases where they are analytically forced.

Tier C constrains the modalities against each other, which is where a shape-compatible wrong answer hides. A translation that pushes one of two instances off the canvas must drop it from boxes, labels, polygons, keypoints and visibilities together; dropping it from boxes alone leaves a keypoint set supervising the surviving box with the departed instance's points, and every shape check still passes. The alignment invariant — a box re-derived from a transported ring equals the box transported separately — is the property the whole ring route will rest on once the warp moves upstream, and it holds only while both ride one matrix. Rotated boxes leave every transform in canonical long-edge form, because that is a task convention rather than a resampling property and has to be re-established after each warp. Out-of-canvas pixels are exactly the grey fill, not zero. Mixup concatenates both label sets unweighted, since the blend factor weighs pixels and never labels.

The keypoint invariant is the one that ties two transports together. A point marking a bright pixel must still mark it after the warp: an integer translation moves the pixel and the point by the same three-and-two, and the assertion reads the warped image at the warped point. A keypoint path using a different sign or a different centre convention from the image path would move the point off its feature while both outputs stayed well inside the canvas and every shape matched.

One case needed its parameters adjusted rather than its expectation: the first draft translated by fourteen pixels to strand one of two instances, and stranded both — the surviving box clipped to a one-pixel width and fell under the default min_box_size of two. Ten pixels leaves one instance intact. Worth recording because the invariant itself passed either way: with both instances dropped, every modality still agreed on zero, and the test was green while checking nothing. An invariant over an empty set is a case that cannot fail, which is the most expensive kind of passing test.

Coverage reaches every transform, including the ones staying local — HSVJitter, the assemblies, and A11's half-open rasterisation, whose ring spanning [1,4] covers three pixels rather than four. A transform that stays is still refactored around, and a guard written after the refactor guards nothing.

WP-149 — what a frozen value is allowed to mean

The guard's third tier is the one that pins rather than proves, and the discipline it needs is entirely about honesty. tests/data/test_aug_frozen.py holds nine cases, each stating explicit parameters, asserting a literal, and naming in its own docstring why no closed form was available. Where a closed form does exist the case is not here — it is in WP-148's contract file, and every case that could be moved there was.

The tier-A/tier-B boundary is a real line, not a filing preference. A rotation is a similarity: it maps a rectangle to a rectangle, so a rotated box re-fits exactly and its expectation is derivable, which is why the quarter-turn rbox case sits in tier A. A shear is not: it maps the rectangle to a parallelogram, and the re-fit is then a choice of enclosing rectangle rather than a recovery of the original. That choice is the implementation's, which makes the sheared case tier B and the rotated case tier A, from what looks like the same operation on the same modality. The letterbox split is the same shape of argument in a different place: the ratio and the padding total are arithmetic, but a leftover of seven and a half rows resolving to an offset of three rather than three and three quarters is a convention, and conventions are pinned rather than derived.

A tier-B docstring that cannot name its reason is a tier-A case in the wrong file. Writing the reasons out caught three candidates and removed them: a mixup blend at a stated factor on constant images is arithmetic, a ring clipped against a canvas edge is a clip, and a mirrored rotated box is a reflection with a negated angle. All three were computed, found derivable, and either moved to tier A or dropped. What survived is a composite affine's boxes and ring, a fused affine-and-letterbox, an off-centre mosaic where the clip meets the instance filter, a hue rotation across an RGB sector boundary, a sheared rbox, keypoints through a flip-pair swap composed with the warp, an odd letterbox split, and a rasterised triangle.

The triangle case carries its own arithmetic as a witness. The ring (2,2)-(10,3)-(6,11) has a shoelace area of exactly 34 and rasterises to 32 pixels. Neither number derives the other — the gap is the boundary rule, resolved one row at a time against the pixel grid — but writing both down means a reader can see the frozen count is close to the area rather than merely take it on trust, and a future failure at, say, 41 is obviously wrong rather than merely different.

goldens/aug_invariants.json takes what a literal would express badly. Fifteen scalar aggregates over the same stated-parameter runs: surviving instance counts and written pixel counts pinned exactly, image means and polygon areas pinned within a band. The split follows what each store is good at — the golden harness compares dict[str, float], so a box array flattened into box_0_x1-style keys would be a worse version of a pytest literal, while an image mean sitting in a test file would be a number with no tolerance and no home. Image expectations stay aggregate on purpose: the upstream engine samples at a different half-pixel convention, may execute through a different backend, and collapses several warps into one resample, so a byte-exact pixel expectation would fail at swap time for a correct implementation and the only escape would be a re-freeze under swap pressure.

Golden discovery was verified positively, not inferred. WP-139's lesson is that the harness globs, so it cannot notice a golden that was never written; a green run therefore proves nothing about a file's presence. check_goldens.py was run directly and printed PASS aug_invariants.json — 15 metric(s) and 31/31, up from 30. That is the check, not the green gate around it.

tests/data/test_aug_seam.py is deleted here as WP-147 said it would be. It existed to witness that __call__ equals sample() then apply() while the frozen tiers did not yet exist; now that tiers A, B and C all call apply with stated parameters, the seam is exercised by every case in all three files, and a witness that outlived its gap would just be a slower way of asserting the same thing.

WP-150 — the tier that is allowed to move

Six determinism assertions were scattered across five test files, each sitting beside the behaviour assertions for its own transform: TestDeterminism in test_affine.py, test_fused_warp.py and test_mosaic.py, TestMixupDeterminism and TestCopyPasteDeterminism in test_mixup_copypaste.py, and two seeded cases inside test_photometric.py's jitter and flip classes. All six now live in tests/data/test_aug_determinism.py, labelled tier D.

The move is not tidying. It is about what a failure will mean. Every assertion in the file is keyed to a seed, and a seed pins whichever sampler happens to be installed. A replacement engine will not draw the same numbers from the same seed — different call order, different distributions, different consumption of the stream — so these values moving at an implementation swap is expected and says nothing at all about correctness. Tiers A, B and C moving at a swap is a defect. Those are opposite verdicts from the same red test run, and the only thing that tells them apart is which file the failure is in.

Mixed in, the two verdicts share a keystroke. A tier-D failure at swap time reads as a regression, and the natural response — re-freeze it, the values were always going to move — is exactly the response that would silently destroy a tier-B guard sitting three classes above it in the same file. Separated, the response follows from the filename. This is the same argument the plan makes for never re-freezing an expectation in the same commit as a call-site swap, applied one level up: keep the thing that is allowed to move away from the thing that is not.

Two counterparts were added rather than only moved. Tier D is a two-sided property — same seed gives the same output, different seeds give different output — and only the first half existed. The second half is what catches a transform that ignores its generator and reads the global RNG: such a transform passes the equal-seeds case whenever the global state happens to line up, which under a seeded test suite is most of the time. That is not hypothetical here; WP-079 was opened by an augmentation RNG collapse. The flip case gained a related assertion, that sixteen draws at p=0.5 contain both outcomes, so a trigger stuck on one branch fails rather than agreeing with itself.

test_letterbox.py's TestDeterminism stayed where it is, and that is the useful distinction. It asserts that two calls on the same input return the same output — purity, not reproducibility. Letterbox has no generator, nothing about it is keyed to a seed, and it will not be re-frozen at any swap. A rescope that swept every class named TestDeterminism into the tier-D file would have moved it, and the name is the only thing the two have in common.

No value in the tier-D file appears in goldens/, and none should: reproducibility is a property of a run rather than a number worth carrying across releases.

WP-151 — the released task that could not leave PyTorch

0.5.0 shipped keypoints as the fourth task and shipped no way to export it. Detection, segmentation and oriented detection each had a graph class in lucid_yolo/export.py and a case in the ONNX gate; keypoints had neither, so it was the only released task whose model could not leave PyTorch at all. KeypointExportGraph closes that, and the gate now runs four tasks where it ran three.

The composition follows segmentation rather than detection, and the reason is structural. A detection graph calls the decoder's plain forward and is done. The point stem, like the mask-coefficient stem, is dense over anchors, so decoding it needs the source anchor of each detection row — which decode_with_indices returns and forward discards. That single fact decides the whole shape of the class: decode_with_indices for the detections and their anchor indices, decode_keypoints over the entire dense tensor, then a gather. Decode first, gather second, which is the order the OBB angle branch established at WP-062 and the training module's own val/oks_mAP already uses. The reverse order is not merely slower; decode_keypoints needs the whole anchor grid and offers no per-detection indexing, so gathering first would mean reconstructing each kept row's anchor and stride by hand — a second implementation of the thing the index already names.

Padding is where the two dense tasks part. A segmentation graph raises on a padding row: gathering mask coefficients at index -1 is an error, so that class documents a real precondition that the canvas carry more anchors than the detection cap. The keypoint gather does not have that precondition, because gather_keypoints clamps a padding index to a valid position and then zeroes the row, so a padding row yields the origin rather than the last anchor's pose. Reusing that function rather than writing a fresh gather is the point: WP-109's three-strides lesson was that a hand-copied padding rule drifts, and this is the third caller of the same rule rather than a third version of it.

The gate was extended, not duplicated. The keypoint case rides the existing fixture — the same doctored checkpoint, the same 128-pixel canvas, the same onnxruntime comparison against an eager decode of the checkpoint-loaded module — and adds one output-shape assertion and one parity assertion. The parity assertion carries a vacuity guard of its own, in the shape the existing mask comparison established: a gather at the wrong anchor still returns finite, in-canvas coordinates, so the point sets must be shown to differ from one another before comparing two orderings of them proves anything. Without it, a graph that returned the same pose for all 300 rows would match a reference that did the same.

K is deliberately three rather than seventeen. The point count is a constructor argument and the architecture is generic in it (A64); COCO's seventeen is one instantiation. A gate that only ever ran at seventeen would pass a shape accidentally baked to that number, which is exactly the class of defect an export gate is for. Three is small, is not COCO's, and puts (1, 300, 3, 2) in the asserted static output shape where a hardcoded seventeen would show up immediately.

WP-152 — the fall-through that answered plausibly

lucid-predict dispatched on the checkpoint's own task through three branches and a final else. That else was written as detection, with a comment explaining why it was not an elif task == "detect": an unknown task must be refused, and the refusal belongs to the library, which names the task the checkpoint actually carries. Sound reasoning, and it stopped being true the moment a fourth task existed. A keypoints checkpoint reached that branch, predict_image accepted it — the module's task was checked against "detect" and raised — so the command failed with a message about detection rather than silently answering. The failure mode was loud, then, but the capability was simply absent: 0.5.0 released a task with no shipped way to run one of its checkpoints on an image.

predict_keypoints closes it, and the composition is predict_segmentation's rather than predict_image's for the reason WP-151 gives for the export graph: the point stem is dense over anchors, so the selected decode path has to report the source anchor of every surviving row. _decode_with_points is _decode_with_coefficients with the point stem substituted, including the rule that matters most — detections, indices and the dense tensor all come from one branch, chosen in one place, so pairing the E2E decode's rows with the dense branch's points is unrepresentable rather than merely unlikely. That pairing would produce a plausible pose of a different object.

The letterbox inverse is applied once to a flattened point list. inverse_map takes (N, 2), and the points are (N, K, 2), so the instance and point axes are folded together for the call and restored after. Folding rather than looping per instance is not a performance choice: one call means one mapping, so no row can take a different inverse from its neighbour. This is also why the fourth entry point landing before the augmentation swap matters — Letterbox.inverse_map now has four consumers rather than three, so the delegation Phase 14 performs on that class is validated against four rather than re-validated later.

K is read from the tensor, everywhere. KeypointPrediction.num_keypoints reports keypoints.shape[1], the report writes it into info, and the empty-prediction path returns an empty stack that still carries the point axis — so a caller reading K off an image with nothing in it still learns the schema. The head's point count is a constructor argument (A64) and COCO's 17 is one instantiation; a function that assumed 17 would silently mis-shape a 15-point letter schema or a 12-point animal one.

The report gains a key rather than changing shape. keypoints: "xy-pairs" appears exactly when the records carry a point set, in the way boxes: "obb-longedge-rad" already appears only on oriented reports, so the three shipped report shapes stay byte-identical. The layout is named because COCO's own keypoint field is a flat triplet list carrying a visibility flag, and this one is neither flat nor triplets: nothing in this project predicts visibility, so writing a third column would be inventing a number. The stdout line prints the point count rather than the points — at K = 17 that is 34 coordinates on one terminal line, which nobody reads.

The skeleton is a caller argument with no default, and that is the honest answer. draw_keypoints draws each detection's box, its markers, and any edges the caller names. Which points connect to which is dataset metadata, exactly as the left/right flip pairs are: the head predicts K points and knows nothing about what they mean. Baking COCO's human topology in would make a 15-point letter skeleton or a 12-point animal one confidently, visibly wrong — a figure that looks authoritative and is not. With no skeleton the points are drawn alone, which is the correct figure for a schema nobody has named. The marker radius is fixed rather than scaled to the box, because a pose figure is read by whether a point sits on its feature, and a marker that grew with the object would hide exactly the small-object error worth seeing.

The dispatch test is parametrised over all four tasks with all four entry points stubbed, so a wrong dispatch shows up as another stub recording the call. Its keypoint case is the one that did not exist before this row, and it is the test that would have caught the gap a release earlier had the parametrisation been written to the task list rather than to the three branches that happened to be there.

WP-152b — thirteen rows become six, and the ratchet earns its keep

Phase 14 was written as thirteen rows because that is how the migration plan decomposes: one row per upstream capability absorbed, one per class deleted, one per closing sweep. Regrouped to six at the principal's direction — five work packages and the release.

What merging rows changes, and what it does not. It changes what one commit contains: a row is now "this much of the engine moved" rather than "this one function moved", so each commit is larger and each groups swaps sharing an upstream capability and a test surface. It changes nothing about the FA-n dependencies. Those name releases the other repository has to cut before anything here can move, and merging two local rows does not merge two upstream releases — WP-154 still cannot start before FA-1, FA-2 and FA-3 have all shipped, where the old WP-154 needed only FA-1. The cost is real and worth naming: a coarser row waits on more upstream work before it can begin, so the phase has fewer places to make partial progress.

The one rule that constrained the grouping. A frozen value may never move in the same commit as a call-site swap, because that combination is indistinguishable from adjusting the test until the new code passes. Thirteen rows kept the re-freeze naturally alone. Six could easily not have: the obvious grouping — "swaps" and "sweep" — puts the export-list collapse, the docs rewrite and the golden re-freeze together, which is fine, but a tempting alternative folds the re-freeze into the last swap row to save a commit. It is not folded. WP-158 holds the re-freeze and no swap at all, which is the property that made the four-tier guard worth building.

Y-n codes are unchanged and now map several to a row. The plan file stays the plan of record and its codes stay stable across this renumbering, which is exactly what §12 #6 wrote them down for: a stable cross-reference survives a roadmap that moves. The mapping is no longer one-to-one, and that is a property of the roadmap rather than a defect in the plan.

The floor is why this edit is legible at all. _WP_FLOOR asserts the roadmap never shrinks, on the reasoning that dropping the last row leaves the remainder perfectly contiguous — so contiguity cannot notice a deletion, and only a count can. The roadmap shrank from 166 numbered rows to 159 here, which is precisely the event the ratchet exists to force into the open, and it did: the floor had to be raised to 159 as a deliberate edit before the gate would pass. Worth recording that the floor had been sitting at 129 since long before Phases 13 and 14 added their rows, so it had a great deal of slack and would not in fact have caught this shrink. It has none now.

The export-count correction rides here rather than in the release, because this commit rewrites the cell that carried it: data/__init__.py re-exports 55 symbols, not the 51 the old row 163 claimed. The real count was 50 when that row was drafted — a miscount of one — and WP-147's five parameter dataclasses took it to 55.

WP-153 — the guard is published before anything it guards moves

0.6.0 releases a regression guard and a task's deployment parity. It adds no model, no loss and no evaluation protocol, which makes it the first release on this train whose subject is entirely the ability to detect a future defect.

The release order is the whole argument for the phase split. Every row in Phase 14 deletes a local implementation and replaces it with an upstream one, and what authorizes each of those deletions is the four-tier expectation suite frozen here. A guard that shipped in the same release as the swaps it authorizes would be a guard whose own correctness was only ever demonstrated against the code it was written to replace. Publishing it first means the version a swap is measured against is a released one, reachable by tag rather than by commit range.

What this release does not claim. The contributor-admission layers are not built: WP-141 through WP-144 stand at ⬜, WP-141 being a principal row with three preconditions outside the roadmap — O3 cleared, the repository made public, WP-125 accepted. So the phase ships its release with four rows trailing, which is not new; 0.5.0 did the same with WP-125. A68, A69 and A70 remain open and each still names an experiment nobody has run. No trained weights ship (D14), and the guard is offline arithmetic on synthetic geometry rather than any statement about accuracy.

The freeze is larger than 0.5.0's by exactly one file. make freeze-goldens MINOR=0.6 copies the live metric sets into goldens/frozen/0.6/; the new one is aug_invariants.json, whose first freeze this is. It is worth naming which of the four tiers reaches the frozen set and which does not: the golden carries tier-B scalar aggregates, tiers A and C live in pytest literals and are frozen by being written down, and tier D deliberately reaches neither — its values pin whichever sampler is installed and are re-frozen at every swap, so freezing them into a release snapshot would state a cross-release regression contract over exactly the numbers that are expected to move.

One stale claim corrected at the source. README.md said the export gate asserts the NMS-free property "on all three tasks". It runs four as of WP-151, and the sentence is the project's own headline architectural claim, so it is the one place where an off-by-one count is a claim about the deliverable rather than about the documentation. The keypoint model card needed nothing: its "the other three tasks" phrasings are comparisons, and comparisons of one task against three others stay correct at four.

No tag is cut here and no distribution is published, as at 0.4.0 and 0.5.0. release.yml fires on a pushed v0.* tag and re-runs the guard, which refuses a tag whose changelog section is missing or whose gate is red; both are the operator's to trigger. v0.4.0 and v0.5.0 are also both still uncut, so the train has now shipped three release commits ahead of its own tags.

WP-153b — the phase is unblocked, and three of its rows described upstream wrongly

fuse 0.12 landed every capability Phase 14 waits on: FA-1 through FA-5, FA-7 and FA-8, with FA-6 and FA-9 declined exactly as the plan agreed. The phase is unblocked. Validating it against the rows that consume it turned up more than a green light, which is the reason to validate before implementing rather than during.

Two capabilities arrived better than they were specified. FA-7 imposes no canonical form on a rotated box — no w >= h swap, no angle range — and takes canonicalize= as a caller callback instead, on the reasoning that the long-edge convention belongs with the assigner, loss and evaluation kernel that share it. That is A22's argument, reached independently upstream, and it is what keeps WP-157's design intact. FA-8 decides when to permute keypoint pairs off the sign of the composed matrix's determinant rather than off whether a flip appears in the pipeline, and applies it on every routing path including the two that never build a warp grid. Two mirrors compose back to a rotation that must not swap, and a rotation past 90 degrees looks like a flip while being none; the local unconditional swap gets both wrong, so this is a capability that arrives stronger than the code it replaces.

Three rows named upstream mechanisms that do not exist. WP-154 planned to pin align_corners=False in the frozen suite, "which fuse currently does not" use. 0.12 declined to add the flag at all and measured the equivalence instead: it samples with align_corners=True against the normalization sandwich derived for that same flag, so the convention cancels and the pixel-space map is the one an align_corners=False implementation produces for the same matrix, verified against an independently constructed reference to atol=1e-5. There is nothing left to pin, and the residual that does survive is a different quantity — the composition centre, ((W-1)/2, (H-1)/2) upstream against (W/2, H/2) here. WP-156 attributed geometric fusion to clip_policy="final"; clip_policy decides when a fused colour chain clamps and shares nothing with geometry, a confusion upstream anticipated well enough to warn against by name in its own docstring. Fusion is adjacency-driven, so a colour operation between two geometric ones splits the run and return_matrix then reports the last segment alone, silently — which makes that row's tier-C single-segment invariant more load-bearing than when it was written, not less. WP-157 planned to delete rotated_aug.py; upstream refuses to clip a rotated box on the grounds that the clipped shape is a polygon and not a rotated box, so clip_rboxes_to_canvas has nowhere to go, and check_rotated_pairing validates a local Targets invariant that was never a resampling concern. The module shrinks and does not vanish.

What a green gate would not have caught. None of the three errors is detectable by any check this repository runs. They are prose in a scope cell, describing a repository the gate never reads, and each would have surfaced as an implementing agent reaching for a parameter that is not there — the same failure mode as WP-139's golden that was never written and WP-146's register that named the wrong commit. The pattern is consistent enough to name: what the gate reads is safe, and what it does not read drifts silently.

Three decisions the measurement forced, taken by the principal. The first is the pin. fuse has no tags, local or remote, and PyPI's latest is 0.10.0.dev0, so 0.12 exists as a pushed bump commit rather than a release; the phase proceeds on a commit pin, and v0.12.0 is tagged from that same commit once the roll-out here is clean. That inverts ADR-005's step order and is deliberate — this project is fuse's first real consumer, so the tag follows the integration that proves it, which is the reciprocal battle-test contract rather than an exception to it. The second is the letterbox. The geometry matches exactly, verified in both directions on integer arithmetic, but the pixels do not: this project resizes with an antialiased bilinear interpolate, upstream runs one grid_sample with antialias off by default and a Gaussian mipmap prefilter when on, so any downscale differs and Letterbox is the inference preprocessor for six evaluation and prediction call sites. The delegation proceeds, with the eval delta measured first and re-frozen in a row of its own; keeping the local resize would have kept a second implementation of the package's whole subject, which is what the migration exists to stop. The third is the mirror axis: upstream reflects about (width - 1) / 2 and this project about width / 2, so every mirrored centre moves a pixel. Upstream's axis is adopted and the convention change recorded, on the reasoning that one convention across image and coordinate transport is worth a re-freeze and two conventions inside one pipeline are worth nothing.

What this row deliberately does not do. No DoD moves and no row splits — WP-153c does both. WP-154 needs splitting — the pin bump moves the synthetic fixtures, because upstream redesigned the triangle from obtuse-scalene to equilateral and replaced the minimum-area OBB derivation with an upright-frame one, and triangle is 25 of 90 detseg and 17 of 61 obb annotations against goldens whose tolerance is zero — so a re-freeze rides with that bump and must not ride with a swap. WP-155 and WP-157 need their DoDs rewritten to match the two decisions above. All of that is a row edit rather than a correction, and it follows rather than accompanies the correction of what was simply wrong.

WP-153c — the rows split so that no commit both delegates and re-freezes

WP-153b corrected what the Phase 14 rows said about fuse and deliberately left the structure alone, because the structure depended on decisions that were still open. They closed, so this row does the splitting. Six rows become eight, and the single rule that produced every split is the one §6.5 already stated: a frozen value must never move in the same commit as a call-site swap, because the two are indistinguishable afterwards from adjusting the test until the new code passes.

Applying that rule strictly turns out to require moving conventions early rather than late. Two of the frozen values Phase 14 touches move for an honest reason — this project adopts upstream's composition centre and upstream's mirror axis, so literals that encode (W/2, H/2) and width / 2 are simply describing a convention the project no longer holds. The tempting arrangement is to let them move with the delegation that motivates them, which is exactly the arrangement the rule forbids. The workable one is to change the convention first, in the local implementation, where the literals move alongside a one-line arithmetic edit that anyone can read, and to let the delegation that follows move nothing at all. WP-154b is that row. It buys something beyond bookkeeping: after it lands, WP-155, WP-156 and WP-157 can be held to the strict reading — any tier-A, B or C value that moves inside one of them is a defect, and the row that moved it is where the defect is — which is a claim the earlier structure could not have made about WP-157.

WP-154 splits along the same seam. The pin bump is not a swap and its re-freeze is not a choice: upstream rebuilt the synthetic fixtures, and the goldens they feed have zero tolerance. WP-154 carries the bump, the two loud breaks it causes, and that re-freeze, with nothing delegated; WP-154b carries the conventions and the FA-1, FA-2 and FA-3 assertions. The loud breaks are worth naming because they are the pleasant kind. tests/fixtures/synthetic.py imports animal_shapes, which upstream's restructure removed, so the pin bump fails at collection rather than at some later assertion; the replacement is tuple(AnimalShape)[:KEYPOINTS_ANIMAL_COUNT], which is what the helper did, and the other three imports from that package are untouched. The fixture drift is louder still — a golden with zero tolerance cannot drift quietly. The discipline the row owes is not detection but tracing: every changed value is matched to a named upstream change before it is re-frozen, and one that cannot be matched is a regression rather than re-freeze fodder.

The letterbox gets a row because its failure is the quiet kind. Decision 6 delegates the resize and asks for the eval delta to be measured in a row of its own, which WP-155b is. The geometry matches exactly and the resampling does not, so every coordinate round-trip test stays green while image-derived metrics move on the evaluation path across six call sites. A delta that hides behind a green suite has to be measured on purpose or it is not measured at all; the row records the number whether or not it is zero, since "nothing moved" is a finding and an absent measurement is not.

Three DoD lines were added for traps that fail silently, each from §8.12's measurement. Upstream's instance_keep_mask defaults are 0.0 and 0.0 against this project's 2.0 and 0.1 — deliberately, on the reasoning that a library should not encode a plausible-looking minimum on the caller's behalf — so an omitted argument at any call site keeps every instance including ones pushed entirely off canvas, with no exception and no shape change. WP-155 now asserts both thresholds at every call site. return_matrix is None for exact-only and letterbox-only pipelines, not merely partial for split ones, so WP-156's consumer check is two conditions rather than one. And permute_keypoint_pairs moves coordinates without touching visibility flags, so WP-157 keeps the local keypoint_vis permutation instead of deleting _mirror_targets outright; dropping it would leave every mirrored sample with a visible point marked occluded and its partner marked visible, shapes identical throughout.

One claim was retired rather than moved. WP-158 said it re-freezes the tier-D determinism values against the now-upstream implementations. Tier D has no values: the module asserts same-seed-equal and different-seed-differ and nothing else, holds no literal, and appears in no golden — which was the point of separating it, since a seed-dependent frozen number would break at every swap for reasons unrelated to correctness and invite precisely the re-freeze-under-pressure the guard exists to prevent. So there is nothing there to re-freeze, and the row now says so. The sentence had been carrying an implication the design had already ruled out.

And the scope prose moved out of the table. Phase 14's cells had grown to three times the register's median length — around 1,500 characters where 374 is typical — because each row carries a boundary that is easy to cross by accident, and stating where a large swap stops takes paragraphs rather than a clause. A roadmap table is a register, not a specification, and prose that wide stops being readable as either. The full text now lives in Phase 14 — what each row does, and where its boundary is, one anchored entry per row, and each cell keeps a summary and a link. Nothing was dropped in the move; the entries are the cells' own words with room to breathe.

What this row does not do. It writes no code and moves no value. It also does not reorder the phase's dependencies beyond threading the new rows into the existing chain, and it does not revisit either decision it implements — both were the principal's, and this row is their mechanical consequence.

WP-141 — the guide is written; the condition it waits on is not this row's

docs/CONTRIBUTING.md, three issue templates, and the sentence in AGENTS.md that D18 was always going to flip. The file sits under docs/ rather than at the repository root deliberately: docs/ is one of the three locations GitHub resolves CONTRIBUTING.md from, and it is the only one that is also inside the mkdocs tree, so a single file satisfies the platform and the docs-site audit at once instead of a root copy drifting from a nav copy.

What the row could not do, and did not pretend to. D18's relaxation is conditional on two things: the reproduction report carrying all four accepted tiers, and the repository being public. The first is satisfied — the fourth tier was accepted at WP-125. The second is not: O3 is still blocked-on-principal in the open-items register, and its own deadline is "before the repository or any tag is public", so it is precisely the thing standing in the way. AGENTS.md used to say "Until WP-141 lands, the contract above applies in full", which names the wrong gate; it now names the repository's visibility, because that is what actually decides. The relaxation is written down and gated rather than in force, and no row here changes that.

Three layers, and the table saying what each one is not. The guide's central table is the part worth defending: DCO establishes ownership and says nothing about derivation; the licence attestation covers derivation and produces nothing reviewable; the provenance trailers produce the reviewable artifact and say nothing about the right to submit. Each column of "what it does not" is what justifies the next layer existing, and a contributor reading only the middle row would reasonably conclude that a checkbox is the whole system.

The clean-room rule is stated at its real width. WP-143 widens the pull-request template; the guide states the same thing in prose, because the template is a checklist and a checklist is a poor place to learn a rule. Ultralytics is enumerated as the instance a YOLO contributor reaches for by reflex, not as the definition, and the guide says explicitly that a contributor who has already read one should say so rather than conceal it — an undisclosed derivation is the only version of this that cannot be repaired.

No unverifiable link ships. config.yml would naturally carry contact_links to the guide and the security advisory form, both absolute URLs into a repository that is not yet public, so neither could be fetched and checked before shipping. Rather than ship two links on the assumption they will resolve later, the file carries none and the pointers moved into the templates as prose. The one external link in the guide, the Developer Certificate of Origin, was fetched and its clause (a) read before being cited.

WP-142 — a sign-off is a representation, not an identity proof

scripts/lint/check_dco.py and a dco job in lint.yml. Every non-merge commit in a pull request must carry a well-formed Signed-off-by: trailer naming a person and an address that reaches them. Merges are excluded because their message is generated by whoever pressed the button rather than written by the contributor, so there is no representation in it to check.

The design decision worth defending is what the check refuses to do. It does not require the sign-off address to match the commit author's, which is the first thing a stricter implementation reaches for. A DCO sign-off is a representation by the signer: co-authored work legitimately carries several, an author committing under an employer address may sign under a personal one, and a maintainer applying a contributor's patch signs their own line beside the original. Author-matching rejects all three and catches nobody willing to type a false name, so the check enforces the shape of the statement and leaves its truth where the DCO itself puts it. A test asserts the non-matching case passes, so the decision is pinned rather than merely intended.

Malformed is reported as malformed, never as missing. A second pattern matches lines that mean to be a sign-off — a missing address, a bare address with no name, a domain without a dot, Signed off by, a trailer without angle brackets — so the report distinguishes a one-character fix from a contributor searching for a trailer they believe they already wrote. Five near-miss shapes are parametrized, and the refusal text names the flag that fixes it.

Pull requests only, and this repository is the reason. Its own history predates the requirement — running the check against main would refuse every existing commit rather than any submitted one, which is a gate that fails at everything and therefore protects nothing. Verified rather than assumed: run over HEAD~1..HEAD it refuses WP-141's own commit, which is the correct answer and exactly why the job is conditioned on pull_request.

Two smaller things. The job installs nothing, the checker being standard-library only, so layer one costs a container start rather than a dependency resolve. And the two SHAs reach the command through the environment rather than the command string: they are interpolated from event data, and a value reaching a shell directly is how that becomes an injection rather than a range.

WP-143 — one instance of a rule, stated as though it were the rule

The pull-request template's clean-room line read "no denylisted source (Ultralytics repo, mirror, package copy, or docs.ultralytics.com) was consulted". Every word true, and the rule it enforces is much wider: D13's admissible set is the permissive allowlist — MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC — so what a contributor attests to is not having copied from anything outside it. A contributor reading the old line would reasonably conclude a GPL detector was fair game, which is not a misreading; it is what the line said.

The checklist now enumerates copyleft (AGPL, GPL, LGPL, SSPL), source-available (BSL, Elastic, PolyForm), paid or proprietary source of any kind, and any source whose licence cannot be read — D17's case, where unreadable is not permissive. Ultralytics stays, moved to the end and labelled as the instance a YOLO contributor reaches for by reflex rather than as the definition. A second item asks for the sign-off WP-142 checks, so the template and the CI job agree about what a pull request owes.

The row's real deliverable is that it cannot narrow again silently. A checklist is prose a maintainer edits, and the failure mode is not a wholesale revert — which is obvious — but one family falling out of a rewrite. check_pull_request_attestation_covers_the_allowlist in audit_docs_present.py pins each family and both clauses, so a dropped SSPL fails the gate naming SSPL rather than passing as an attestation that still looks thorough. That the previous line survived eight months while meaning all of this is the argument for gating it rather than trusting the next edit.

Two notes on the check itself. It takes docs_dir and discards it, so it matches the signature every other check in that module carries and _CHECKS stays a plain tuple rather than growing a dispatch. And the two non-licence conditions — "proprietary", "cannot be read" — are matched as substrings against the lowercased text, because neither is a licence name and both are exactly the kind of clause that falls out of a rewrite unnoticed.

WP-144 — the layer that produces an artifact, and the contradiction it exposed

A trailers job in lint.yml runs check_commit_trailers.py --range over a pull request's own commits. This is the only admission layer producing something reviewable rather than a promise: a contributor must name what an algorithmic change derives from, so an unnamed derivation on a loss or an assigner is visible at review instead of after release.

ci-tests.yml's exemption was right and too wide. Its comment said the trailer contract is a local pre-commit check "because a squash merge replaces the messages this workflow would have validated", which is true of the merge result and false of the submission. Checking the squashed commit validates a message nobody wrote; checking the contributor's own commits validates the ones review actually reads. The comment now says which half it covers rather than reading as a blanket exemption.

Wiring it up exposed a contradiction between the checker and the guide. WP_RE required WP: <digits>, so every commit needed a roadmap row — while D18, and the guide WP-141 had just written, say a change altering no shipped behaviour, no public symbol, no golden and no documented assumption lands without one. Enforced as written, CI would have demanded a row id from exactly the contributors D18 exempts, and the first person to hit it would have been told to invent a number. WP: now also takes the literal none.

none rather than an omitted trailer, and the distinction is the point: it matches how Assumptions: states its own absence, and it makes the message say no row applies instead of leaving a reader to decide whether one was forgotten. Omission is still refused, and so is free text — a test pins each. The other three trailers stay mandatory, because the derivation question does not go away because the tracking did.

The range path had no test at all before this row, in either checker: validate_message was covered and the git walk under it was not. Both now assert that an empty range is clean — a pull request selecting no commit is not one that failed to cite — and the trailer checker additionally validates this repository's own last ten commits, so a regression that let a real message fail surfaces here rather than on a contributor's first pull request.

With this row Phase 13's four-row tail closes. Every numbered row in the roadmap is now ✅; what is not done is not a row — the repository is still private, and O3 still stands between here and the relaxation these four layers were built for.