DEV Community

Finley Zhou
Finley Zhou

Posted on

Case Study: A Free Model's C++ Hash Map Passed 42 Tests. The Scaling Curve Found the O(n ).

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:

  1. All unit tests pass.
  2. AddressSanitizer and UndefinedBehaviorSanitizer report nothing.
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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_;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Results

The first run produced this:

25000  262ms
50000  1048ms
100000 4197ms
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The rerun:

25000  9ms
50000  19ms
100000 38ms
Enter fullscreen mode Exit fullscreen mode

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)