How a Dedup Pass Deleted My Training Curriculum A developer built a capture-the-flag arena where language models attack and defend containers, and trained a local Qwen2.5-3B-Instruct bot with an MLX LoRA adapter on game replays. The bot outperformed five cloud models on offense, capturing 400 flags in 221 games, but its defense lagged. The developer discovered that a deduplication step in the training pipeline was silently discarding the weighted repetitions intended to emphasize defensive turns, nullifying two attempted curriculum fixes. I built a capture-the-flag arena where language models attack and defend each other's containers. The replays then became training data for a small local model that plays in the same tournament. The bot is Qwen2.5-3B-Instruct with an MLX LoRA adapter rank 8, 8 layers trained on turns extracted from game logs. It does well on offense. Across the 221 logged games it played in, it captured 400 flags, more than any of the five cloud models on the same network in those same games. | model | games | flags captured | per game | flags lost | per game | |---|---|---|---|---|---| | custom bot 3B, local | 221 | 400 | 1.81 | 134 | 0.61 | | GPT-OSS 120B | 221 | 275 | 1.24 | 163 | 0.74 | | GLM-5.1 | 221 | 237 | 1.07 | 160 | 0.72 | | Nemotron 3 Super | 221 | 61 | 0.28 | 133 | 0.60 | | Gemini 3 Flash | 221 | 55 | 0.25 | 40 | 0.18 | | RNJ-1 8B | 221 | 2 | 0.01 | 131 | 0.59 | Defense is the gap, and it is the one I kept trying to close. Gemini 3 Flash loses its flag 0.18 times per game; the bot loses its flag 0.61 times per game, roughly the rate of models it beats comfortably on offense. Two attempts to fix that were both rolled back. The v5 self-play loop regressed and was replaced by a curated mix v6, still the active build . The v7 retrain was built specifically around weighting good defensive turns. It regressed offense more than it gained on defense, and went back to the v6 snapshot. I thought I had tested two weighted curricula and found both wanting. Neither of them was ever weighted. MLX LoRA fine-tuning takes a JSONL file, one example per line, with no per-example weight field. The usual way to emphasise an example is to write it more than once, which is what the pipeline does. training/defense training data.py on the custom-bot branch, not the repository's default master scores every defender turn against the game's own vulnerability-check timeline and returns a weight: 6 for a turn that patched an unpatched vulnerability and kept the flag, 3 for one that patched but lost the flag anyway, 1 for harmless background activity, 0 for turns to drop. Attacker turns get 3 if that command captured a flag and 1 otherwise. The weight is applied by repetition: ex = make example 'attacker', thinking, cmd examples.extend ex weight Then, 56 lines later, the same function ends like this: Dedup by last 200 chars seen = set deduped = for line in examples: key = line -200: if key not in seen: seen.add key deduped.append line The copies are byte-identical, because make example is a pure function of the role, the thinking text and the command. Every copy after the first has a key already in seen , so every copy after the first is discarded. The weighting stage and the dedup stage are the same operation with opposite signs, and dedup runs second. I ran the extractor over all 358 session directories in logs/ 329 of them have a complete game.json . Once as written, and once with the dedup key replaced by a unique object so that nothing collapses: | corpus | lines | |---|---| | after weighting, before dedup | 94,022 | | distinct lines exact | 55,034 | | what the pipeline hands the trainer | 50,145 | 38,988 lines, 41.5% of the weighted corpus, were the weights themselves. The weighted total is not an approximation of the intent; it is the intent, exactly: | category | turns | weight | intended lines | |---|---|---|---| | defender, patched and kept the flag | 2,861 | 6 | 17,166 | | defender, patched but lost the flag | 555 | 3 | 1,665 | | defender, background activity | 18,227 | 1 | 18,227 | | attacker, command captured a flag | 2,518 | 3 | 7,554 | | attacker, everything else | 33,035 | 1 | 33,035 | | Gemini 3 defender exemplars | 3,275 | 5 | 16,375 | total | 94,022 | Those six products sum to 94,022, which is what the unmodified extractor produces before its own dedup, so the weighting really did happen; it just did not last. The gold defensive turns were meant to be 18.3% of what the trainer saw 17,166 of 94,022 . They ended up at most 5.7% 2,861 of 50,145 , a smaller share of the corpus than the neutral turns they were supposed to outrank. The Gemini exemplars are the sharpest version. They are 3,275 curated defensive chains from the strongest defender in the tournament, kept in their own file and pulled in at GEMINI CHAIN WEIGHT = 5 . The comment above that line states the goal: Weight 5x up from 2x so these dominate the bot's own neutral defense habits in the training pool. Raising that constant from 2 to 5 changed nothing at all. The file's 3,275 lines carry 3,187 distinct 200-character tails, so after dedup the exemplars contribute 3,187 lines whether the constant says 2, 5 or 500. One weight did survive, because zero copies of a line is a decision dedup cannot reverse. 27,386 defender turns scored 0 were never appended 6,602 of them for issuing commands against another machine's IP, which is an attack, not a defense . The graded curriculum degenerated into a binary include-or-exclude filter, and the difference between a 6 and a 1 became the difference between a 1 and a 1. The tail-of-the-line key does one more thing I did not intend. Comparing distinct lines 55,034 against what dedup leaves 50,145 , 4,889 lines were removed that were not copies of anything: 8.9% of the genuinely distinct examples. They collide because the key is the last 200 characters, covering the end of the truncated thinking text and the command. Two turns with different reasoning that end in the same command are therefore one example. Every repeat of a good command under different circumstances is exactly the sort of thing I was trying to teach. training/self play loop.py has the same construction in two places: at the end of its extractor, and again where each generation's new data is merged into the cumulative pool. The v5 self-play loop was training on the same flattened corpus. The chain that produced the training file is arithmetic all the way through. defense retrain.py shuffles the deduped list and splits it 90/10. The train.jsonl it wrote has 38,448 lines and valid.jsonl has 4,272, which sum to 42,720, and int 42,720 0.9 is 38,448. All 38,448 training lines are unique, both as whole lines and by their last 200 characters. Nothing was emphasised. That file is not something a reader can check, because the session logs, the JSONL corpora and the adapters are all gitignored; the extractor, the retrain script and the per-generation stats are in the repository, so the mechanism is public even though my copy of the data is not. What I cannot claim is that this explains the rollbacks. Both retrains were judged on game outcomes, and a correctly weighted corpus might have regressed too; the experiment that would settle it same data, weights preserved, same evaluation has not been run. Two smaller caveats belong here as well. My extraction run today covers more sessions than existed when v7 trained, so the ratios are the finding and the absolute counts are not comparable to that run. The session logs also do not record which adapter version was playing, and the match format changed partway through the tournament. The per-game table above is a pooled result across versions, not a comparison between them. There is a weaker piece of evidence that the training signal was not doing what I thought, and it is public in the repo. The 11 self-play generations each record a validation loss beside the games those weights then played. Validation loss fell from 0.526 at generation 0 to 0.462 at generation 9 0.377 at its best . Average score over the same span fell from 172.2 to 101.4, and flags lost per game rose from 2.7 to 4.7. Self-play scores are close to zero-sum between six copies of one bot, so I would not read that score column as a capability measure; the useful part is that the metric being optimised kept improving while the behaviour it was supposed to produce did not. If your weighting scheme is implemented as duplication, any later uniqueness pass is a silent reset of that scheme. It will not announce itself either: the pipeline runs, the loss curve looks healthy, the model trains and deploys. My extractor does report its own work, as a count of turns scored def gold , def patched , def neutral , and every one of those counts is correct. They describe the intent rather than the delivery. The number worth printing is the one I never printed, the corpus length before and after dedup. A 94,022 next to a 50,145 would have ended this in one run. The concept, research questions and system design of this project are mine, and much of the implementation was AI-assisted, as the repository's README states. This bug is a fair illustration of what that costs. Both halves are reasonable code. A weighting function that repeats examples is a standard trick, and deduplicating a corpus assembled from overlapping logs is ordinary hygiene. Nothing is wrong with either one until you notice they sit in the same function, 56 lines apart, undoing each other.