Operations: testing & benchmarking
Status: design target (post-spike). Strategy informed by the language-bindings research: the JTS/GEOS shared XML corpus is free differential-testing capital, and Shapely/PostGIS are the cross-implementation oracles.
1. Test pyramid
| Layer | What | Source of truth |
|---|---|---|
| Unit | Every wrapper op vs hand-written goldens; FFI roundtrips; shim behavior | this repo |
| Differential (corpus) | GEOS/JTS xmltester cases — thousands of overlay/predicate scenarios with expected results, parsed from the upstream XML and executed through our API |
GEOS/JTS repos (pinned tag) |
| Oracles | Cross-checks against PostGIS (Docker, pinned) and Shapely 2 on gnarly/degenerate inputs | external |
| Property-based | Invariants below | this repo (seeded, reproducible) |
| Native hygiene | ASan/Valgrind on the shim; leak soaks; FFI value-roundtrips | this repo + CI jobs |
2. Property-based invariants (seeded, always reproducible)
- Roundtrip identity: GeoJSON → geom → GeoJSON equal (mod RFC-7946 winding option); WKB → geom → WKB byte-identical (same settings); WKT roundtrip; Arrow roundtrip alias-then-copy equality.
- Robustness contracts:
isValid(out)holds for overlay outputs on valid inputs; area additivity on disjoint unions;covers(a, b) ⇒ area(intersection) = area(b). - Prepared/unprepared agreement: every prepared predicate returns identical results to its unprepared twin across the corpus (RelateNG short-circuits must not change answers).
- Batch/singular agreement:
unionAll([a,b])≡union(a,b); every*Allmask matches element-wise singular calls. - Builder fuzz: shim builders with randomized offsets/degenerate rings produce geometry identical to per-ring construction (and never leak — ASan job).
- Empty/degenerate coverage: typed empties, single-point rings (rejected with the
right error),
POINT (NaN NaN)WKB, collinear lines, zero-area polygons.
3. Native hygiene gates
- ASan/Valgrind CI job over the shim suite (ownership transfers,
destroy_manyon partially built arrays, error paths that bail mid-construction). - Leak soak: 10M create/destroy cycles;
geos.stats.heapBytesApproxmust return to baseline; RSS bounded. - FFI value-roundtrip smoke per Bun release: declare every arg/return type used and
assert values survive (guards the coercion bug class — cf.
oven-sh/bun#44906); runs in a separate
"bun-compat" job triggered on new Bun majors. Seeded by
spike/ffi-values.test.ts(passes on Bun 1.4.3 — see spike results F5). - Worker race test: spawn N workers that immediately load the package (regression net for the #40972 class; depends on the main-thread preload).
- PostGIS oracle (Phase 2):
test/differential/postgis-oracle.test.tscross-checks WKB ingest, validity, and overlay results against a live PostGIS. Skipped unlessBUN_GEOS_PG_URIis set; CI runs it as its own job against a pinnedpostgis/postgisimage (local:docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgis/postgis:17-3.5).
4. Benchmarks (match the marketing)
Run on pinned hardware in CI (manual trigger), results committed as JSON + rendered in docs. Two suites:
- Internal (
bun run bench): bun-geos against itself — batch vs singular, prepared vs unprepared, pool vs calling thread. Results inbench/results.json(schema 2), regression-gated bybun run bench:check. - Competitors (
bun run bench:competitors, provisioned bybun run bench:competitors:setup): every alternative engine measures the SAME seeded workloads with the SAME methodology. Results inbench/competitors/results.json(schema 3, engine-tagged), merged intodocs/benchmarks.mdbybun run bench:report.
Competitor matrix
| Engine | Boundary | Scenarios covered |
|---|---|---|
| bun-geos (reference) | native FFI | all |
| geos-cpp (direct C API against the same vendored GEOS) | none — the zero-boundary baseline | all |
| geos-wasm | WASM | overlay, union, prepared-scan, ingest, makevalid, buffer (STRtree query skipped when its callback binding is unusable) |
| Shapely 2 (Python venv, subprocess) | ctypes/Cython | all — its vectorized ufuncs are the batch-layer parallel |
| JSTS | pure JS | overlay, union (unary via Geometry.union()), strtree, ingest (WKT only — the npm bundle ships no WKB codecs), buffer (prepared geometry is absent from the npm bundle) |
| turf | pure JS | overlay, union (pairwise fold, capped to 100 tiles), prepared-scan (ray-casting), buffer |
| polygon-clipping | pure JS | overlay |
| rbush / flatbush | pure JS | strtree (index specialists) |
| wkx | pure JS | ingest (WKB/WKT codec specialist) |
Engines degrade to skipped (reason) when not installed or when an op is not workable
against the installed API — never hard-fail, never silently dropped.
Shared methodology (the "same benchmark" contract)
- Inputs: one seeded workload source (
bench/lib/gen.ts). The orchestrator materializes canonical files (WKT text, little-endian WKB + cumulative u32 end-offsets, TSV envelopes) underbench/competitors/.workload/for the subprocess engines — identical doubles at every boundary. - Timing: warm-up run excluded, then best-of-N wall ms (
repeatsrecorded per record; slow engines may use fewer repeats — recorded, never hidden). - Noise floor: each engine runs in its own process at a different moment; CPU
boost/allocator drift between windows is ±10% on this hardware (measured by
interleaved re-runs: bun-geos and geos-cpp swap the overlay lead between suite runs,
and an interleaved 12/8-sample probe puts cpp ~6% ahead with nearly disjoint
distributions). Ratios within [0.9, 1.1] are rendered "≈" (tie) in the report —
near-parity rows mean "the boundary is free", which IS the claim; only deltas beyond
~25% are meaningful rankings. The same floor applies to
bench:check: baseline variants under 10 ms are advisory (noisy), never regressions. bun-geos cannot beat geos-cpp on engine-bound work by construction (same GEOS binary + one extra boundary) — where it wins outright is parallelism (geos.parallel.*vs every single-threaded baseline here) and the safe batch/typed API surface. - Preparation symmetry: every engine prepares ONCE outside the timed region (bun-geos, Shapely, geos-cpp, geos-wasm, JSTS all support one-shot preparation); the prepared variant measures only the per-point predicate work.
- Fingerprint accumulation symmetry: every engine accumulates through its own batch
measure API inside the timed region (bun-geos
areaAll, Shapely's vectorizedarea().sum()) — the overlay/makeValid/buffer loops measure the op per item (or the one-crossing batch), then aggregate areas in one call on both sides. - Optimization note (2026-10-22): arena-owned batch outputs skip the redundant
per-geometry FinalizationRegistry registration (D10 refinement — the arena's bulk
destroy is the backstop; ~40% of batch-output wrapping).
STRtree.queryAll(one crossing for N envelope queries, D18 contract, selftest-pinned) is the batch twin ofquery. - Work: answer fingerprints (result counts, area sums, hit counts) are accumulated INSIDE the timed region for every engine, so a fast number can't mean "did less".
- Honesty anchor: every record carries its fingerprint; the report flags ✗ when an
engine's answer differs from bun-geos at the same size (counts exact, areas at 1e-9
relative). Fingerprint math is planar everywhere — turf's geodesic
area()is normalized to the same shoelace so its buffer ✗ reflects the algorithm, not the metric. - Ceilings are explicit: pure-JS engines cap the overlay ladder at 10⁴; turf's union caps at 100 tiles (pairwise fold); sizes the engine didn't run simply don't appear.
| Benchmark | Sizes | What it proves |
|---|---|---|
| Overlay throughput | 10³ / 10⁴ / 10⁵ squares × wobble(720) | native vs WASM boundary vs pure JS vs vectorized Python |
union strategy ladder |
961 aligned tiles | coverage-safe (validate+union, D20 — CoverageUnion is silently wrong on non-coverages) vs coverage-raw (union-only, the parity row vs Shapely's coverage_union_all and the C++ baseline — the ~9× gap between them IS the validity check) |
| Prepared predicate scan | 10⁴ / 10⁵ points × wobble(720) | RelateNG prepared + typed-array masks |
| STRtree query | 10⁵ boxes × 10³ envelopes (build + query timed) | one-call shim collection vs rbush/flatbush/JTS/GEOS trees |
| Ingest | 2×10⁴ squares (WKT / WKB / batch) | the I/O layer isn't the new bottleneck (bun-geos includes per-geometry GC-safe wrapper registration the C baseline doesn't pay) |
| makeValid | 10³ bowties | the repair suite vs the only two engines that have it |
| Buffer | 10² wobble(720) copies, +2, quadsegs 8 | the most-used real op, at scale |
Positioning rule (from the go-geos lesson): never headline trivial ops (area of a
small polygon) — pure-JS and flat-layout implementations can match those. The claims are
robust overlays, repair, coverage algebra, and indexed/batched predicates.
5. CI gates per phase
| Phase | Gates added |
|---|---|
| 0 | overlay roundtrip; error-capture test; FFI roundtrip set |
| 1 | unit + roundtrip properties; leak soak; ASan job |
| 2 | XML corpus differential; prepared/unprepared agreement; PostGIS oracle job |
| 3 | benchmark suite; batch/singular agreement |
| 4 | per-platform native smoke tests on runners; install matrix (bun/npm/pnpm; glibc/musl) |
| 5 | full gate set enforced on PRs; benchmark regression alerts |
Runner note: tests run on the same GitHub Actions matrix that builds the platform packages — each platform artifact is loaded and smoke-tested natively before publish.