curayious Language Lab

logagg — the one program, written in every language

The constant of this series. The languages are the variable; this program and the ten questions are the constant. Every track implements logagg to the letter of this spec, and the verifier diffs every implementation's output against the same golden file. Agreement is evidence you actually understood the language, not just that you got something to compile.

Why this program

It was chosen because it forces every language to show its hand on the dimensions that actually differ between languages, and on nothing else:

What logagg needs What it reveals
Read a file line by line I/O model, buffering, encoding, what a "string" is
Split a line into fields String handling, allocation, ownership
Parse integers and a decimal Numeric model, parse-failure handling
Accumulate into a keyed map The standard collection, hashing, mutation
Sort by a compound key Comparators, closures, stability
Emit a fixed-width report Formatting, rounding, locale traps
Reject bad input without crashing The error model — the biggest difference of all
(stretch) shard the work The concurrency model

It is small enough to hold in your head and large enough that an idiomatic Rust version and an idiomatic Erlang version look nothing like each other. That difference is the lesson.


1 · Input

A plain-text log file. One record per line. Fields are separated by runs of whitespace (one or more spaces or tabs). Exactly six fields per record:

<timestamp>  <service>  <method>  <path>  <status>  <latency_ms>
2026-09-12T08:15:22Z  api-gateway  GET   /v1/accounts        200  12.480
2026-09-12T08:15:22Z  ledger       POST  /v1/postings        201   4.005
2026-09-12T08:15:23Z  api-gateway  GET   /v1/accounts/9931   503  91.200
Field Rule
timestamp Opaque. Non-empty. Never parsed — dates are a rabbit hole and this program does not need them.
service The aggregation key. 1–20 chars, no whitespace.
method Opaque, non-empty.
path Opaque, non-empty.
status Exactly three ASCII digits, 100–599.
latency_ms digits, ., then exactly three digits — 12.480, 0.001, 1204.000. No sign, no exponent.

Blank lines (empty or whitespace-only) are skipped silently and counted in neither ok nor malformed — they get their own counter.

Malformed records

A line is malformed if any of the following hold, checked in this order — first match wins, and that is the reason reported:

  1. FIELDS — the line does not have exactly six whitespace-separated fields
  2. STATUS — status is not exactly three digits, or is outside 100–599
  3. LATENCY — latency_ms does not match ^[0-9]+\.[0-9]{3}$

A malformed line is counted, its line number and reason recorded, and processing continues. It contributes nothing to any service's statistics.

This ordering is observable and is part of the spec, exactly as the validation order in the COBOL track's ACCTRPT is. An implementation that reports all failures for a line, or checks latency before status, produces different output and fails the diff — even though it is arguably the better program. Legacy behaviour is like this, and the discipline of matching it byte for byte is most of what modernization work actually is.


2 · The arithmetic rule (read this twice)

All latency arithmetic is done in integer microseconds. A conforming implementation contains no floating point anywhere.

Parse 12.480 by taking the digits before the . as whole milliseconds and the three digits after as thousandths of a millisecond. Combine:

micros = ms_int * 1000 + frac_int          # 12.480 -> 12 * 1000 + 480 = 12480

Accumulate sum_micros as a 64-bit signed integer. Track max_micros the same way.

The mean is an integer division with explicit half-up rounding:

mean_micros = (sum_micros + count / 2) / count       # integer division, truncating

The error rate, in tenths of a percent, likewise:

err_tenths = (errors * 1000 + count / 2) / count

Why go to this trouble

Because if you hold latency in a double and format with %.3f, C and Python round half-to-even while Java rounds half-up. Roughly one value in a thousand differs. You would lose an afternoon to it and conclude that one of the three languages is broken. None of them is — IEEE 754 and java.util.Formatter simply made different, defensible choices.

Pinning the arithmetic to integers removes that variable so the language differences stay visible. It is also exactly why COBOL has COMP-3 and an explicit ROUNDED clause, and why no banking system on earth keeps money in a double. The first real lesson of the series arrives before you have written a line of code.


3 · Aggregation

Group by service. Per group track count, errors (records where status >= 500), sum_micros, max_micros.

Sort for output by:

  1. count descending
  2. then service ascending, byte-wise ASCII — not locale-aware, not case-folded

The tie-break is mandatory. Without it, two implementations with different hash-map iteration orders disagree and you learn nothing except that hash maps are unordered.


4 · Output

Written to stdout. ASCII only. Unix line endings (\n) — not \r\n, including on Windows. Exactly this shape:

logagg 1.0
source: access.log
lines: 200  blank: 3  ok: 191  malformed: 6

SERVICE                 COUNT   ERRORS    ERR%     MEAN_MS      MAX_MS
----------------------------------------------------------------------
api-gateway                69        5     7.2      39.115     267.435
ledger                     45        0     0.0       5.366      17.028
auth                       35        3     8.6      12.802      37.407
search                     22        7    31.8     151.582     427.023
notify                     14        1     7.1      22.982      66.989
batch-reconcile             6        4    66.7    4788.934    9608.826
----------------------------------------------------------------------
TOTAL                     191       20    10.5     187.323    9608.826

malformed:
  line 7: FIELDS
  line 31: STATUS
  line 52: STATUS
  line 73: LATENCY
  line 98: LATENCY
  line 120: FIELDS

Formatting rules

Column Width Alignment
SERVICE 20 left, space-padded, truncated at 20
COUNT 9 right
ERRORS 9 right
ERR% 8 right, one decimal
MEAN_MS 12 right, three decimals
MAX_MS 12 right, three decimals

Columns are separated by nothing — the widths above are the whole story, so the header line is "SERVICE" left-padded to 20, then "COUNT" right-aligned in 9, and so on. The rule line is exactly 70 - characters.

Millisecond values are rendered from integer microseconds by printing micros / 1000, a ., then micros % 1000 zero-padded to three digits. Never by dividing into a float.

The TOTAL row aggregates across all services. Its MEAN_MS is over the whole population (total_sum_micros against total_count), not the mean of the per-service means. Its MAX_MS is the max over all services.

The malformed: block — blank line and header included — is omitted entirely when there are none. When present it lists every malformed line, in line order.

If ok is zero, the table has no data rows and the TOTAL row shows 0, 0, 0.0, 0.000, 0.000.


5 · Invocation and exit codes

logagg <path-to-log>

Exactly one argument. The exit code follows the JCL convention from the COBOL track, deliberately:

Code Meaning
0 Clean run, no malformed records
4 Completed, but one or more records were malformed. The report is still written to stdout.
12 Fatal: wrong argument count, or the file could not be opened. Message to stderr, nothing on stdout.

6 · The stretch goal — --shard N

Once the single-threaded version passes, add --shard N: split the file across N workers and merge the partial maps. Output must stay byte-identical to the serial run.

This is where the languages stop resembling each other at all:

Track What sharding looks like
C pthread_create, plus either a mutex around one map or per-thread maps merged at join
Java 8 ExecutorService + Future, or parallelStream().collect(...)
Python multiprocessing — and a paragraph on why threading buys nothing here
Go N goroutines, a chan partial, one collector
Rust rayon, or scoped threads with the borrow checker proving the merge sound
Erlang spawn per chunk, gather by message; there is no shared memory to guard
COBOL You don't. Explain what a batch window is instead.

Skip it on the first pass of a track. Come back after question 6.


7 · Conformance

python bench/verify.py

Builds and runs every implementation, diffs stdout against the golden file, and checks the exit code. bench/expected/logagg.txt is generated once from the Python reference implementation and then frozen — it is checked in, and changing it means changing this spec.

A track is done when its output is byte-identical and its exit code matches.