Skip to content

🤝 Contributing

This is a clean-room reproduction. That single fact decides almost everything below, so it is worth stating plainly before any process: the value of this repository is not that the code works — it is that the code's lineage is known. A patch that improves a number while making its own derivation unclear costs more than it adds, because it converts a traceable artifact into an ordinary one.

So the contribution process asks for two things that most projects do not: where your change came from, and your representation that you had the right to submit it. Everything else here is the usual: run the gate, write the commit, open the pull request.

🧭 Before you write anything

Never read a reference implementation of YOLO. Not the Ultralytics repository, not a mirror, not a vendored copy inside another package, not docs.ultralytics.com. This is the prime directive and it has no exception for "just to check". The admissible sources are the papers and the small set of permissively licensed implementations enumerated in PROVENANCE.md, each admitted by name under D13 and ADR-004, and each admitted for diagnostics rather than for method.

The rule is broader than its most famous instance. What you must not copy from is any source that is copyleft (AGPL, GPL, LGPL, SSPL), source-available (BSL, Elastic, PolyForm), paid or proprietary, or whose licence cannot be read at all (D17). Ultralytics is enumerated because it is the one a YOLO contributor reaches for by reflex, not because it is the definition.

If you have already read one, that is not a disaster and it is not something to conceal — say so in the pull request. What cannot be repaired is a derivation nobody knew about.

🚪 The three admission layers

Each layer answers a different question, and none of them answers the other two. That is why there are three rather than one.

Layer Mechanism What it establishes What it does not
DCO sign-off Signed-off-by: on every commit a person authored, checked by the DCO GitHub App Ownership and right to submit — your own representation, on the record, per commit Nothing about what the work derives from
Licence attestation The clean-room checklist in the pull-request template That you did not copy from a non-permissive source Nothing reviewable — it is a promise, not an artifact
Provenance trailers WP:/Provenance:/Assumptions:/Gate: in the pull request's title and description, validated by a job in this repository What an algorithmic change is derived from, in a form a reviewer can check against the register Nothing about your right to submit

The third is the only one producing a reviewable artifact rather than a promise, which is why an unnamed derivation on a loss or an assigner becomes visible at review rather than after release. The first two are cheap and they are still worth having: a promise on the record is a different thing from silence.

Signing off

Add -s to your commit, or write the trailer yourself:

git commit -s -m "fix(data): ..."

The line must name you and reach you, at the address the commit is authored under: Signed-off-by: Ada Lovelace <ada@example.com>, on a commit whose author is ada@example.com. It means you agree to the Developer Certificate of Origin — that you wrote the patch, or have the right to pass it on. The check is the DCO GitHub App, which posts a DCO status on every pull request: it reads every commit, not just the last one, skips merge commits and commits an automation authored (pre-commit.ci's autoupdate, a dependabot bump), and fails the status when a sign-off is missing or names an address other than the author's. The address rule is the app's, and it is stricter than the check this repository ran itself until the app was installed — that one accepted any well-formed sign-off, on the argument that a sign-off is a representation rather than an identity proof. Two checks with two rules on one line was the worse state, so the in-repo one is gone; if you commit under one address and sign under another, the app will tell you which commit to amend.

The sign-off is checked on pull requests and nowhere else, and there is deliberately no local commit-msg hook for it — unlike the provenance trailers, which have one. The reason is history: this repository's own commits predate the requirement and carry no sign-off, so a local hook would refuse to let a contributor rebase, amend or cherry-pick any of them, and a push to main has no contributor range to check in the first place. The practical consequence for you is that a missing -s surfaces on the pull request rather than at commit time, so it is worth making -s automatic in your own clone — Git has no config switch that adds it (format.signOff applies to git format-patch, not to git commit), so an alias such as git config alias.ci "commit -s" is the usual way — rather than discovering a fifteen-commit branch needs a rebase.

The provenance trailers are checked on the pull request's title and description, not on its commits. This repository merges by squash only, so a branch's commits never reach main; the one commit that does takes its subject from the title and its body from the description, and that is the message the squash-message job in pr-message.yml validates — title as the Conventional subject, description as the body with the four trailers, co-authors after a --- line. Edit either and the check re-runs. Your own commits are yours to shape; the local commit-msg hook still validates them for the commits you keep on your own branches. A pull request an automation opens (pre-commit.ci, dependabot) is skipped on the same reasoning as the sign-off: a pin bump has no derivation to name, and the maintainer writes the landing message.

📐 When a change needs a work package

Most of this repository's history is one work package per commit: a numbered row in ROADMAP.md, a definition of done, a green gate, and a commit carrying provenance trailers. That contract is a reproduction instrument, not a permanent process (D18), and it has an end condition.

Once the reproduction report carries all four accepted tiers and this repository is public, a change that

  • alters no shipped behaviour,
  • adds or removes no public symbol,
  • moves no golden, and
  • changes no documented assumption

lands as an ordinary gated commit. A typo fix, a clearer docstring, a test that covers an existing branch: open a pull request, no roadmap row needed.

Everything else still opens one. And adding a new task to the model family — a fifth alongside detection, segmentation, oriented detection and keypoints — restarts the full procedure: its own phase, numbered work packages, a smoke tier, a principal gate and a 0.MINOR release. A new task is a new reproduction claim, and a claim is what this contract exists to substantiate.

The gate never relaxes, under either regime. That is the part D18 does not touch.

✅ Running the gate

make setup        # create .venv and install the dev dependency group
make gate         # every linter, strict typing, the full offline suite, the offline goldens
make gate-gpu     # the other half — needs an accelerator; not part of the merge bar

make gate is the merge bar and it runs offline. It must be green locally before you open a pull request — CI re-runs it, but a red gate discovered in CI is a round trip that costs a reviewer's attention for something your own machine could have told you.

What green does not mean

make gate is a structural verdict. Read it as a quality signal and you will ship the exact class of defect the 0.8.0 audit was opened for — an assigner whose one-to-many localization objective was annihilated at 4.12e-09 while every offline golden passed.

  • It pins no learned-quality number. The accuracy floors — the per-task overfit_micro gates whose figures the model cards quote — live under goldens/gpu/, and scripts/check_goldens.py deliberately keeps that subtree out of default discovery: its producers retrain a model on a local accelerator. make gate never passes --include-gpu. What it verifies is that the architecture, the assignment cases, the optimizer toy problem and the frozen per-release goldens still reproduce — not that the models detect anything.
  • The accelerator half has a schedule, and the schedule may be inert. make gate-gpu runs the gpu- and data-marked tests plus the goldens/gpu/ floors. In CI that is .github/workflows/gate-gpu.yml, nightly plus manual dispatch — but the job carries if: vars.GPU_RUNNER_LABEL != '' and runs on that same label. With the repository variable unset, which is its state on any fork and on any checkout without a self-hosted CUDA runner, both triggers resolve to a visible skipped job. That is deliberate (a queued job against a label no machine answers to is worse), but it means a green Actions tab is compatible with the accelerator gate never having run. If your change touches the loss, the assignment, the decode or the optimizer, run make gate-gpu on a machine that has an accelerator and say in the pull request that you did.
  • Its data is synthetic by policy. Every offline test runs against micro-datasets generated by fuse-augmentations, never against COCO or DOTA (A26 / D12b) — the suite is offline by design and downloads nothing. The consequence is that the gate exercises the code on data this project drew, so a defect only real annotations elicit is outside its reach. Real data enters at tier acceptance and nowhere earlier.

Two failures deserve a specific response rather than a retry:

  • A golden moved. Do not re-freeze it. A frozen expectation may change only in a change whose subject is that expectation, with the reason recorded in ENGINEERING_LOG.md: what moved, from which value to which, and why the new value is correct. A frozen value moving in the same commit as an implementation swap is indistinguishable from "the new code disagreed and we adjusted the test", which is why it is refused. Two such moves are on record, both on goldens/frozen/0.6/aug_invariants.json and both principal-authorized under escalation trigger 4 (ESCALATION.md, 2026-09-03); neither is a precedent.
  • An assumption is in your way. The register in ASSUMPTIONS.md records every place where the papers underdetermine the implementation. If your change depends on reading one differently, revise the row and say why in the same change. Do not work around it silently — the register being complete is the point.

✍️ The commit message

<type>(<scope>): <detail>          # <= 72 characters

<body: what changed and why, in prose>

WP: 160                            # the roadmap row, when there is one
Provenance: R1 sec. 3.2.1          # at least one source id from PROVENANCE.md
Assumptions: A22, A40              # A-ids touched, or `none`
Gate: tests/data/test_x.py::test_y # what proves the definition of done

type is one of feat, fix, test, ci, docs, chore, perf, refactor, refine, exp, release. Trailers are validated against the register, so a Provenance: id that does not resolve fails the check rather than the review.

Under D18's relaxation a change with no roadmap row writes WP: none — the literal word, matching how Assumptions: states its own absence, so the message says no row applies rather than leaving a reader to decide whether one was forgotten. The other three trailers stay mandatory: the derivation question does not go away just because the tracking did.

Reworking a message across a range rewrites history, and the safe way to do that is to take a backup-* or backup/* ref first. Those refs are local and are never pushed, so nothing in CI or on the remote will ever tell you they have gone stale — delete each one by hand (git branch -D <name>) once the rewritten branch is confirmed, in the same sitting. Left alone they accumulate silently, and a clone that carries a dozen of them offers a dozen plausible-looking answers to "what did this look like before", only one of which is the branch you actually want.

📓 Adding a notebook

A notebook here is a Python file. Write notebooks/<name>.py in jupytext's percent format — # %% opens a code cell, # %% [markdown] a prose cell — and the toolchain does the rest: make notebooks converts it to docs/notebooks/<name>.ipynb, the site renders that file unexecuted, and the Docs workflow publishes it with the built site onto the gh-pages branch on push to main, at notebooks/<name>.ipynb, which is where the Colab badge in the title cell opens (D25). The .ipynb is never committed: docs/notebooks/ is gitignored and the no-ipynb-tracked hook refuses one that reaches the index anyway.

Four things every source carries, in this order: the SPDX header (license-headers scopes notebooks/); a title cell with the badge, https://colab.research.google.com/github/Borda/lucid-YOLO/blob/gh-pages/notebooks/<name>.ipynb; a bootstrap cell guarded on "google.colab" in sys.modules whose clone and install are shell lines — !git clone ... and %pip install -e lucid-YOLO, written into the .py by jupytext as # !git clone ... and # %pip ..., with the os.chdir and sys.path statements after them so the guarded block is not comments alone; and, read once at the top, FAST = os.environ.get("LUCID_NOTEBOOK_FAST") == "1".

Every step the notebook takes is a call into lucid_yolo on an object the kernel holds — download_coco or build_tiles, DetectionLitModule and DetectionDataModule built from the values the tier's *_nano_smoke.yaml carries and named in the prose, a Trainer for fit and validate, lucid_yolo.predict and lucid_yolo._viz for the answer and the figure — with the fast budget a conditional argument (batch_size=4 if FAST else 64). The console command the documentation spells is quoted as prose, never run: no lucid_yolo.cli import, no DetectionCLI, no subprocess, no %%bash cell, no checkpoint reload. The gate still executes the .py through a kernel rather than as a script — converting it with jupytext and running the result on the venv's own interpreter, with no kernel to install (notebooks/README.md, "Building and checking", has the mechanism) — because the bootstrap's !git clone and %pip install are magics a script run reads as comments. That is why the earlier rule, "use subprocess, not a ! magic", is withdrawn: it protected a script run the gate no longer makes. A full run's dataset lives under the gitignored data/ — data/coco2017, data/dota — and its checkpoints under results/demo_<task>; the fast run writes only into tempfile.TemporaryDirectory(), and the smoke test points TMPDIR at its own tmp_path, since a kernel nbclient shuts down runs no finalizer and pytest prunes what it owns.

FAST is the contract the gate holds you to. tests/notebooks/test_notebooks_run.py runs every source with that variable set, on CPU, on every make test: under it the notebook swaps its dataset for the synthetic slice, trains one epoch on a small batch, draws no figure and downloads nothing, and finishes within the ceiling the test sets. Without the variable the same file does what it is for — that run is marked gpu and data and belongs to make test-gpu. The Colab run is the third layer and yours to make by hand, once, after the push; until then the badge is dead by construction.

Add the page to mkdocs.yml under the Notebooks nav section as - <Title>: notebooks/<name>.ipynb (uncommenting the section for the first one). docs-site fails a source with no entry; mkdocs build --strict fails an entry with no source. notebooks/README.md has the full shape, including the one ruff rule relaxed for the directory.

🐛 Reporting a problem

Issue templates are in the repository. Two things make a report actionable here that are easy to leave out:

  • The commit or release you are on. "Latest" is not a version; this project ships release commits ahead of its own tags, so name the commit.
  • Whether make gate is green on your machine before your change. A gate that is already red locally usually means an environment problem rather than a defect, and it is the fastest thing to rule out.

For a numeric disagreement — a metric that does not reproduce — the useful report names the golden or the reported figure, the value you got, the hardware, and --data.num_workers. Three divergences are expected and documented rather than defects:

  • Cross-platform last-bit rounding (A26): libm differs across OS and architecture, which is why the goldens assert structural metrics with tolerance rather than byte hashes.
  • The worker count is part of the seed. A seeded run is byte-identical only against another run at the same num_workers. At 0 the pipeline draws from one generator in-process; with workers each is re-seeded per worker and per epoch from the loader's own generator (WP-079). Both streams are fully determined by seed and they are not the same stream — so 32 workers does not reproduce 0 workers, or 16.
  • MPS is not strictly deterministic. deterministic: true in a config is a request; default_determinism (cli/train.py) downgrades it to "warn_only" when MPS is the auto-picked accelerator, because MPS has no deterministic kernel for some backwards this model reaches. CPU and CUDA keep strict determinism.

A structural difference — a different number of detections, an inverted ordering, a metric off by more than tolerance — is none of those, and is worth reporting.

🔒 Security

Do not open a public issue for a vulnerability. This project ships no trained weights (D14) and has no network surface at inference, so the realistic classes are dependency-borne and deserialization-borne. Report privately through the repository's security advisory form.

📜 Licence

Contributions are Apache-2.0, matching the project. Signing off is your statement that you are entitled to submit them under those terms.