Write the spec before the code
Every design decision in tcrga — the Python version, the optimisation
library, what the algorithm is even trying to maximise — was written down and reasoned
about in README.md
and PLAN.md
before a single line of the package existed. This page explains why that order mattered,
especially when the person writing the code some of the time is an AI coding assistant.
What "spec-first" meant here
Before implementation began, this project answered, in writing:
- What is the fitness function actually optimising, and what merely constrains it? (Binding probability is the objective; biological plausibility and off-target risk are feasibility constraints, not co-equal goals.)
- What Python version, and why? (3.12, pinned — see below.)
- What optimisation engine, and why? (pymoo 0.6.2, used through its object-typed variable API rather than a hand-rolled loop.)
- What does the data actually look like, and what are its known problems? (McPAS-TCR's session-gated download, its beta-only rows, its inconsistent gene nomenclature.)
None of this required code. All of it removed ambiguity that would otherwise have surfaced mid-implementation, at a point where undoing a wrong assumption is expensive.
Why this matters more, not less, with an AI coding assistant
A written spec is the artefact that both the person and the model can point at. When a prompt says "add the crossover operator," the model still has to guess what the chromosome looks like, what invariants it must preserve, and what "correct" means for this project — unless that is already written down. With a spec:
- Disagreement becomes visible early and cheap. If the model's reading of the plan doesn't match the author's intent, that mismatch shows up as a question or a strange design choice while it's still one paragraph of text to fix — not as a few hundred lines of code implementing the wrong thing.
- Phases end in something runnable. The plan is explicit that nothing is deferred to a big-bang integration: Phase 0 ends with an importable package, Phase 2 ends with the GA running end-to-end against a stub scorer, and so on. Each phase is small enough that a mistake is caught the same day it's made, and the assistant always has a working baseline to extend rather than a half-finished tangle to debug.
-
The spec constrains hallucination. An assistant asked to "write a
fitness function" with no other context will invent an interface. An assistant told
"the fitness is
P(bind)in [0, 1], plausibility and off-target are constraints inout["G"], pymoo minimises so negate the objective" has much less room to drift.
Concrete case: checking a dependency before designing around it
The plan for Phase 0 explicitly set out to retire a risk before writing any scorer
code: probe whether epytope's
TCR-specificity extension — a unified interface to a couple of dozen pre-trained TCR
binding predictors — was actually installable and usable. It looked, on paper, like it
could shortcut a large chunk of the scoring work.
The probe found that it could not, in the form hoped for. The findings are recorded in full in docs/predictors.md; in short:
pip install epytopeinstalls a version with no TCR module at all — the TCR extension exists only in a research fork, not on PyPI.- Every wrapped predictor in that fork requires cloning its own separate repository and running it inside its own per-predictor conda environment. There is no bundled, self-contained model.
- That structure — shelling out to a conda environment per predictor — is architecturally incompatible with a genetic algorithm's inner loop, which needs to score dozens or hundreds of candidates every generation.
Finding this out during planning meant the project could design Phase 3 around torch-free, self-contained scorers (TCRdist distance, positional weight matrices, a gradient-boosted classifier) as the foundation, not a fallback — and treat any future neural predictor integration as a batched, cascade-style addition rather than a per-individual subprocess call. Discovering this mid-implementation, after scorer code had already been written around the wrong assumption, would have cost real time — plausibly days, once you account for the false starts.
A related example lives in docs/environment.md: this project is developed on an Intel Mac, and several dependencies (biopython, numba, llvmlite, torch) have dropped macOS x86_64 wheel support in their latest releases, failing to build from source against the machine's outdated compiler toolchain. That was diagnosed and pinned around — with the exact package versions and reasoning recorded — before it could quietly break an installation step later.
Concrete case: the real dataset is much smaller than the raw export
McPAS-TCR's export lists 40,731 rows, and it would be easy to plan a scorer around a figure that size. Actually loading and cleaning the data told a different story:
- Roughly 59% of rows carry no epitope annotation at all, so they cannot supervise a peptide-specific scorer regardless of how the rest of the pipeline is built.
- The database is heavily skewed toward beta-chain-only entries; only 4,988 rows survive cleaning as usable, checksum-validated paired alpha/beta records — the unit this project's scorers actually need, since binding is a joint property of both chains together.
- Of those, only 38 peptides have 10 or more distinct paired receptors behind them — a rough floor for a scorer to learn anything peptide-specific rather than noise.
None of this changes what the project builds, but it changes what any run of it can honestly claim: a scorer trained against this data has real supervision for a few dozen peptides, not tens of thousands of rows' worth of coverage. Measuring that before writing scorer code, rather than after training something on an assumed-large dataset, is the same habit as the two cases above: check what you actually have before designing around what you hoped you had.
Concrete case: the optimiser cheats if you let it
The very first prototype run of the GA used a deliberately naive placeholder scoring function — something quick to wire up just to prove the engine ran end-to-end. That placeholder rewarded sequences containing more tryptophan (one of the twenty amino acids). It seemed harmless: a simple, fast, obviously-not-final stand-in for a real binding predictor.
Within 25 generations, the algorithm found CKYIWWWWWAVAWHMF — a sequence
that is mostly tryptophan repeated — and scored it at a perfect 1.000. This is not a
plausible T-cell receptor sequence by any biological measure. It is what happens when
you give a search process a score to maximise and the score has a loophole: the
algorithm does not know or care what the score is supposed to mean, it only
climbs whatever number you hand it. If the shortest path to a high score runs through
nonsense, that is the path it takes.
This is worth stating plainly for anyone new to optimisation: an optimiser exploits whatever you actually give it, not what you meant to give it. Any time a scoring function has an unintended shortcut, a sufficiently thorough search will find it. This is sometimes called "reward hacking" or "objective exploitation," and it is a general hazard of automated optimisation, not something specific to this codebase or to genetic algorithms.
The response was to turn the failure into a permanent safeguard rather than just fix the placeholder and move on: that exact scenario — a scorer trivially exploitable by a degenerate sequence — is kept as a regression test. Any future scorer, including real trained ones, is checked against it. That is one of the concrete reasons this project's fitness design (see the landing page) treats plausibility and off-target binding as hard constraints rather than optional extra terms in a weighted sum: a weighted sum can still be gamed by sacrificing one term for another, while a constraint that must be satisfied cannot be traded away.
The takeaway
None of this required exotic process. It required writing down, before coding, what the system should do, what it should never do, and why — then treating a bad result during development as information to encode permanently, rather than a bug to quietly patch. Both habits are just as valuable, arguably more valuable, when part of the implementation work is delegated to an AI coding assistant: the spec is the shared reference that keeps the human and the model pointed at the same target.
See the prompts page for the actual, verbatim prompts that put this into practice on this project.