---
title: "How I Turned a 100 ms Problem into a Microsecond Query with AI-Assisted Performance Engineering"
description: "A real Hungarian-lottery problem, two complete solutions with the actual Go on the table, and the honest story of how AI helped collapse latency from a 100 ms budget to ~1.5 microseconds — not in one prompt, but one disciplined iteration at a time."
url: https://akemara.com/en/blog/ai-assisted-performance-engineering/
lang: en
author: "Ahmed K Emara"
date: 2026-06-23
lastmod: 2026-06-23
section: blog
tags: ["AI agents","algorithms","concurrency","performance","systems engineering"]
translations:
  ar: https://akemara.com/ar/blog/ai-assisted-performance-engineering/
---

# How I Turned a 100 ms Problem into a Microsecond Query with AI-Assisted Performance Engineering

> A real Hungarian-lottery problem, two complete solutions with the actual Go on the table, and the honest story of how AI helped collapse latency from a 100 ms budget to ~1.5 microseconds — not in one prompt, but one disciplined iteration at a time.

*A real Hungarian-lottery problem, two complete solutions with the actual Go on the table, and the honest story of how AI helped collapse latency from a 100 ms budget to ~1.5 microseconds — not in one prompt, but one disciplined iteration at a time.*

One of the most useful ways I have found to work with AI on a performance problem is *not* to ask:

> “Can you optimize this code?”

but instead to ask:

> “What work can be moved out of the critical path?”

That single question often rewrites the entire architecture. This article is about a problem that began with one ordinary line in a spec — **maximum acceptable query latency: 100 ms** — and ended somewhere I did not expect.

The job was to report winner counts for a lottery-like system with up to **10 million players**. The final results landed like this:

```
100 ms budget
        ↓
12 ms   optimized parallel scan      (ordinary systems engineering)
        ↓
1.5 µs (0.0015 ms)  indexed O(1) query           (AI-assisted redesign)
```

Put another way: the budget was 100 ms = 100,000,000 ns. The final query latency was about 1,500 ns — roughly **66,000× under budget**. But the final number is not the interesting part. The interesting part is how AI can help decompose a problem into known building blocks that *snap together* into something dramatically faster — and how much human steering that took.

Before we go anywhere, it helps to fix the scale we are talking about, because the difference between a millisecond and a microsecond is the difference between “fast enough” and “effectively free.”

The relationship between the units is worth committing to memory — performance work lives and dies on knowing which unit you are in:

```
UNIT          SYMBOL   SECONDS        vs. NEXT UNIT     EVERYDAY FEEL
─────────────────────────────────────────────────────────────────────────
second         s       1              —                 a slow page load
millisecond    ms      0.001          1 s   = 1,000 ms  a snappy API call
microsecond    µs      0.000001       1 ms  = 1,000 µs  a few memory reads
nanosecond     ns      0.000000001    1 µs  = 1,000 ns  one CPU instruction
─────────────────────────────────────────────────────────────────────────
This project:  100 ms budget  →  12,000 µs scan  →  1.5 µs indexed query
               100,000,000 ns →  12,227,000 ns   →  ~1,500 ns
```

![Time units on one scale: from seconds to milliseconds to microseconds to nanoseconds, marking the journey from a 100 ms budget to a 1.5 µs query](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/article-cover-2.webp "The whole journey on one scale. A second is a thousand milliseconds; a millisecond is a thousand microseconds; a microsecond is a thousand nanoseconds. Moving from 12 ms to 1.5 µs is not a small win — it is crossing nearly four orders of magnitude.")

## The Hungarian Lottery problem


The rules are simple:

- Each player chooses **5 distinct numbers** from **1 to 90**.
- Later, the lottery draws **5 distinct winning numbers**.
- A player wins if they match exactly **2, 3, 4, or 5** numbers.
- At peak load there can be around **10 million players**.
- After the draw, the system must immediately report the winner counts.

The required output, after each draw, answers four questions: how many players matched exactly 2 numbers, exactly 3, exactly 4, and exactly 5?

At first glance this looks trivial — it is a counting problem. But performance engineering starts the moment someone adds a latency requirement. With **maximum response time after the draw: 100 ms** in the spec, the question quietly changes. We stop asking “can we count the winners?” and start asking the only question that matters at scale:

> How much work must happen after the winning numbers are known?

That distinction leads to two very different implementations: a **scan-based solution** optimized for simplicity, low memory, and cache efficiency; and an **indexed solution** optimized for extremely low post-draw latency. Both are useful. Both are worth understanding. And the gap between them is where this story lives.

## Understanding the shape of the problem

Performance-heavy problems are rarely solved by one trick. Most of the time, the real work is understanding the *shape* of the problem before writing any code. I try to answer a small set of questions first:

- What is known ahead of time?
- What must be answered immediately?
- What is the true critical path?
- Where is the bottleneck — CPU, memory bandwidth, allocation, I/O, synchronization, or algorithmic complexity?
- What tradeoff are we willing to make between memory, preprocessing time, and query latency?

For the lottery, the answers are clarifying. The tickets are *known before the draw*; the winning numbers are *only known after*. The critical path — the part a human is actually waiting on — is the post-draw report; everything else is preparation we can do on our own schedule. So the right question is not “how fast is the whole program?” but “how fast is the part users are waiting for?”

## Thinking like an AI performance agent

When I work with AI on optimization problems, I try to hand it *constraints* instead of implementation details. Something like this:

```yaml
Goal:        Answer winner counts after the draw.
Constraint:  Maximum latency 100 ms.
Dataset:     Up to 10 million tickets.
Allowed tradeoffs:
             more preprocessing,
             more memory,
             lower query latency.
Question:    What work can be moved before the draw?
```

That framing naturally produces two architectures: **scan everything after the draw**, or **precompute enough that the draw becomes almost free**. The second option is where things get interesting — but I want to be honest up front about the order of events. I did *not* arrive at the fast solution by typing one clever prompt. I arrived at a solid, ordinary solution first, by hand, and only then used AI to attack the part that ordinary engineering could not improve.

## The key idea: split the problem into phases

![Diagram splitting the system into a preparation phase before the draw and a minimal query phase after the draw](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/the-key-idea-split-the-problem-into-phases.webp)

Because the tickets are known before the draw and the winning numbers are not, the system splits naturally into two phases:

For performance-heavy problems, this separation is often more important than the algorithm itself. A system that does more work *before* the draw and very little *after* can beat one that starts quickly but scans everything while the clock is running. This is one of the most important patterns in our field: **move work from the critical path into a preparation phase.** Search engines do it with inverted indexes, databases with secondary indexes, compilers with intermediate representations, recommendation systems with precomputed features. The lottery is no different.

## High-level architecture

![Architecture with one front end feeding two execution paths: an O(N) scan mode and an O(1) indexed mode](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/high-level-architecture.webp "One front end, two execution paths. Scan mode is O(N) per draw; indexed mode does a fixed 26 lookups per draw — O(1), independent of the number of players.")

The final design supports two execution modes that share the same front end. Input is parsed in parallel into a compact packed representation, and from there the data feeds either a scan path or an indexed path:

The scan mode met the latency target comfortably. The indexed mode essentially eliminated query latency altogether. We will build both — and, conveniently, both satisfy the same one-line Go signature, so the rest of the program is written against the interface and the algorithm is chosen at startup with a flag:

```
type winnerCounter func(d1, d2, d3, d4, d5 int) (w2, w3, w4, w5 int)
```

```
// ./lottery -mode scan   input.txt   → O(N) per draw, lower memory
// ./lottery -mode indexed input.txt  → O(1) per draw, default
```

That symmetry is not just tidy; it is what lets the slow path *verify* the fast one later.

## Solution 1 — the cache-friendly scan (12 ms, and no AI required)

I want to be clear: there is *no AI magic in this section*. The 12 ms result came from ordinary, disciplined systems engineering — the kind any performance-minded engineer can do with a profiler and some patience. That matters, because it sets the bar the AI-assisted solution had to beat.

The scan solution is the simplest correct approach. Load all tickets into memory, then for each draw, scan every ticket and count matches:

```
for each ticket:
    matches = count numbers that appear in the draw
    if matches == 2: winners2++
    if matches == 3: winners3++
    if matches == 4: winners4++
    if matches == 5: winners5++
```

The complexity is **O(N) per draw**. For 10 million players that *sounds* expensive, but the constants are tiny — and data layout is where the win comes from.

## Compact representation

Instead of storing tickets as objects, structs, maps, or nested slices, store them as one flat byte array:

```
tickets []byte        // player i occupies tickets[5*i : 5*i+5]
```

So the memory layout is just numbers, back to back, with nothing in between:

```
Player 0        Player 1        Player 2
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ n n n n n   │ │ n n n n n   │ │ n n n n n   │ ...
└─────────────┘ └─────────────┘ └─────────────┘
```

```
Flat memory: [n0 n1 n2 n3 n4][n0 n1 n2 n3 n4][n0 n1 n2 n3 n4]...
```

With one byte per number, 10 million tickets pack into about `10,000,000 × 5 = 50 MB`, and a modern CPU streams through 50 MB of contiguous memory surprisingly quickly. (The raw text file is closer to 145 MB — more on how we avoid copying *that* later.) The advantages stack up: no per-ticket allocation, no pointer chasing, a compact footprint, sequential access, cache-friendly scanning, and trivial chunking across workers.

“For performance-heavy code, **data layout is part of the algorithm**. A theoretically simple algorithm with good locality routinely beats a cleverer algorithm implemented with poor locality.”

## The hot loop: a 91-byte table that lives in L1

![Scan mode flow: mark the 91-byte hit table, fan packed tickets across workers with private histograms, merge and print](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/the-hot-loop-a-91-byte-table-that-lives-in-l1.webp "Scan mode, per draw. Mark the hit table, fan the packed tickets out across workers, let each build a private histogram, merge, and print. No locks on the hot path.")

For each draw, build a tiny membership table — one byte per possible number — and mark the five that were drawn. At 91 bytes it stays resident in L1 cache, so every lookup is essentially free. Counting a player then collapses to five indexed reads and an increment: no set intersection, no map lookup, no sorting, no allocation. Here is the actual hot loop, lifted straight from the source:

```
var isDrawn [maxNumber + 1]byte            // 91 bytes — resident in L1
isDrawn[d1], isDrawn[d2], isDrawn[d3], isDrawn[d4], isDrawn[d5] = 1, 1, 1, 1, 1
```

```go
// each worker walks its own slice of players into a private histogram:
for player := chunkStart; player < chunkEnd; player++ {
    o := player * 5
    m := isDrawn[t[o]] + isDrawn[t[o+1]] + isDrawn[t[o+2]] +
         isDrawn[t[o+3]] + isDrawn[t[o+4]]   // m is 0..5
    histogram[m]++                           // buckets 2..5 are the answer
}
```

That is the entire computation: a branch-free sum of five bytes and one array bump, ten million times — the kind of work a CPU chews through without complaint.

## Parallel scanning

![Map-then-reduce over packed memory: workers build local histograms in parallel and a single merge sums buckets 2 to 5](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/parallel-scanning.webp "Map-then-reduce over packed memory. Workers produce local histograms in parallel; a single merge sums them. Histogram buckets 2–5 are the answer the business asked for.")

The scan splits cleanly across CPU cores. Each worker processes a contiguous range of players and keeps a *private* histogram, so there is no locking in the hot loop:

```
Packed tickets
┌───────────────────────────────────────────────┐
│ player 0 ............................. player N │
└───────────────────────────────────────────────┘
       │              │              │
       ▼              ▼              ▼
┌────────────┐  ┌────────────┐  ┌────────────┐
│ Worker 0   │  │ Worker 1   │  │ Worker 2   │
│ hist[0..5] │  │ hist[0..5] │  │ hist[0..5] │
└─────┬──────┘  └─────┬──────┘  └─────┬──────┘
      └───────────────┼───────────────┘
                      ▼
              ┌──────────────┐
              │ Merge result  │
              └──────────────┘
```

The theoretical complexity stays **O(N)**, but practical latency approaches **O(N / p)** where *p* is the number of cores. Better still, every worker walks a contiguous, predictable region of memory — exactly the access pattern hardware prefetchers love.

## Result: 12 ms

On a deliberately constrained box — 4 logical CPUs, 4 GB RAM — the scan implementation handled 10 million players with an average draw latency of about **12.2 ms**. Against a 100 ms budget, that is already **~8× faster than required**. Mission accomplished. Or so it seemed.

**Why keep scan mode at all?**

It is simple, memory-efficient, easy to debug, and easy to trust. It makes a perfect correctness baseline, shines when memory is tight or draws are rare, and — as we will see — can *verify* the fast solution. Before building a complex optimization, build the simplest version that is fast enough to measure: sometimes it is already good enough, and always it tells you where the real bottleneck is.

## Asking AI a better question

With 12 ms in hand, the lazy follow-up would have been “Can this be faster?” AI is happy to answer that — tighter loops, SIMD, chunk-size tuning — and you claw back a few percent. Instead I asked a structurally different question:

> Can the query become independent of the number of players?

That changes everything. A few percent is optimization; independence from N is a different runtime model. The answer turned out to be yes — and the resulting architecture is not new mathematics but a composition of several classical ideas. Composing them correctly, in code, at 1.5 µs, was not a one-shot prompt.

## The AI collaboration was iterative, not one prompt

This is the part most “I used AI to 1000× my code” stories quietly skip. The reframing question opened the door, but walking through it took several rounds of *precise* instruction, architectural constraints, and — above all — measurement and verification at every step. Here is the honest iteration log.

## Iteration 1 — the first design was correct and slow

Asked to make the query independent of N, the model proposed precomputing combination frequencies and answering via lookups. Good instinct — but its first implementation keyed everything on hash maps: `map[pair]int`, `map[triple]int`, and so on. Correct, yet heavy: hash computation on the hot path, bucket overhead, pointer chasing, GC pressure, unpredictable latency. I gave it a hard constraint: **no hashing on the query path, no per-item allocation.**

## Iteration 2 — dense arrays demand a bijection

Removing maps means storing counts in flat arrays, which forces a new sub-problem: every combination needs a deterministic integer *index*. I pinned it precisely — “map each sorted k-combination to a unique index in `[0, C(90,k))`, allocation-free” — and the model reached for the **combinatorial number system** (combinadics). Two details were non-negotiable: convert numbers to zero-based first, and **precompute the binomial coefficients** with Pascal's recurrence so `C(n,k)` is never recomputed in a loop.

## Iteration 3 — the inflated-counts bug (caught by the baseline)

The first end-to-end indexed run produced numbers that were obviously too large — the single most important moment in the project, and I only caught it because the scan baseline gave me the invariant `scan(draw) == indexed(draw)`, which failed. The cause is a classic trap: *subset totals are not exact-match counts*. A 5-match ticket is also counted inside every pair, triple, and quadruple total. I asked the model to derive the correction rather than patch it, pinning the contribution table (a 4-match ticket contributes 6 pairs, 4 triples, 1 quadruple; a 5-match ticket 10/10/5/1). That produced the **exactly-k inclusion–exclusion** back-substitution, and the invariant held again.

## Iteration 4 — make preprocessing fast, too

Constant-time queries are worthless if the index takes forever to build, and the naive preprocessing both allocated per combination and pinned four cores to the same hot cache lines. Closing that gap — allocation-free increments, parallel parsing over memory-mapped input, and a per-table choice between private accumulation and atomics (the subject of a whole section below) — is where a lot of the back-and-forth actually went.

## Iteration 5 — correctness, edge cases, and validation policy

Finally I drove a test pass: the tiny hand-checkable dataset, duplicate tickets, malformed lines, and pathological numeric tokens that overflow a hand-written parser. We made validation policy explicit (strict vs. lenient) rather than letting bad input silently corrupt the output.

**The real division of labor**

The AI supplied the classical building blocks and a lot of code. **I supplied the spec, the invariants, the measurements, and the correctness gate.** “Make it faster” gets you nowhere; “make the query O(1), dense, allocation-free, with exact-match semantics, and verify it against the baseline” gets you to 1.5 µs. The leverage is in the constraints, not the prompt count.

## Solution 2 — indexed counting (the 1.5 µs solution)

![Indexed counting pipeline: precompute subset tables from tickets before the draw, then answer with 26 lookups and inclusion–exclusion after it](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/solution-2-indexed-counting-the-1-5-s-solution.webp "Precompute, then answer with lookups. The expensive ticket-to-subset work happens before the draw. After the draw, four table reads' worth of subsets become T2–T5, and inclusion–exclusion turns those into exact counts.")

The indexed solution starts from a different question: can we move *almost all* of the work before the draw? Each ticket has five numbers, and from those five we can generate every smaller combination:

```
C(5,2) = 10 pairs
C(5,3) = 10 triples
C(5,4) =  5 quadruples
C(5,5) =  1 quintuple
```

For every ticket, during preprocessing, we increment a frequency counter for each of those subsets. For the ticket `1 2 3 4 5`, the ten pairs `(1,2) (1,3) (1,4) (1,5) (2,3) (2,4) (2,5) (3,4) (3,5) (4,5)` each get bumped — and likewise for its triples, quadruples, and the full quintuple.

After preprocessing, when the draw arrives, we do not touch the player list at all. We generate the *draw’s* own 10 pairs, 10 triples, 5 quadruples, and 1 quintuple, and look each of them up in the precomputed tables. That is just **26 lookups**, so the per-draw complexity is **O(1)** — it no longer depends on the number of players.

> Scan mode asks: “which players match this draw?” Indexed mode asks: “how many tickets already contain each subset of this draw?”

## Dense arrays instead of hash maps

A straightforward implementation might reach for hash maps. But here the universe is small and fixed — numbers are always 1 to 90 — so the number of possible combinations is finite, predictable, and small enough to store densely:

```
C(90,2) =      4,005
C(90,3) =    117,480
C(90,4) =  2,555,190
C(90,5) = 43,949,268
```

```yaml
With 32-bit counters:
  count2:      4,005 × 4 B  ≈  16 KB
  count3:    117,480 × 4 B  ≈ 470 KB
  count4:  2,555,190 × 4 B  ≈  10 MB
  count5: 43,949,268 × 4 B  ≈ 176 MB
  ───────────────────────────────────
  total index memory        ≈ 186 MB
```

That is a reasonable price for constant-time queries, and dense arrays avoid hashing, bucket overhead, pointer chasing, allocation, and GC pressure. The only remaining puzzle is mapping each combination to a unique array index.

## Ranking combinations with combinadics

Each combination needs a deterministic rank. Convert numbers to zero-based first (`1..90 → 0..89`), then for a sorted combination `c0 < c1 < … < c(k-1)` compute:

```
rank = C(c0,1) + C(c1,2) + ... + C(c(k-1),k)
```

```
For a 5-number combination:
rank = C(c0,1) + C(c1,2) + C(c2,3) + C(c3,4) + C(c4,5)Example — combination 1 2 3 4 5, zero-based 0 1 2 3 4:
rank = C(0,1) + C(1,2) + C(2,3) + C(3,4) + C(4,5)  →  a unique index into count5[]
```

The rank becomes the array index, and you write `count5[rank]++`. No maps, no serialized keys, no per-combination heap allocation — just direct counter increments. The binomial coefficients are themselves precomputed once with Pascal's rule, so ranking never does arithmetic heavier than a few array reads and adds:

```
var binomial [91][6]uint64                 // binomial[n][r] = C(n,r)
```

```go
func initBinomialTable() {
    for n := 0; n <= 90; n++ {
        binomial[n][0] = 1
        for r := 1; r <= 5; r++ {
            if r > n { binomial[n][r] = 0; continue }
            binomial[n][r] = binomial[n-1][r-1] + binomial[n-1][r]
        }
    }
}// rank of a sorted, zero-based quintuple c0<c1<c2<c3<c4:
rank := binomial[c0][1] + binomial[c1][2] + binomial[c2][3] +
        binomial[c3][4] + binomial[c4][5]   // lands in [0, C(90,5))
```

For a fixed, small combination space, this is exactly the transformation that turns a general-purpose solution into a high-performance one.

## The subtle part: exact match counts

The indexed approach has one important trap — the bug that bit me in Iteration 3. The raw subset counts are *not* the final answer. A ticket matching all five drawn numbers contains 10 pairs, 10 triples, 5 quadruples, and 1 quintuple, so naively summing pair, triple, and quadruple totals counts it many times over. A four-match ticket similarly contributes 6 pairs, 4 triples, 1 quadruple.

The precomputed tables answer “how many tickets *contain* this pair/triple/quadruple?” But the business wants “how many tickets have *exactly* 2, 3, 4, or 5 matches?” Those are different questions, and conflating them is the most common bug in combination-indexed solutions.

## From subset totals to exact counts

![Diagram of the inclusion–exclusion correction peeling higher-match contributions off each subset total, from m5 down to m2](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/from-subset-totals-to-exact-counts.webp "Why the correction is exactly these coefficients. Higher matches are folded into every lower subset total. Inclusion–exclusion peels them back off, from the most specific count (m5) down to the least (m2).")

Let `T2, T3, T4, T5` be the subset totals for the draw, and `m2, m3, m4, m5` the exact match counts we want. Counting how each exact category contributes to each total:

```
Exactly 2 matches:  C(2,2)=1 pair
Exactly 3 matches:  C(3,2)=3 pairs,  C(3,3)=1 triple
Exactly 4 matches:  C(4,2)=6 pairs,  C(4,3)=4 triples, C(4,4)=1 quad
Exactly 5 matches:  C(5,2)=10 pairs, C(5,3)=10 triples, C(5,4)=5 quads, C(5,5)=1 quint
```

```
So the totals expand as:
  T5 = m5
  T4 = m4 + 5·m5
  T3 = m3 + 4·m4 + 10·m5
  T2 = m2 + 3·m3 + 6·m4 + 10·m5And we solve backward:
  m5 = T5
  m4 = T4 - 5·m5
  m3 = T3 - 4·m4 - 10·m5
  m2 = T2 - 3·m3 - 6·m4 - 10·m5
```

This is the core correction step. Without it, the indexed solution reports inflated counts — a fast wrong answer, which is just a more expensive bug.

## Why the query is constant time

![End-to-end per-draw query: sort and validate, generate ranks, 26 lookups, inclusion–exclusion correction, print — constant work regardless of players](https://akemara.com/en/blog/ai-assisted-performance-engineering/images/webp/why-the-query-is-constant-time.webp "The per-draw query, end to end. Sort and validate, generate ranks, do 26 lookups, correct with inclusion–exclusion, print. The number of operations never grows with the player count.")

Put it together and the post-draw path is fixed work: generate 10 pairs, 10 triples, 5 quadruples, 1 quintuple; do 26 dense-array lookups to get T2–T5; apply the inclusion–exclusion correction; print. Whether there are 10,000 players, 10 million, or 100 million, the draw performs the same amount of work. In code, the entire correction is four lines — and the subset totals accumulate into `int64` even though each stored count is a `uint32`, because a sum of subset totals can run larger than any single count:

```
// total2..total5 are int64 sums of the 26 looked-up counts
winners5 = int(total5)
winners4 = int(total4) - 5*winners5
winners3 = int(total3) - 4*winners4 - 10*winners5
winners2 = int(total2) - 3*winners3 - 6*winners4 - 10*winners5
```

## Building the index without melting the cache

Here is the part the math never warns you about. On paper, preprocessing is trivial: for each player, bump 26 counters — ten million players, 260 million increments, done. So you write the obvious parallel loop, split the players across four cores, let each core increment the shared tables… and your beautiful O(1) query now sits behind a build that *crawls*, because four cores are elbowing each other for the same handful of cache lines.

The culprit is contention, and it is wildly uneven across the four tables. The pair table has only 4,005 slots but absorbs roughly 100 million increments, so a few cache lines run white-hot — atomic adds there would serialize the entire build on them. The quintuple table is the opposite: 44 million slots sharing only ~10 million increments, so collisions are rare. One strategy cannot be right for both, and the real code splits accordingly:

```
// SMALL + HOT (pairs: 4,005 slots, ~100M hits; triples: 117,480 slots):
// each worker counts into a PRIVATE copy, merged by index shard afterward.
local.pairs[binomial[a][1]+binomial[b][2]]++
```

```
// LARGE + SPARSE (quads: 2.5M slots; quintuples: 44M slots):
// atomic adds straight into the shared table — collisions are rare, and
// cloning 176 MB per worker would be absurd.
atomic.AddUint32(&quintupleCounts[rank], 1)
```

So the two small tables get per-worker private copies (zero contention while counting, then a quick shard-by-index-range merge), while the two big tables take atomic increments directly into shared memory (no 10 MB-and-176 MB-per-core duplication, and no merge pass at all). The payoff: a build whose peak memory stays roughly flat as cores are added, doing genuinely O(N) work with an O(N/p) span — no rescanning the dataset once per worker.

**The lesson hiding in the build**

The algorithm said “increment 26 counters.” The hardware said “watch four cores fight over one cache line.” Choosing private-accumulate versus atomic per table, by size and collision rate, is exactly the kind of decision the AI and I traded notes on — and exactly the kind that never survives into the tidy final write-up. Concurrency correctness is cheap to state and expensive to earn.

## Why the indexed solution is not new mathematics

It is worth emphasizing, partly out of honesty and partly because it is the most reusable lesson here, that the indexed solution invents nothing. It is a composition of **four classical results**, none original to it — and they span more than a century.

## 1 · The addressing layer — the combinatorial number system (combinadics)

Representing each sorted k-combination as `C(c₁,1) + C(c₂,2) + … + C(c_k,k)` creates a bijection onto `{0, …, C(n,k)−1}`. The idea was observed by **D. H. Lehmer (1964)**, later named the *combinatorial number system* by **Donald Knuth** in *The Art of Computer Programming, Volume 4A* (2011), who traces the underlying concept back to **Ernesto Pascal (1887)**.

## 2 · The arithmetic — binomial coefficients

Underneath the ranking is nothing more exotic than `C(n,k) = C(n−1,k−1) + C(n−1,k)`, commonly associated with **Blaise Pascal (1654)** though known much earlier in India, Persia, and China. Precomputing this triangle is what makes ranking allocation-free and fast.

## 3 · The counting layer — itemset support counting

Incrementing a counter for every subset contained in every record is essentially *itemset support counting*, the core idea behind the **Apriori** algorithm introduced by **Rakesh Agrawal and Ramakrishnan Srikant (1994)**. Our “tickets” are transactions; our “combinations” are itemsets.

## 4 · The recovery layer — inclusion–exclusion (exactly-k)

Recovering exact winner counts from subset totals uses the exactly-k form of inclusion–exclusion:

```
m_j = Σ_{k≥j} (−1)^{k−j} · C(k,j) · T_k
```

This is associated with **Charles Jordan**, later generalized through the **Schuette–Nesbitt formula**, all resting on the classical inclusion–exclusion principle.

**The actual contribution**

The solution’s only original move is *noticing these four pieces snap together* in a way that makes query cost independent of dataset size. That is engineering, not new mathematics — and recognizing such compositions is precisely where an AI collaborator earns its keep, provided you supply the constraints that force the pieces into place.

## Loading and parsing efficiently

For a 10-million-ticket dataset, parsing can dominate startup time. The input is ASCII text, one ticket per line (`4 16 22 35 78`, `1 2 3 4 5`, …). A naive parser using high-level string splitting and per-line integer parsing is fine for small files but creates real overhead at scale: temporary strings, repeated allocation, expensive conversions, GC pressure.

**Memory-map the file.** Rather than copying the ~145 MB into a heap-owned slice the way `os.ReadFile` would, the file is `mmap`'d read-only: the OS faults pages in straight from its cache, in parallel, as the workers touch them — less heap pressure and free cooperation with the page cache (with a plain-read fallback when mapping is unavailable). Because parsing is the dominant cost, wall-clock load time then scales down roughly linearly with core count.

**Split work by line boundaries.** Parsing can run in parallel, but chunks must never split a ticket line. So the file is divided into byte ranges and each boundary is nudged forward to the next newline, guaranteeing every worker parses complete lines and writes to its own output buffer with no shared writes:

```
File bytes
┌──────────────────────────────────────────────────────────────┐
│ line line line line line line line line line line line line   │
└──────────────────────────────────────────────────────────────┘
          │                    │                    │
          ▼                    ▼                    ▼
   ┌────────────┐       ┌────────────┐       ┌────────────┐
   │ Worker 0   │       │ Worker 1   │       │ Worker 2   │
   │ whole lines │       │ whole lines │       │ whole lines │
   └────────────┘       └────────────┘       └────────────┘
```

Each worker emits its own packed buffer; a prefix sum over the per-chunk sizes hands every chunk a disjoint offset in the final slice, so the buffers are stitched together with a parallel copy and no worker ever overlaps another.

**Then make the inner loop mean.** Two small tricks keep the byte-level parse tight. A single unsigned subtraction decides whether a byte is a digit (if `ch-'0'` wraps above 9, it is not), and the running value is clamped *while* it accumulates, so an absurdly long token can never overflow an `int` and wrap back into the valid range — it is read in full, then rejected as out of range:

```
if ch-'0' > 9 {              // unsigned wrap-around: "is this a digit?"
    valid = false
}
// ...
if value <= maxNumber {      // clamp WHILE accumulating: no overflow, ever
    value = value*10 + int(digit)
}                            // 99999999999999999999 is read, then rejected
```

The output buffer is sized to a provably safe upper bound — a valid ticket consumes at least nine input bytes and emits five — so it appends without ever risking an overrun.

**Validate while parsing.** A valid ticket has exactly five distinct numbers in 1..90 — cheap to check. But validation *policy* is a product decision, not a performance one. *Strict mode* rejects the whole input if any line is malformed (right when every line is a real transaction); *lenient mode* skips bad lines and reports how many (useful for best-effort ingestion). Make the choice explicit — silent data loss is not an optimization.

## Benchmarks & interpretation

I benchmarked both implementations on a deliberately constrained environment:

```yaml
CPU:      4 logical CPUs used by the Go runtime
RAM:      4 GB
Go:       go1.23.2 linux/amd64
Setting:  GOMAXPROCS=4
Draws:    1 2 3 4 5  |  10 20 30 40 50  |  5 15 25 35 45
Metrics:  time-to-READY, per-draw latency, max RSS (/usr/bin/time -v)
```

A word on how the program runs, since it shapes what these numbers mean. It loads the file, prints `READY`, then reads draws from stdin and answers each on stdout, flushing per draw so the consumer gets every result immediately. Diagnostics go to stderr so the answer stream stays clean. Crucially, the per-draw timing brackets *only the computation* — not the line parsing or the printing — so a draw latency reflects the calculation itself, independent of I/O.

**1 million players:**

```
MODE      TIME→READY   DRAW1     DRAW2     DRAW3     AVG DRAW     MAX RSS
──────────────────────────────────────────────────────────────────────────
Scan       23.69 ms    0.884 ms  0.882 ms  0.915 ms  0.894 ms     40.5 MiB
Indexed   177.77 ms    1.472 µs  0.650 µs  0.628 µs  0.917 µs    202.9 MiB
```

At 1M players, scan mode is already fast enough — but indexed mode answers roughly **three orders of magnitude faster per draw**, in exchange for more time before READY and more memory.

**10 million players:**

```
MODE      TIME→READY   DRAW1      DRAW2       DRAW3       AVG DRAW    MAX RSS
──────────────────────────────────────────────────────────────────────────────
Scan      210.64 ms    7.978 ms   15.017 ms   13.687 ms   12.227 ms   255.9 MiB
Indexed    1.220 s     2.649 µs    1.044 µs    0.791 µs    1.495 µs   301.5 MiB
```

At 10M players, scan mode still performs well because it is streaming compact contiguous memory across four cores — an average of about **12.2 ms** is very respectable. But indexed mode is in a different class, answering in about **1.5 µs** after the index is built. The difference is simply: scan touches *every ticket* after the draw; indexed touches *26 counters*.

## Putting the gap on a bar

```
10 million players · average draw latency
```

```
Scan mode     12.227 ms  ██████████████████████████████████████████████████
Indexed mode   0.0015 ms  ▏ratio ≈ 12.227 ms / 0.001495 ms ≈ 8,178×
```

So indexed mode was roughly **8,000× faster per draw** here. That does not make scan mode bad — it means the two modes optimize different parts of the system.

## Startup vs. query latency (the break-even)

```yaml
10 million players
  Scan:     before READY ~211 ms   |  per draw ~12 ms
  Indexed:  before READY ~1.22 s   |  per draw ~1.5 µs
```

```
extra startup cost of indexed   = 1.220 s − 0.211 s ≈ 1.009 s
per-draw saving                 = 12.227 ms − 0.0015 ms ≈ 12.2255 ms
break-even                      = 1.009 s / 12.2255 ms ≈ 83 draws
```

If you only care about total runtime on this machine and dataset, indexed mode pays back its extra preprocessing after about **83 draws**. But total runtime is often the wrong metric: if the requirement is *minimum latency after the draw*, indexed mode is justified even for a single draw, because it moves work out of the critical path.

On memory: measured max RSS was ~256 MiB (scan) and ~302 MiB (indexed). The theoretical tables are ~186 MB plus ~50 MB of packed tickets; measured RSS also includes runtime overhead and mapped pages, so it will not match exactly. One small but deliberate touch: once the indexed tables are built, the program drops the ~50 MB ticket buffer (`packedTickets = nil`) and forces a GC, since indexed draws read only the tables — keeping steady-state memory closer to the tables themselves. Both modes fit comfortably in 4 GB.

## Comparing the two approaches

```
DIMENSION                  SCAN MODE                 INDEXED MODE
────────────────────────────────────────────────────────────────────────────
Preprocessing              minimal                   heavier
Per-draw complexity        O(N)                      O(1)
Memory usage               lower                     higher
Implementation complexity  lower                     higher
Scaling with players       linear per draw           constant per draw
Debuggability              very high                 requires stronger validation
Best use case              low memory, baseline      lowest post-draw latency
```

Choose **scan mode** when memory is constrained, the dataset is modest, draws are rare, or you want a correctness baseline. Choose **indexed mode** when post-draw latency is critical, there may be many queries, the player count may grow, and enough memory is available. Indexed mode is not “better” in every dimension — only for the one goal of minimizing critical-path latency. That is the engineering tradeoff.

## Performance lessons from this problem

The Hungarian lottery problem is small, but it demonstrates several principles that apply to many performance-heavy systems.

## 1. Optimize the critical path

The critical path is the work that must happen while someone is waiting.

Here, that is the post-draw report.

Indexing is valuable because it moves work from the critical path into the preparation phase.

This pattern appears everywhere:

- search engines build inverted indexes before queries
- databases maintain indexes before reads
- compilers build intermediate representations before optimization
- recommendation systems precompute features before serving
- analytics systems aggregate data before dashboard queries

Precomputation is not an implementation detail.

It is a latency strategy.

## 2. Data layout matters

The scan solution is fast because the data layout is simple and compact.

A flat byte array is not glamorous, but it works well.

Many performance problems are not solved by more abstraction. They are solved by fewer cache misses.

Before optimizing arithmetic, look at memory access patterns.

## 3. Avoid allocations in hot paths

The draw path should not allocate per ticket.

The indexed query path should not allocate per combination.

The parser should avoid temporary strings where possible.

Allocation is not always expensive, but repeated allocation in hot paths creates secondary costs:

- allocator overhead
- garbage collection
- memory fragmentation
- reduced locality

## 4. Measure phases separately

A single “total runtime” number can be misleading.

For this problem, scan mode may start faster, while indexed mode answers faster.

Neither number alone tells the full story.

Performance-heavy systems usually have multiple important latencies:

- cold start
- warm query
- p50 latency
- p99 latency
- peak memory
- throughput
- tail behavior under concurrency

Measure what matters.

## 5. Keep a simple implementation around

The scan implementation is useful even when indexed mode is the default.

It helps validate correctness.

It provides a fallback.

It gives future engineers a simpler mental model.

In high-performance systems, complexity tends to accumulate. A clear baseline keeps the project grounded.

## 6. Exact semantics matter

The indexed solution’s biggest risk is not performance.

It is semantics.

Subset counts are not exact match counts.

That difference is easy to miss, and it completely changes the answer.

Performance work must never blur the meaning of the result.

A fast wrong answer is just a more expensive bug.

## Closing thoughts

This problem starts as a lottery-counting exercise, but it quickly becomes a performance engineering problem.

The core lesson is not specific to lotteries.

The broader lesson is:

> *Move work out of the critical path when the problem allows it.*

The scan solution shows how far careful data layout and a simple CPU-friendly loop can go.

The indexed solution shows how preprocessing can change the runtime model completely.

On a 4 CPU / 4 GB RAM environment, scan mode handled 10 million players with an average draw latency of about **12 ms**. That is already very fast.

Indexed mode handled the same 10 million players with an average draw latency of about **1.5 microseconds** after preprocessing.

## The Bottom Line

Ultimately, software engineering rarely hands us a single “correct” path only trade-offs. Both solutions are incredibly valuable; they simply optimize for entirely different dimensions of performance.

- **Sometimes, the right answer is minimalist:** a flat array paired with a tight, cache-friendly loop for raw speed.
- **Sometimes, the right answer is structural:** a precomputed index that slashes lookup times as complexity scales.

The most resilient architectures don’t treat these patterns as rivals. They recognize that the best system often doesn’t force a choice between the two, it harmoniously leverages both.
