Unit tests prove correctness. They do not prove complexity. This is not a story about failing tests. It is a story about tests that passed while the code was still broken.
In this case study, a free model's C++ hash map passed 42 unit tests and both sanitizers, then took 4.2 seconds to insert 100,000 realistic keys. The scaling curve exposed an O(n²) collision pattern in three runs. The fix took one round.
I generated the first version with MonkeyCode's free model access and ran the verification gate on its free server option, so the compiler, flags, and environment stayed identical across every run.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Background
I needed a small string-keyed map for a log-deduplication tool. The hot path reads a line, extracts a key like 2026-08-23T10:15:30 node=7 seq=000123, and counts unique keys. The map is the bottleneck.
The requirements were narrow: C++17 only, no external dependencies, amortized O(1) expected insert, and 100,000 inserts in under 100 ms. That last requirement is a complexity contract, not a style preference.
Goal
The acceptance criteria were:
- All unit tests pass.
- AddressSanitizer and UndefinedBehaviorSanitizer report nothing.
- Inserting 100,000 structured keys completes in under 100 ms.
Criterion 3 is the one most verification pipelines omit. This case study is about why that omission is expensive.
Implementation
The model produced a chaining hash table with a power-of-two capacity. The structure was conventional. The hash function was not:
size_t hash(const std::string& key) const {
size_t h = 0;
for (size_t i = 0; i < key.size() && i < 4; ++i) {
h = (h << 8) | static_cast<unsigned char>(key[i]);
}
return h & (capacity_ - 1);
}
Only the first four bytes of the key participate. The insert path looked correct:
void insert(const std::string& key) {
size_t idx = hash(key);
for (auto* node = buckets_[idx]; node; node = node->next) {
if (node->key == key) return;
}
buckets_[idx] = new Node{key, buckets_[idx]};
++size_;
}
Every key in my log data starts with 2026. Every one of them hashed to the same bucket. Each insert scanned a growing chain.
The gate
I ran three stages in order. Stage one was compilation with warnings and sanitizers:
g++ -std=c++17 -Wall -Wextra -O1 -fsanitize=address,undefined -o map_test map_test.cpp
./map_test
All 42 unit tests passed. ASan and UBSan were silent. Stage two was the scaling benchmark:
#include <chrono>
#include <iostream>
#include <string>
int main(int argc, char** argv) {
const size_t n = std::stoull(argv[1]);
StringMap map;
auto start = std::chrono::steady_clock::now();
for (size_t i = 0; i < n; ++i) {
std::string key = "2026-08-23T10:15:30 node=7 seq=" + std::to_string(i);
map.insert(key);
}
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
std::cout << n << " " << ms << "ms\n";
}
Stage three was the decision rule. I ran the benchmark at N, 2N, and 4N, then compared the ratios:
for n in 25000 50000 100000; do
./map_bench "$n"
done
Results
The first run produced this:
25000 262ms
50000 1048ms
100000 4197ms
Doubling the input quadrupled the time. That is the signature of O(n²), and it is visible in three numbers. No profiler. No flame graph. No speculation.
I fed the scaling table back to the model with one instruction: the hash must use the entire key. The second version replaced the hand-rolled hash with std::hash<std::string>:
size_t hash(const std::string& key) const {
return std::hash<std::string>{}(key) & (capacity_ - 1);
}
The rerun:
25000 9ms
50000 19ms
100000 38ms
Doubling the input doubled the time. The same 42 unit tests still passed. Nothing about correctness changed. The first version was correct and unusable at the same time.
The decision table
| Gate | Round 1 | Round 2 | Verdict |
|---|---|---|---|
| Unit tests (42) | pass | pass | correctness OK |
| ASan + UBSan | clean | clean | memory safety OK |
| 25k / 50k / 100k | 262 / 1048 / 4197 ms | 9 / 19 / 38 ms | complexity reject → accept |
The rule I use now: if time(2N) / time(N) is above 3, stop and inspect the algorithm before touching anything else. A ratio near 2 means linear. A ratio near 4 means quadratic. The benchmark costs about one second and replaces hours of guessing.
Lessons learned
First, green tests are not a performance license. The 42 unit tests used short, distinct keys like alpha and beta. Their first four bytes differed, so they scattered across buckets. The tests never exercised the distribution that production data would.
Second, a scaling benchmark is the cheapest complexity detector I know. Three runs, a shell loop, and a ratio. It found a defect that sanitizers, warnings, and a correct-looking code review all missed.
Third, the environment matters. I ran every stage on MonkeyCode's free server option, which meant the compiler and flags were identical across rounds. If the benchmark had run on my laptop, thermal throttling could have produced the same 4x ratio for the wrong reason.
Fourth, the fix was not a rewrite. It was a one-line change to the hash function. The model's structure was sound; the distribution was broken. That is a common failure mode, and it is cheap to catch.
Limitations
This case study has narrow scope. The prefix-hash defect only appears when keys share a prefix. Random or short keys may never trigger it, which means the gate looks unnecessary until the day it is not.
Do not use this approach for a read-only workload over a fixed dictionary. A sorted vector with binary search will beat any hash map, generated or hand-written. Do not use a generated hash map where you need thread safety; this one is single-threaded by design.
The free server option is a verification environment, not a production host. I used it to gate code, not to serve traffic. And one small C++17 project is not a general verdict about free models. The reusable artifact is the method: compile, sanitize, scale, decide.
If you run a similar gate on generated code, send me the scaling curve. I want the shape, not just the pass/fail.
Top comments (0)