Nine Engines, One Answer
How EPA SWMM 5.2.4 was carried into Fortran, C#, Python, TypeScript, Java and Go without changing one bit of what it computes
The question, and the only honest way to answer it
Bob Dickinson asked how many languages the SWMM5 engine could be carried into. He already had two answers in hand: a C++ restructuring called swmm5-cplus, and a Rust engine. The survey that followed sorted the candidates into languages that can run the engine one way or another and the smaller set that can hold a native port matching EPA byte for byte on Linux. Then he asked for three from that set: Fortran, C# and Python. When those three matched, he asked for two more, TypeScript and Java, and they turned out to need something the first three did not: a copy of the C library's mathematics. Last, he asked whether any of the other languages in his Code Rosetta Stone app had a chance. A quick survey (gate/lang_survey.sh) tried four of them against glibc: each one's own math functions on 100,000 arguments apiece, and its printf on twenty awkward cases. Perl and Ruby hand their math to the C library, so it matched; Perl's printf matched too, while Ruby's printed five of the twenty differently and Free Pascal's eight. Go's printf matched on all twenty. Its own math did not, pow disagreeing on 46 arguments in 100, but Go compiles to machine code, which Perl does not, and the copy of glibc's math already built for TypeScript and Java could be taught to speak Go. Bob chose Go, and it was the sixth.
There is an easy way to do that job. You write a new engine in each language and show that it "agrees with EPA to three decimals." What you have then is a new program. It has bugs of its own, and nobody can tell them from EPA's, because agreement to three decimals hides everything in the fourth.
The other way sets the bar where nobody can argue with it. The same input file must produce the same binary results file, every byte, and the same report file, every character, except the three lines that print the wall clock. One bit different and the port is wrong. No bits different, on hundreds of models, and whatever else the port may be, it computes what EPA computes.
That is a hard bar. It is also the only one a machine can check for you, and a check you can hand to a machine is worth ten you have to argue about.
What this is
Nine engines are lined up against 480 models from Bob's 1729-SWMM5-Models-2030 corpus: EPA SWMM 5.2.4 compiled from the USEPA v5.2.4 tag, which is the reference; swmm5-cplus; the Rust engine; and the six new ports. The app shows, model by model, whether each engine's .out and .rpt match the reference, what the models contain, what their results look like, and what each engine costs to run. This essay explains how the ports were built, how they were judged, and how far the evidence reaches.
Two of the nine were not built here. swmm5-cplus is EPA restructured as a C++ class, and swmm5-rust is a translation into safe Rust (the engine crate forbids unsafe code), made in separate sessions and kept in their own repositories. Both were built from those repositories (swmm5-rust at commit de9fbf1) and put through the same gate as the six new ports, with the same reference and the same 480 models.
Don't translate by hand
EPA's engine is 50,788 lines of C, headers included, with the code in 54 .c files. Suppose a careful engineer translates a thousand lines a day and slips once in every few hundred. That is two months of work and a couple of hundred slips, and every slip is a place where the port quietly disagrees with EPA. You would then spend another two months finding them, one model at a time.
So nobody translated by hand. We wrote a translator instead. It reads EPA's C through libclang, the parser inside the Clang compiler, which means it sees the program exactly as a compiler does: every macro expanded, every type resolved, every conversion the C language performs without saying so. One front end builds the tables (968 functions, 626 global variables, 103 structs and unions, 714 enum constants) and works out what every pointer is for. Six back ends then write Fortran, C#, Python, TypeScript, Java and Go.
One rule did more good than any other: when a port disagreed with EPA, the fix went into the translator (or, for a C library function, the hand-written runtime), never into the generated code. Patch the generated file and the next regeneration quietly undoes your patch. Patch the translator and every function that shared the mistake is fixed at the same time, including the ones no test had reached yet.
| Piece | Lines | Made by |
|---|---|---|
| EPA SWMM 5.2.4 solver (C) | 50,788 | EPA; untouched |
| Translator: front end + six back ends (Python) | 10,231 | written for this job |
| Fortran engine (one module) | 46,364 | generated |
| Fortran runtime + printf/scanf/strtod | 4,048 | hand-written |
| C# engine | 41,429 | generated |
| C# runtime | 1,130 | hand-written |
| Python engine | 31,568 | generated |
| Python runtime | 1,253 | hand-written |
| TypeScript engine | 41,192 | generated |
| TypeScript runtime | 971 | hand-written |
| Java engine (one class per C file) | 44,855 | generated |
| Java runtime | 1,078 | hand-written |
| Go engine (one file per C file) | 40,094 | generated |
| Go runtime | 1,846 | hand-written |
| glibc math routines in TypeScript / Java / Go | 5,011 / 4,943 / 5,376 | generated from GCC's GIMPLE; glibc's wrappers and TypeScript's pow fast path come from hand-written text in the translator |
| Math translator (with that hand-written text), census and table tools | 1,508 | written for this job |
| Math runtimes (fma, bit casts, branred) | 551 | hand-written |
The hand-written parts are small and dull on purpose: the C library functions SWMM calls (printf, sscanf, strtod, the FILE functions, strtok), plus two whole C files whose pointer tricks are easier to port by hand than to teach a translator, odesolve.c and mempool.c, and six helper functions. Everything else, 951 functions of engineering, came out of the translator.
A pointer is seven different things
C has one pointer type, and SWMM uses it for seven different jobs. double* x may be an array you index as x[i]. It may be one number passed by address so the callee can change it, as in findroot_Newton(..., &d, ...). TNode* node may be a single object you reach through with ->. A char* is a string. A FILE* is a file. A void* is "whatever the caller had in mind." And a pointer to a function is a callback. In C these are one thing. In Fortran, C#, Python, TypeScript and Java they are seven different things, and the translator has to know which one it is looking at before it writes a single line. Go has real pointers, so for Go some of the seven fold back together; the arrays do not, because Go has no pointer arithmetic, and an array is a slice.
Some of the seven are settled by the declaration alone: a char* is a string, a FILE* a file, void* and function pointers are what they say, and an array declared as an array is an array. The rest, pointers to numbers, to structs and to other pointers, are where the type tells you nothing. For those the translator watches what the program does with each pointer, everywhere in all 54 files: whether it is indexed, moved with pointer arithmetic, assigned from malloc, or handed an array. A pointer that is ever used as an array is an array, and so is every parameter it is passed into; a struct pointer that never is, is one object. Callbacks bound to the same function-pointer type must agree on their parameters, so the translator makes them agree.
void*, callbacks and declared arrays are known from the declaration; for pointers to numbers, structs and pointers the uses decide.A string is an address into bytes
SWMM reads its input one line at a time and chops each line into tokens with strtok, which writes zero bytes into the line and hands back pointers into the middle of it. A Fortran CHARACTER variable, a C# string, a Python str, a JavaScript string, a Java String and a Go string cannot do any of that. They are values, not addresses.
So each port carries C's idea of a string with it: a byte buffer plus an offset. The Fortran version is type(CPtr), an int8 pointer array and an integer. strtok works on it exactly as glibc's does. It writes the zeros and returns offsets into the same buffer, so a later strcpy into that buffer sees precisely what EPA's would see. When you want a byte-identical port, you carry the machine model along with it, not just the algorithm.
Same numbers means the same bits
IEEE 754 double arithmetic is the same on every machine you are likely to meet. Add, subtract, multiply, divide and square root give the correctly rounded answer, everywhere. If that were the whole story, byte-identical ports would be easy. It is not, and the trouble always comes from the places where a language leaves the result up to the implementation.
Conversions. C says converting a double that is too large into an int is undefined. The x86 instruction gcc emits returns the most negative integer. SWMM hits this by accident in gage.c, line 251, when a rain time series has a single point and dxMin is still its initial huge value:
gageInterval = (int)(floor(Tseries[k].dxMin*SECperDAY + 0.5));
On x86 that gives -2,147,483,648, and EPA prints no warning. Rust's as and Java's (int) saturate to +2,147,483,647 instead, and the same model prints WARNING 09; WebAssembly saturates or traps, depending on whether the toolchain emits its non-trapping conversion. Fortran's int() and C#'s (int) make no promise either way, so every port routes every double-to-integer conversion through a function that spells out the x86 rule.
The math library. exp, log, pow, sin and the rest are not required to round correctly, and different libraries disagree in the last bit. Fortran, C# and Python call the same glibc library EPA calls: C# through the .NET runtime on Linux, Python through its math module, Fortran through gfortran's built-ins. JavaScript, Java and Go do not; V8, the Java virtual machine and Go's standard library bring math functions of their own. That story needs a section to itself, further on.
Fused multiply-add. A compiler that turns a*b + c into one fused instruction rounds once instead of twice. That is more accurate and it is a different answer. EPA's build does not fuse. The Fortran build says -ffp-contract=off so it never does, and the controls below show what happens when it does. Go has no supported switch for it, only a compiler debugging flag; how the Go port keeps its compiler from fusing is told in its own section.
NaN. Zero divided by zero on x86 produces a NaN with its sign bit set, and glibc prints it as -nan. EPA's reports carry such cells in plain sight, in tables such as the node depth summary. The reference reports in this gate contain 292 of them. Python's float('nan') and JavaScript's NaN have the sign bit clear and would print nan. The ports carry the x86 NaN instead.
The report is arithmetic too
The .rpt file is text written by fprintf, so it is a second calculation stacked on the first. %.2f does not mean "the number to two decimals." It means: take the exact value of the double, which is a binary fraction, and round that, with ties going to the even digit. The double nearest 2.675 is a hair below 2.675, so it prints 2.67. 0.125 is exact in binary and sits on the tie, so it prints 0.12.
The ports do not leave this to luck. The C# port carries its own exact formatter built on big-integer arithmetic. The Python port uses Python's own % formatting, which rounds the exact binary value half to even just as glibc does, and adds glibc's spellings of infinity and NaN. The Fortran port carries a formatter written in pure Fortran, and that one was checked against glibc 2.39 directly: 52,402,636 printf cases, 531,658 sscanf cases and 248,060 strtod cases, with 0 mismatches. Those cases cover random magnitudes, exact binary ties, decimal spellings like the ones in SWMM input files, subnormals, infinities and both kinds of NaN. The Java port rounds with new BigDecimal(x).setScale(p, RoundingMode.HALF_EVEN), which is glibc's rule stated in Java. The TypeScript port uses JavaScript's toFixed, which is exact but breaks ties away from zero, and repairs the ties: one can only occur when x times 2 to the power p+1 is an odd integer, a test that costs one multiplication. The C#, Python, TypeScript and Java formatters were checked by the gate alone.
The Go port formats with Go's own strconv, whose fixed and exponent conversions are exact and break ties to even on the binary value, as glibc's do, and builds %g from them the way the C standard describes. It was the second formatter checked against glibc directly, with a harness written for it (tools/crt/test/mkfmt.c writes glibc's answers, go/internal/crtcheck compares): 1,194,187 printf cases, 200,052 strtod cases and 13 sscanf cases, with 1 mismatch. It is glibc's, not Go's: %#g of 999999.5 prints 1.e+06, dropping the zeros the # flag exists to keep (the Go runtime prints 1.00000e+06), a quirk of a flag SWMM never uses. The same harness found two spellings glibc's strtod reads that the Java runtime, which the Go one was ported from, did not: hexadecimal floating constants such as 0x1p3, and NaN payloads such as nan(123). No gate model contains either (gate/results/strtod_inputs.txt); the Go runtime now reads both. The Fortran runtime's tests already included both spellings and it reads them; the C#, Python, TypeScript and Java runtimes were not tested for them.
Carrying the math library along
Before copying a library it pays to know which parts of it are used. A small interposer, loaded in front of glibc while EPA ran all 480 models, counted every call. pow was called 1,235,269,125 times, in 462 models; sin and cos 135,803,579 and 136,429,895 times; exp 13,743,401; log 3,256,385; log10 135,286; acos and asin 277,501 and 45,499, in three models; tan 453 times, once per model; atan never. sqrt never reaches the library at all, because the compiler turns it into a single instruction, which is correctly rounded everywhere.
Then came a surprise. glibc does not have one pow. On x86-64 it has several, and it picks one when the program starts, according to the processor: on one with fused multiply-add and AVX2, as this machine has, it takes a version compiled to use fused multiply-add, which rounds once where the generic version rounds twice. The same goes for exp, log, sin, cos, tan, asin, acos and atan. So EPA's own answers depend on the processor. Told to use the generic versions, as glibc would on an older machine, the same EPA binary on the same input files gave 437 of 480 models identical and changed 43.
For Fortran, C# and Python this should change nothing: they call the same library EPA calls, so on a given machine they should get the version EPA gets (only EPA was run on the generic routines). JavaScript and Java bring their own. Held against glibc on the random and census arguments of the test described below, V8's Math.pow returns a different double on 9.09% of them and its Math.exp on 7.94%; Java's Math.pow on 0.08%, and StrictMath.pow, Java's port of the old Sun library fdlibm, on 8.73%. One argument in 1,200 sounds harmless until you remember that pow is called a billion times. Go's math package, its own code with parts ported from FreeBSD's libm and the Cephes library, is the furthest off of the three on eight of the ten functions (not on log or log10): math.Pow differs from glibc on 35.85% of the same arguments, math.Exp on 10.65% and math.Cos on 26.13%. The TypeScript, Java and Go engines therefore carry glibc's routines with them, and not the generic ones: the fused versions EPA actually runs here.
Was that necessary, or only careful? Each engine was built a second time with its own language's Math in place of the translations, and gated on the 40 control models. The TypeScript copy differed from EPA on 6 of them; the Java copy on 1, and on 38 of all 480 when it was run on the whole set, about as many as EPA itself changes on glibc's generic routines; the Go copy, with Go's math package, on 10 of the 40 and 108 of all 480. Java's Math comes much closer than V8's, as its rate on pow, the function called most, promised (on log10 it is the worse of the two), but none of them would have passed the gate. (gate/run_ts_native_control.sh, gate/run_java_native_control.sh, gate/run_go_native_control.sh.)
Translating them by hand from glibc's C would not do, for the same reason that matters everywhere else in this essay. The source says a*b + c; whether that becomes one fused instruction or two rounded ones is the compiler's decision, and the answer is in the compiled code, not the source. So the translation starts from the compiler. glibc 2.39's sources were compiled with the flags glibc uses for its FMA versions, plus two that turn vectorization off so that every operation stays scalar (whether that changed any result is exactly what the comparison below would catch), and GCC was asked to print its optimized intermediate form, GIMPLE, in which every fusion it chose is spelled out as .FMA, .FMS or .FNMA. A second translator turns that GIMPLE into TypeScript, Java and Go, block by block. The constant tables were copied out of the compiled objects byte for byte. Java has an exactly rounded Math.fma, and so does Go, math.FMA; JavaScript has none, so the TypeScript runtime builds one from Dekker's exact product and Boldo and Melquiond's rounding to odd, with a slow exact path for the awkward cases. Nothing compiled from glibc was ever run: the reference is the system's own library.
Then the translations were held against the real library, argument by argument: random arguments over the ranges SWMM uses, every argument the census sampled from the real models, and a set of edge cases (huge angles, negative bases with integer powers, overflow, underflow, subnormals, domain errors). Go: 21,992,590 arguments, 0 differences; Java: 21,992,590 arguments, 0 differences; TypeScript: 21,992,590 arguments, 0 differences. Every function, every language: not one bit different, and not one NaN with the wrong sign.
The comparison earned its keep twice, and both times the fault was the translator's. pow came out wrong for negative bases raised to even powers, though exp and log were already exact. glibc's source asks whether the lowest integer bit of the exponent is set. GCC rewrote that test as a conversion to a one-bit boolean, and in GIMPLE such a conversion keeps the low bit; the translator had read it as C's "not zero" (gate/results/checkint_evidence.txt):
if (iy & (1ULL << (0x3ff + 52 - e))) /* glibc 2.39 e_pow.c:264, checkint */
_119 = _54 >> _115; /* GCC 13.3 GIMPLE of the FMA build */
_10 = (_Bool) _119;
The second: to skip if (...) statements, the translator skipped every line that began with "if", including assignments to GCC's temporaries, which are named iftmp. That one failed loudly, but only on pow's special cases. The first would not have shown up in a spot check of positive bases.
One more thing came out of the census. 81 percent of the calls are pow, and JavaScript has no 64-bit integers except BigInt, in which the translation does its 64-bit steps. So TypeScript's pow has a hand-written fast path for the common case that does exactly what the translation does, with the 64-bit steps done on 32-bit halves and four of the fused multiply-adds replaced by cheaper operations that give the same bits (an exact product, and the exact rounding error of a product). Everything else falls through to the full translation. The two were compared on 20,000,000 further arguments: 0 differences. The saving is smaller than I expected. On two million arguments the fast path takes 636 nanoseconds a call and the full translation 917; V8's own Math.pow takes 120 (gate/results/powtime.txt). What remains includes the sixteen fused multiply-adds the runtime must still build out of ordinary arithmetic.
What Fortran made hard
C# and Python are near cousins of C, in the ways that matter here. Fortran is older and stricter, and it brought four problems of its own.
No short-circuit. In C, p != NULL && p->x > 0 never touches p->x when p is NULL. Fortran's .and. makes no such promise, and gfortran will evaluate both sides when it pleases. An early test at -O0 crashed on exactly SWMM's kind of guard. So the translator keeps a plain .and. only when the right side provably cannot fault or change anything. Otherwise it evaluates the right side inside an IF on the left side.
Names ignore case. C's node and Node are two variables. To Fortran they are one. The translator renames every clash, and it also keeps clear of Fortran's intrinsic names, so a C local called index or size does not hide the intrinsic of the same name.
Statements are statements. C lets you assign inside a condition, if ((f = fopen(...)) == NULL), and lets a switch fall through from one case into the next. Fortran allows neither. The translator hoists the assignment into its own statement and turns fall-through into numbered labels, so the order of execution is exactly C's.
Small surprises. gfortran numbers the units it opens with negative integers. The first Fortran run simulated a whole model and wrote two empty files, because the runtime took a negative unit to mean "closed." The lesson is in the repository: never encode state in a value the compiler picks for you.
What JavaScript and Java made hard
TypeScript and Java look more like C than Fortran does, and each still found its own ways to be different.
In JavaScript every number is a double. An int is a double that happens to hold a whole number, and doubles have a negative zero. In C, -i for i = 0 is zero; in JavaScript it is -0, and so are 0 * -3, -3 % 3 and Math.trunc(-0.5). Nothing goes wrong until that value becomes a double in a report, where glibc prints it as -0.00. So the TypeScript back end writes integer negation as (0 - x), multiplication as Math.imul(a, b), division as (a / b) | 0 and remainder as (a % b) | 0, each of which can only give +0, and keeps unsigned values in range with >>> 0.
NaN has two signs, and JavaScript's literal has the wrong one. At run time V8 computes 0/0 the way the processor does and keeps the bits, through arrays and object fields alike (checked by tools/libm/test/nan_bits.js), but the literal NaN is the other pattern. So the runtime never writes the literal where C would compute a NaN. The same care turned up a small fact about glibc itself: its log10, asin and acos on x86-64 still go through old compatibility wrappers, which answer a domain error with a NaN whose sign bit is clear, while the routine underneath would have set it. The ports copy the wrappers.
Neither has pointers or ref parameters, and Java will not let a lambda change a local. A C function that takes double *f gets a small object with a value in it: a box for a local whose address is taken, an element reference for &a[i], a field reference for &link->flow. Arrays of numbers become Float64Array and its kin in TypeScript, double[] in Java. In Java a field reference is a pair of lambdas over the object, __o -> __o.depth, because a lambda may read the object it is given but may not assign a local it captures.
Java has no unsigned integers and rejects code C allows. SWMM's one hash function uses unsigned arithmetic, which Java does in a long masked to 32 bits. Java's (int) of a huge double saturates where x86 gives the most negative integer (defect 2 below), so every conversion goes through R.D2I, exactly as in the other ports. And Java refuses a statement it can prove unreachable, such as the break after a return that C programmers write out of habit; the Java back end applies Java's own reachability rules to the C and leaves such statements out.
What Go made easy, and what it made hard
Go is the nearest thing to C in this set. It has real pointers, structs that copy the way C's do, integers that wrap the way the processor's do, and a compiler that writes machine code. That took most of the work of the last section away. There are no boxes and no element references, because &x in C is &x in Go and a TNode* is a *TNode. What Go lacks is pointer arithmetic, so an array is a slice: &a[i] handed to a function that indexes it becomes a[i:], and a single number whose address goes to code that indexes it, as subcatch.c does when it hands one depth to the ODE solver, becomes a one-element slice laid over the variable itself, so nothing has to be copied back afterwards.
Nothing converts by itself. Go refuses to add an int32 to a float64, or to compare an int32 with an int64, until you say which you mean. C converts silently at every turn. The front end already writes down every one of those silent conversions, because Fortran and Python needed them, so the Go back end spells each one out.
The compiler may fuse, unless it is told not to. The Go specification lets a compiler turn x*y + z into one fused operation, even across statements, and names the one way to forbid it: an explicit conversion, float64(x*y) + z. So the Go back end writes every floating-point operation inside a conversion. The permission is not theoretical. Compiled for 64-bit ARM, the plain form of that line becomes one FMADDD instruction and the converted form a separate FMULD and FADDD (go/internal/fusedemo, gate/check_go_fma_arm64.sh). Built for ARM, the whole engine holds 392 fused instructions in the translated glibc routines, 6 in Go's own runtime, mostly its garbage collector, and 0 in the SWMM code. On x86-64 this compiler fuses nothing, converted or not, even when told the processor has the instruction (GOAMD64=v3); disassembled with GNU objdump, because Go's own disassembler misreads the x86 encoding, the shipped engine holds 392 fused instructions, all in the 9 glibc routines that fuse on purpose, and 0 anywhere else (gate/check_go_fma.sh).
Constants are exact, and C's are not. Go works out constant expressions with unlimited precision and rounds once, at the end. Write 0.1 * 3.0 with two literals and Go gives 0.299999999999999988898, the double nearest three tenths. C multiplies the double nearest 0.1 by 3 and rounds that, and gets 0.300000000000000044409, the next double up (go/internal/constcheck, gate/results/go_constants.txt). The cure is to make every constant a typed double the moment it appears: the back end writes float64(0.1), and since every operation is converted too, Go rounds after each step, as C does. The same rule costs Go its negative zero: the constant -0.0 is plain zero, so where C negates a zero the back end writes a run-time negative zero instead.
No ?:, no assignment inside an expression, no fall-through, no unused variables. x = c ? a : b becomes if/else; a ?: inside a larger expression becomes R.If[float64](c, a, b) when both sides are numbers or plain variables that cannot fail, and otherwise a function literal called on the spot, so that only one side is evaluated, as C promises. A switch case that runs into the next gets Go's fallthrough. do { } while (c) becomes for first := true; first || c; first = false, which sends continue to the test, as C does. Go rejects a local variable that is never read, which a mechanical translation of C produces by the dozen, so every function declares its locals at the top and marks them used; the front end has already given each a unique name, so C's nested scopes cannot collide.
The Go back end is a subclass of the Java one. Before the engine would build, the back end needed three cases of its own in tools/c2x/emit_go.py: a pointer to the rows of a two-dimensional table, the extern declarations of tables whose size is given in another file, and the void* that fread sees in place of the buffer it is handed. Once it built, the first full gate run was identical on 480 of 480 models (gate/results/go_disc1.json). Two things changed after that, neither of them a result: the two strtod spellings described above, which no gate model uses, and the shape of the math routines, described next. The final gate ran on the finished engine.
A loop where a goto would do. The math translator first gave Go what it gives TypeScript and Java, which have no goto: each routine as a loop that picks the next basic block by number, with every value held across the loop. A profile of one long model, sewer_rdii_problem.inp, put almost half of the Go engine's time in those routines, and a stopwatch showed why: Go's pow took 93 nanoseconds a call where glibc's own takes 18. Go has goto. With each block a label and each PHI an assignment on the edge that feeds it, the compiler sees the program's real shape, and pow fell to 23 nanoseconds, sin from 84 to 30 and cos from 77 to 29, with the same bits on all 22 million test arguments (gate/results/go_powtime.txt; the profile is gate/results/go_pprof_loop.txt). Go's own math.Pow, for comparison, takes 65.
The gate
Each engine runs every model in the model's own folder, the way EPA's command line does, so that relative file names resolve the same way. The gate then compares the .out byte for byte, and the .rpt line for line after removing three lines: "Analysis begun on", "Analysis ended on" and "Total elapsed time". Nothing else is excused. When an input error stops EPA before it writes any results, the engine's results file must be empty or absent too (39 of the 480 models are like that; this rule was made explicit in the gate after the runs, and the stored outputs of every engine pass it). The reference is EPA 5.2.4 built from the v5.2.4 tag with gcc 13.3 at -O2, with _FORTIFY_SOURCE off, because Ubuntu's fortify check aborts EPA on a relative input path (defect 3 in the register below).
| Engine | Models | .out bytes compared | .rpt lines compared |
|---|---|---|---|
| EPA 5.2.4 (C) | reference | - | - |
| C++ swmm5-cplus | 480 of 480 identical | 1,244,999,775 | 152,690 |
| Rust swmm5-rust | 480 of 480 identical | 1,244,999,775 | 152,690 |
| Fortran | 480 of 480 identical | 1,244,999,775 | 152,690 |
| C# | 480 of 480 identical | 1,244,999,775 | 152,690 |
| Python | 465 of 465 identical | 966,242,270 | 132,566 |
| TypeScript | 480 of 480 identical | 1,244,999,775 | 152,690 |
| Java | 480 of 480 identical | 1,244,999,775 | 152,690 |
| Go | 480 of 480 identical | 1,244,999,775 | 152,690 |
Python is the one engine that did not run everything. It is the slow one (the benchmark below puts its median CPU time at 82 times EPA's), so the gate gave it the 451 models EPA finishes in 1.5 seconds or less, then a sample of the 29 slower ones: the 14 that EPA finishes within 4.1 seconds. 14 of those 14 were identical.
The sample earned its keep. Its first run crashed on one model, Horton_Continuous_Model.inp, where rain.c hands sscanf pointers it was itself given (int *year) instead of &year. The Python back end wrapped the second kind for the runtime and passed the first bare. The C# back end had tripped on the same construct in its own first gate run and been given a special case for it; the Python back end, written afterwards, never inherited it. The fix went into the translator, the regenerated engine changed in exactly four calls, and the whole Python gate, fast set and sample, was run again; the numbers above are from that rerun.
TypeScript and Java ran every model. An earlier build of the TypeScript engine, before its pow fast path, was gated first (480 of 480 identical; the stored run does not say which build ran, so that rests on the session's account in gate/RUNS.md), then the final build, in four quarters so that no single job ran longer than the session allows. Both final engines were then gated a second time, because I had deleted the first final runs' output files to free disk before the app could re-check them (the app re-hashes every engine's files and does not take the gate's word). The first final runs were identical on all 480 models in both languages; the table gives the second.
Go ran every model three times: first two at a time while the work was still in progress (480 of 480 identical, go_disc1 in gate/RUNS.md), then one at a time after the strtod additions (480 of 480, go_final1), and once more after the math routines were rebuilt with goto (described in the Go section), with its outputs kept for the app's re-check; the table gives that last run.
Java's first run, started (by the session's account in gate/RUNS.md) with a plain java -jar, passed 479 of 480 and crashed on Hydrology/sewer_rdii_problem.inp with "Java heap space". That model has 613 RDII unit-hydrograph groups whose slow response lasts 9,900 hours, and rdii.c keeps the rainfall history of each at the one-minute wet step: 3.38 GB of arrays (gate/rdii_memory.py). EPA's own peak resident size on it is 3.11 GB (gate/results/peak_memory.txt), and the Java virtual machine's default heap is a quarter of the machine's memory. The repository's launcher, java/runswmm, gives it three quarters and the parallel collector, and the final Java run in the table went through that launcher (gate/RUNS.md). Run again for this essay with a plain java -jar, the model still stops with "Java heap space" (gate/results/peak_memory.txt).
Proving the gate can fail
A test that cannot fail tells you nothing, so the gate had to prove it would catch a difference when there was one, and stay quiet when there was not.
The quiet case first. EPA was run a second time on a 40-model control set, and every file came out identical to the stored reference: 40 of 40. The reference is repeatable, so any difference the gate finds belongs to the engine under test.
Then the loud case. The Fortran engine was rebuilt with fused multiply-add allowed: -ffp-contract=fast -mfma in place of -ffp-contract=off, and nothing else (gate/build_fma_control.sh). The first flag lets the compiler fuse; the second gives it the instruction to fuse with, which the x86-64 baseline lacks. The control build holds 101 fused instructions and the shipped build none. It differed from EPA on 3 of the 40 control models and on 87 of all 480 models.
Many differences are what one rounding step per operation does to a long calculation: in the 40-model control set, a control rule acting at 03:28:08 instead of EPA's 03:28:14 (Clocktime_Pump_Rules.inp), a node's time of maximum moving six minutes (FM_Example2.inp), a continuity error moving from 0.066 to 0.068 percent (CN_Horton.inp). Times can jump: in 31_Subs.inp, node 3060 reports the same maximum depth, 3.22, reached nearly seven hours earlier (day 0 17:02 instead of day 1 00:00). Some are not small. Among decimal quantities in the first report line that differs, the largest relative changes: vsp_pump_si_units.inp, 0.905 to 1.021 (+13%); 5_H&H_Elements (1).inp, -0.170 to -0.179 (-5%).
That second number deserves a moment, because it cuts both ways. Under this one perturbation, most models came back identical: rounding once instead of twice never reached what they print or store. A model that cannot notice such a change proves little on its own about last-bit agreement. The evidence that the ports compute bit for bit what EPA computes rests on the 87 models that would have noticed, and on how many different code paths they cover. That is why the gate runs the whole set and not a handful.
What EPA got wrong
A byte-exact port is a hard reader of the code it copies. Five defects in EPA 5.2.4 came to light along the way. Where EPA's behaviour is defined, even when it is wrong, as with the integer overflow, each port copies it, because the job was to match EPA, not to fix it. Where it is undefined (reading past the end of an array, writing past a buffer, using memory it never cleared) there is nothing defined to copy; the ports do the safe thing, and the register says which gate models, if any, reach that code. The register lists them so they can be fixed upstream deliberately.
| # | Where | The code | What happens |
|---|---|---|---|
| 1 | inlet.c:146 | static char *PlacementTypeWords[] = {"AUTOMATIC", "ON_GRADE", "ON_SAG"}; | No NULL sentinel, so findmatch() reads past the end. EPA's build finds a zero word there; the ports add the NULL. Without it the first C# gate run crashed on Hydraulics/inlet_capture_test.inp (gate/csharp_run1.txt), and the Python back end, written later without the fix, raised IndexError on the same model (python.json in the stored results). |
| 2 | gage.c:251 | gageInterval = (int)(floor(Tseries[k].dxMin*SECperDAY + 0.5)); | Overflows int when a series has one point. x86 gives INT_MIN and no WARNING 09. Rust's as and Java's (int) saturate to INT_MAX and the warning appears; WebAssembly saturates or traps, depending on the toolchain. The ports use the x86 rule. |
| 3 | swmm5.c:1593 | realpath(fname, absPath); | Writes a full path into a 260-byte InpDir. glibc's _FORTIFY_SOURCE aborts every run opened by a relative name, so the reference is built with fortify off. A path longer than 259 bytes would overflow InpDir; the ports have no fixed buffer there and cannot reproduce an overflow. The longest gate model path here is 115 bytes. |
| 4 | output.c:523 | fwrite(&IDStartPos, sizeof(INT4), 1, Fout.file); | IDStartPos is an 8-byte off_t written with sizeof(INT4): fwrite copies its first four bytes, which on little-endian x86 are the low half, so the value is right (all three positions are taken before any results are written, so they stay small). A big-endian machine would write the high half, zero. The ports write the low four bytes. |
| 5 | project.c:421 | memset(a[0], 0, size); | size is nrows*ncols (project.c:401), a count of doubles, but memset counts bytes, so seven eighths of the matrix keeps malloc's contents. Only USE INFLOWS reaches it, and no gate model does. There is nothing defined to copy, so the ports zero the whole matrix. |
What it costs to run
Speed was measured on 12 models chosen to span EPA run times from 0.01 to 0.72 seconds. In each of 3 repetitions, every engine ran every model in a fresh random order, and EPA ran twice. Each ratio is the engine's median CPU time over the repetitions divided by EPA's. EPA's second copy, measured the same way, lands between 0.98 and 1.06 of the first on every model: that is the noise floor. Single runs scatter much wider, 0.88 to 1.81, which is why nothing here rests on one run. All 12 models are short; the long ones were not timed, so these are ratios for short runs. The Python timings are from the engine before the scanf fix described above, which changed four calls that none of these models reaches.
| Engine | Median CPU time vs EPA | Range over models | Start-up (s) |
|---|---|---|---|
| EPA again | 1.00x | 0.98-1.06x | 0.002 |
| C++ | 0.94x | 0.92-1.06x | 0.003 |
| Rust | 1.11x | 1.00-1.27x | 0.002 |
| Fortran | 1.26x | 1.13-1.40x | 0.002 |
| C# | 5.19x | 2.90-27.67x | 0.068 |
| Python | 81.63x | 57.02-99.04x | 0.029 |
| TypeScript | 19.24x | 11.44-73.66x | 0.101 |
| Java | 9.18x | 4.28-93.97x | 0.118 |
| Go | 1.72x | 1.47-2.14x | 0.003 |
Read the first row first: EPA against itself, 0.98 to 1.06. swmm5-cplus, built with link-time optimization, has a median of 0.94, just below that band, but model by model it runs from 0.92 to 1.06 and is slower than EPA on 2 of the 12. Call it a tie with a slight lean its way.
swmm5-rust joined later, after the session had moved to a fresh machine, so it was timed in a run of its own: interleaved with EPA on the same 12 models, 18 repetitions, every ratio against EPA on that same machine. EPA against itself spread wider there, 0.84 to 1.08. Rust's median is 1.11 times EPA, from 1.00 to 1.27 model by model, and above EPA's band on 9 of the 12: a tenth or so slower. Its own repository, measured separately, reports 1.12 times at the median, and its instruction-count profile puts over a third of the extra work on one model in xsect.c's lookup(), the same table lookup that leads the Fortran profile below.
The Fortran port is the one that should make you raise an eyebrow: 1.26 times EPA, between 1.13 and 1.40 on every model timed. Same arithmetic, same library, and a quarter more work. I did not run that down. A gprof profile of the longest benchmark model, 10_Subs_Continuous.inp (kept with the stored results as fortran_gprof.txt), spreads the time over the engineering routines (the cross-section table lookup is called twelve million times), with the runtime's helpers showing up beside them: the checked double-to-integer conversion is called thirteen million times. EPA was not profiled, so this says where the Fortran spends its time, not where it spends more than EPA. The run is short, well under a second of samples, so the percentages are rough; the call counts are exact. No single culprit; a translation that buys sameness pays for it in small coin, everywhere.
C# has no typical ratio; its models fall into two groups. On the 6 that EPA runs in 0.17 seconds or more, C# costs 2.9 to 3.7 times EPA. On the 6 shorter ones it costs 6.7 to 28 times, climbing as the run gets shorter, because fixed costs swamp the work: starting .NET alone takes 0.07 seconds (timed with --version), and compiling the engine's methods just in time comes on top of that; I did not time the compiling separately. The median of all twelve, 5.2, falls between the groups and describes neither. Python's median is 82 times EPA, and its models stay between 57 and 99. That is the price of byte identity in a language where every number is an object: the answer is the same, and it takes longer to get it.
TypeScript and Java were timed together, in a run of their own: the same 12 models, 5 repetitions, interleaved with EPA and EPA again, every ratio against EPA in that run (EPA against itself there: 0.87 to 1.32). Java's median is 9.18 times EPA, from 4.28 to 93.97; starting the Java virtual machine alone takes 0.12 seconds, and on the 6 models EPA runs in 0.17 seconds or more (in the main benchmark) Java costs 4.3 to 8.2 times. TypeScript's median is 19.24, from 11.44 to 73.66 (11.4 to 20.9 on those same models), with 0.10 seconds to start Node. Part of TypeScript's price is the mathematics: a fused multiply-add the hardware does in one instruction costs the TypeScript runtime a few dozen floating-point operations, and its pow makes sixteen of them on its common path. Timed against the same engines built with their own languages' Math (3 repetitions, gate/results/bench_math.json), the copy of glibc makes TypeScript a median 1.32 times as slow (1.17 to 1.78 by model) and Java 1.12 times (0.94 to 1.30): about a quarter of TypeScript's time and a tenth of Java's. That run had no EPA-against-itself control; a Java ratio below 1 shows its noise is at least that large.
Go was timed last, in a run of its own: the same 12 models, 5 repetitions of EPA, EPA again and Go, every ratio against EPA in that run (EPA against itself there: 0.95 to 1.07). Go's median is 1.72 times EPA, from 1.47 to 2.14 model by model, and it starts in 0.003 seconds, so what it costs is the work itself, not a virtual machine. Timed against the same engine built with Go's own math (5 repetitions, gate/results/bench_math_go.json), the copy of glibc makes Go faster, not slower: a median 0.81 of the time (0.73 to 0.89 by model). pow, the function SWMM calls most, is quicker translated than in Go's own math; sin is not (gate/results/go_powtime.txt). A CPU profile of one long gate model, sewer_rdii_problem.inp (12 seconds of samples; gate/profile_go.sh, gate/results/go_pprof.txt), puts its largest shares in updateDryPeriod (15%), glibm.Pow (13%), lookup (11%), dwflow_findConduitFlow (6%), glibm.Cos (5%). EPA was not profiled, so this says where Go spends its time, not where it spends more than EPA.
What this does not show
- It does not show that SWMM is right. Identical to EPA means EPA's answers, good or bad. No observed flow enters anywhere.
- It shows one platform: x86-64 Linux, glibc 2.39, gcc 13.3 (
gate/results/toolchain.txt). On Windows or macOS the math library differs, and a port matches EPA built there only if its math library matches too. - It shows one kind of processor. EPA itself changes 43 models when glibc picks its generic math routines, as it would on a processor without FMA and AVX2. Fortran, C#, Python, C++ and Rust should follow EPA there, because they call the same library (this was not tested); TypeScript, Java and Go carry the FMA versions and would not.
- The Go engine was built and gated on x86-64. Its ARM build was compiled and disassembled to look for fused arithmetic, not run.
- The gate set is 480 of the 518 distinct models (distinct by content) in six folders of the corpus, which holds 4,540 .inp files. The other 38 were left out by an early timing survey with a 12-second limit, whose records were not kept. The cut is rough. Re-timed for this essay on a busy machine, 13 of the 38 ran past a 30-second limit and the rest took 5.8 to 29.6 seconds, 4 of them within 12; and 3 gate models took longer than 12 seconds in the reference run (attenuation_KW_High_CE.inp, 13 s, SCANLON.inp, 35 s, 72_H&H_Elements(2).inp, 39 s).
- 49 of the 480 stop on an input error. For 39 of those EPA writes no results file and the report is the whole comparison; the other 10 write a short one, which is compared.
- Some paths are thin or missing. Counting only models that ran, none exercises these: Streets and inlets; Climate file; USE HOTSTART; USE INFLOWS (interface); USE RUNOFF. (Street and inlet hydraulics appear in one deck, which stops on input errors before simulating.) Snow packs appear in 4 models that ran, and only 2 of them supply the temperatures snowmelt needs.
- Python ran the 451 fast models and 14 of the 29 slow ones, not all 480.
- swmm5-cplus and swmm5-rust were gated as built from their repositories; how they were made is their own repositories' story, and this essay does not re-examine it.
- Only the command-line path was exercised. The API used for stepping a model from another program (swmm_step and friends) is translated but was not gated.
- The TypeScript, Java and Go math routines were checked against the system libm on the arguments described above, not proved equal to it; a disagreement on an argument no test reached is possible, though none was found.
- No person has read the 260,832 generated lines. The evidence is the gate and the controls, not review.
How to use the app and the repositories
- Scoreboard shows the summary: one row per engine, its gate result and its cost.
- Models lists every gate model. Filter by folder or name, and open a row to see its report statistics and each engine's output fingerprint.
- Graphs plots results from the reference .out for a chosen model and object, and under it the difference of each engine's own .out from the reference, period by period.
- Speed has the benchmark with its noise floor. Verify has the parity contract, controls, census and defect register.
- To rebuild an engine, regenerate it with
tools/c2x/emit_f90.py(oremit_cs.py,emit_py.py,emit_ts.py,emit_java.py,emit_go.py), build withf90/build.sh,cs/build.sh,npm run buildints/,java/build.shorgo/build.sh, then rungate/gate.py NAME "ENGINE"against the stored reference outputs.tools/libm/make_libm.shregenerates the math routines from glibc's source, andtools/libm/test/run_tests.shchecks them against the system libm.
Use of AI
The six ports, the translator that wrote them, the translations of glibc's math routines that the TypeScript, Java and Go ports carry, this app and this essay were made with Claude, Anthropic's AI model, in Claude Code sessions in October 2026, under the direction of Robert (Bob) Dickinson. He chose the languages (Fortran, C# and Python first, then TypeScript and Java, then Go after a survey of the languages in his Code Rosetta Stone app), set the test (byte-identical .out and .rpt against EPA 5.2.4 on his corpus), chose the repositories and the app, and is responsible for the work. Claude wrote the translator (front end and the Fortran, C#, Python, TypeScript, Java and Go back ends), the GIMPLE translator for glibc's math routines and its tests, the hand-written runtimes and overrides, the gate and benchmark scripts, ran every test, built this app and wrote this text; one Claude subagent wrote the Fortran printf/sscanf/strtod module and its test suite. The checks are mechanical: the byte gate on the corpus, the null and positive controls, the Fortran and Go formatters' comparisons with glibc, and the math routines' comparison with the system libm. A second Claude instance, given the files but not the conclusions, audited this essay's claims against the stored evidence; the claims it found wrong or unsupported were corrected or removed. The slow-model sample turned up one Python crash, a translator bug in how scanf arguments were passed; it was fixed in the translator and the whole Python gate was rerun. No person has yet reviewed the generated code line by line. swmm5-cplus and swmm5-rust were made in earlier Claude sessions, also for Bob, and are only built and gated here.