I Only Asked to Change One Character—And 527 Coupons Got Duplicated
I only asked to change one character: normalizing the item name from ‘蛋塔’ to the official spelling ‘蛋撻’. Claude took over and followed the explicit advice in the project documentation—add the synonym to the mapping table and leave the data files alone. After running the bulk merge, the output looked like this:
Added: 527 ← Should be Changed
Changed: 0
Total output: 1513 ← Originally 986
This action did not introduce a single new coupon; it was supposed to be a simple rename. Instead, the 986-coupon dataset inexplicably bloated by 527 extra items. One character detonated three long-buried landmines—and throughout the entire process, my only role was making judgment calls. Planning, execution, breaking things, diagnosing, and fixing them were all done by Claude.
Background: Item Family Filters Went Live Just the Day Before
The coupons project sorts a bunch of discount coupons based on their actual value for money. Just the day before, ‘item family filters’ went live: eight filter chips above the coupon list—burgers, fries, etc.—where clicking one shows only the coupons containing that specific item family.
The granularity of these filter chips was not arbitrarily decided; it was measured from the real dataset. There were 36 original item names, creating 630 pairwise combinations, 391 of which were empty sets (62%)—because a single coupon rarely contains two different items. After grouping them into eight families, all 28 pairwise combinations became non-empty. This metric is the load-bearing wall of the entire design: The default AND logic works only because each individual filter chip is already an OR operation.
Day Two: New Data Takes a Bite
The next day, a new crawler run brought in two new item names (a burger variant and an egg tart flavor) that were missing from menu.json. The behavior of families() at the time was to silently ignore unmatched names. Consequently, a coupon containing a burger failed to appear when filtering for ‘burgers’.
This needed a fix. As for how to fix it, I made the first judgment call of this cycle:
Fix it, but fix it from the perspective of generality.
I also proposed the specific approach, which was more economical than Claude’s original plan: group unmatched items under a single filter chip that remains hidden by default and only appears when populated. Claude originally wanted to add commit message tags and CI annotations to crawl.yml, so that partial solution was scrapped—the chip itself acts as the signal, and it surfaces where users actually see it, rather than buried in unread CI logs.
Even the naming was meticulous: ‘Other’ was rejected because the page layout already had an ‘Other’ section with a different meaning; ‘New’ was also rejected because an unmatched name is not necessarily new—source renames, spec splits, or typos would all produce the same outcome, making it a potentially dishonest label. The id was set to unknown to prevent confusing data-families with category when reading the DOM.
The Flashpoint: A Single-Character Rename
Then came my second judgment call, which is the single character mentioned at the beginning: the family label should use the official spelling ‘蛋撻’. The dataset itself was already inconsistent—the short name was written as 蛋塔, appearing 546 times, while the end of the long-name items used 蛋撻.
The project’s CLAUDE.md explicitly stated: source synonyms should be added to menu_mapping, and do not modify coupons.json—the latter would simply be overwritten by the crawler the next day. Claude followed these instructions and ran --allow-bulk, which produced the output shown at the beginning: 527 coupons were treated as new additions, and the dataset was duplicated.
Three Overlapping Landmines
Digging deeper revealed three previously unexploded defects in merge.ts.
Landmine 1: The matching loop built signatures using item names. The key used to find existing coupons was code|price|品項簽章, and the signature consisted of the item names. After the rename, they no longer matched, so all 527 coupons went down the ‘new coupon’ branch and were assigned new ids.
Landmine 2: The disappearance loop recalculated the signature, and again without normalization. The original signature of the old coupon was not in seenSigs, so it was treated as ‘disappeared but retained’ and added back in. Thus, it was duplicated twice, not once. Claude’s first iteration of the plan asserted that ‘old coupons will not enter the disappearance branch after a successful match’—this assertion was wrong. The implementer stopped and left it unchanged per Out of Scope constraints, and the review correctly judged this as a plan defect, not an implementation error.
Landmine 3 was the most insidious: renaming does not affect coupons the source no longer provides. Those coupons will never be re-parsed, so they are permanently stuck with the old name. Testing confirmed that one expired coupon was left behind.
Why was the third landmine dangerous? It exhibited absolutely no symptoms. Those were the exact expired coupons fed into the regression fitting set—a single rename caused samples of the same item to split under two different names. The cost-performance ranking would slowly degrade without anyone noticing. At least the first two bugs produced an easily spotted number like 1513; the third bug produced nothing at all.
Why the Three Landmines Never Detonated Earlier
It was purely due to sequential luck. The only existing rename—a cola item where the source later simplified its long name—was added when no coupons were storing the old name yet. Testing confirmed: there were 0 instances of that old name in the dataset, and 0 duplicate groups sharing the same code+price. The landmines were not nonexistent; it was just that no one had stepped on them yet.
Claude’s Second Mistake: The Requirement Itself Was Wrong
Before fixing it, Claude brought up another requirement: ‘Renaming must not merge two different coupons into one.’ This requirement was inherently wrong—if the code, price, and items of two entries are exactly identical after normalization, they are the same coupon. Merging them is the correct behavior; forcefully preserving both simply creates duplicates. What truly needed to be safeguarded was not losing the earlier first_seen timestamp, and the winner could not be determined by insertion order. The test was later updated to assert exactly this.
The Fix: Apply the Same Rename Mapping in Three Places
The fix boils down to a single sentence: the same rename mapping must be applied in three places—the matching index, the disappearance check, and the retained disappeared coupons. locked coupons bypass this (a hard rule of the project); verified_at remains frozen, because a rename does not constitute a re-verification.
Rerunning the process after the fix yielded: Added 0、Changed 527、總數維持 986. The ID set remained completely identical, with zero changes to first_seen. The safeguard calculated 527 / 門檻 394—without --allow-bulk it would have been blocked, and rightfully so: a rewrite of over five hundred coupons inherently requires human approval. The safeguard threshold was intentionally left untouched.
Side Benefit: Write Only High-Confidence Rules
This incident also included my third judgment call:
At the very least, basic patterns like ‘xx burger’ belongs to burgers and ‘xx egg tart’ belongs to egg tarts must exist.
Claude originally opposed inference, arguing that it ‘only fills in a quarter of the data’—new items also require type, portion, and anchor_price. However, the architecture combining rules with an unclassified fallback resolved that objection: only write high-confidence rules, and let the rest fall into the unclassified category as usual.
Testing this approach by treating the 36 known items as ‘unseen’ (with exact table lookups disabled) yielded the following results: morphological rules correctly inferred 20 items, incorrectly inferred 0, and relegated 16 to the unclassified category. The items that fell into unclassified were almost exclusively those named after ingredients. There was a deliberate trade-off made here: omitting a rule for ‘chicken’. A chicken sandwich would simultaneously trigger both ‘chicken’ and ‘burger’, and introducing priority conflicts just for a few fried chicken items was not worth it.
There were three key design elements: The parsing sequence is menu.json exact lookup → rules → unknown. The exact lookup always takes precedence, ensuring not a single line of classification for existing items changes. Longest match wins, so 薯餅 (hash browns, under other fried foods) outranks 薯 (fries). The old spelling 蛋塔 remains in the rules because menu_mapping only maps exact full names; if the source introduces a 草莓蛋塔 in the future, it will not be mapped and must be caught by the rule. These rules live in item_families.match within brand.json—they are data, not code, meaning switching brands does not require modifying derive.ts.
One acceptance criterion was written explicitly: rules must not mask ‘unrecorded’ status. The items absent from menu.json warning in the build log had to remain, and check:unmatched still needed to list those two item names. The rules only solve the ‘filterable’ aspect, not type, portion, or anchor_price; suppressing the warnings would mean hiding unresolved issues. After deploying, the unclassified filter chip automatically vanished (chip count went from 9 → 8)—it was not manually disabled; it simply stopped rendering because no coupons fell into it. This was the first field test of the self-healing mechanism.
By the Way: Two Hardcoded Numbers
My fourth judgment call was minor, but it caught something significant: storing mutable values in CLAUDE.md was bizarre. So the three instances of ‘917 coupons’ were removed and replaced with instructions on ‘how to query’ it. On the same day, an even more egregious instance was found on the /methodology/ page—it read, ‘Currently, the system has 5 items awaiting baselines, causing 7 coupons to be downgraded,’ when the actual count was 2 items and 4 coupons. That was a public page, and ironically, the section heading was ‘Honest Disclosure and Limitations’. The specific numbers were stripped out entirely.
Post-Mortem Heuristic
Looking back, this issue was only discovered because a mapping table was modified to change a single character—the landmines had been lying there for a long time, in a path no one usually took. My workload throughout the entire incident consisted of four judgment calls: fixing for generality, renaming a single character, setting the baseline for the classifier, and removing hardcoded numbers. The rest, from the incorrect assertion to the correct fix, was entirely done by Claude.
Thus, the heuristic is this: Before renaming an item, first ask ‘what happens to the existing coupons?’ For any ‘content-derived key’ in the pipeline—in this case, code|price|品項簽章—as long as the content is subject to configured rewrites, mismatch issues of this nature are inevitable. The next time a normalization layer is added anywhere in the pipeline, check this first.