Liking cljdoc? Tell your friends :D

Root causes and closure of the liblevenshtein-java performance gap

Status: causal gate closed on 2026-08-16. Controlled production experiments H-O9 through H-O33 are decided, and their accepted mechanisms are implemented. Direct Rust now passes the core Java-parity latency gate; provider traversal is within its resource/direct target; and the JVM result-delivery bottleneck has been reduced. The complete post-optimization cross-language matrix is now closed with exact results across all 45 shared coordinates. The follow-up structural wave also replaces the producer arena's read lock, queues compact generated-state IDs, rejects dictionary edges before materializing children, and isolates each foreign query's owner and fault channel.

This report explains the construction and matching gaps reported in the Java comparison. It separates three systems that were previously easy to conflate:

  1. the libdictenstein dictionary constructor;
  2. the native Rust Levenshtein traversal; and
  3. the dictionary-resource and language-binding boundary layered over that traversal.

The conclusion is not that one allocator, garbage collection, or sorting is the culprit. The gaps are generated by different algorithms and ownership models. Rust performs substantially more structural work during construction, substantially more position/state work during matching, and then the resource path adds millions of callbacks, locks, handle materializations, and descriptor copies.

Causal decomposition of construction, matching, and resource-boundary work

The machine-readable measurements behind every exact number below are in observations.json. The structural follow-up, including rejected alternatives, is in structural-wave.json. The diagnostic counters are compiled out unless perf-instrumentation is enabled; timings and counters were therefore collected in separate runs.

1. Measurement design

The published anchor remains the committed 79,343-term aspell dictionary and the 1,000-query standard/d2/std-d2 cell. Every native and resource run returned the same result signature:

fieldvalue
matches18,514
returned term bytes82,131
summed distances36,201
order-insensitive checksum7,775,666,136,087,164,888

The investigation adds deterministic 1k and 10k anchor strata plus 25k-term prefix-heavy, suffix-heavy, and mixed-Unicode shapes, each in sorted and seeded-shuffled order, and a full-anchor packed-u64 pair. The manifest labels every cell's unit domain explicitly; runners do not infer semantics from file names. generate_corpora.py records SHA-256 digests and basic prefix/suffix structure. The sorted and shuffled members of a pair contain identical terms and queries.

The evidence channels were deliberately redundant:

  • AMD uProf time-based hotspots, pinned to CPU 3;
  • Heaptrack allocation reports, analyzed only by heaptrack_print;
  • Java unified GC logs;
  • relaxed-atomic work counters in Rust and provider/consumer boundary counters across the two crates;
  • bytecode inspection of the published Java 3.0.0 jar; and
  • identity-based graph censuses rather than Java equals()-based sets.

The reusable wrapper profile-headless.sh forces Heaptrack's --record-only mode. It never invokes heaptrack -a, heaptrack --analyze, or heaptrack_gui.

2. Construction: the true causes

2.1 Sorted order is an algorithmic precondition, not a sorting win

The Java SortedDawg.add bytecode rejects decreasing terms, finds the common prefix with the previously inserted term, minimizes only the unchecked suffix, and adds only the new suffix. That is the lexicographically ordered incremental minimal-automaton construction described by Daciuk et al. [1].

Rust's DynamicDawg::from_sorted_terms does not implement that algorithm. It only skips the input sort and then calls the ordinary persistent insert for every term. Each insert:

  1. loads the published ArcSwap graph revision;
  2. walks every unit in the term from the root;
  3. creates missing nodes;
  4. path-copies every node back to the root, cloning each parent's edge list and its child Arcs;
  5. publishes a new root revision with compare-and-swap; and
  6. reclaims the predecessor revision on the inserting thread.

The full anchor produces exactly one version load and one successful root publication per term, with no contention:

The algorithmic difference can be summarized without implementation detail:

ordered-minimal-build(sorted_terms):
  previous := empty
  for term in sorted_terms:
    prefix := common_prefix(previous, term)
    minimize_and_intern(unchecked_suffix_after(prefix))
    append_only_new_suffix(term, prefix)
    previous := term
  minimize_and_intern(all_remaining_unchecked_nodes)
  publish_root_once()

current-rust-build(terms):
  for term in terms:
    root := load_published_root()
    revised_root := path_copy_and_insert(root, term)
    compare_and_swap_published_root(root, revised_root)
    reclaim_obsolete_path(root)

The first algorithm relies on sorted order to prove that the unchecked suffix will never be extended again. The second preserves concurrent incremental mutation semantics even when the entire input is already available.

work itemcount
term insertions / CAS publications79,343
input units / path units / cloned edge lists673,918
cloned child Arcs2,948,511
nodes created937,426
nodes destroyed during the build753,261
nodes remaining184,165
CAS retries0

The identical path-copy counts for from_terms and from_sorted_terms prove that current sorted input changes locality and preparation only; it does not select a better construction strategy.

2.2 Sorting is small; input cloning is larger but still secondary

Thirty uninstrumented repetitions on CPU 3 give:

isolated operationmedian
clone the in-memory String list2.417 ms
clone and unstable-sort it2.863 ms
from_terms71.323 ms
from_sorted_terms63.896 ms
sorted streaming inserts63.746 ms

The sort itself adds only about 0.446 ms. The 7.43 ms from_terms premium also includes cloning and later reclaiming all input strings. Avoiding that work is useful, but a 63.9 ms persistent build still trails the 34.2 ms Java median from the same profiling window. Sorting therefore cannot be the root cause of the remaining gap.

2.3 Reclamation is expensive because Rust first creates the garbage

In the current uninstrumented uProf capture, Rust construction self time is:

hotspotself time
LockFreeDawgNode::drop36.65%
insert_units28.80%
SmallVec<Arc<Node>>::clone16.23%
ArcSwap debt payment4.71%
malloc + free4.19%

Heaptrack observes 4,466,530 allocations across one warm-up and two measured builds: about 1.489 million allocations per build, or 18.77 allocations per term. The dominant reclamation cost is not the allocator call itself. It is reference-count traffic plus discovering and destroying the obsolete persistent graph fragments.

This distinction matters for solution design. Deferring drops, changing the allocator, or moving reclamation to another thread can reduce producer latency, but those treatments retain the excess work and memory traffic. A bulk builder that never creates one published graph revision per term removes the cause.

2.4 The JVM garbage collector is not Java's decisive advantage

With the Serial collector and a fixed 2 GiB heap, 50 Java builds incurred nine young collections totaling 93.812 ms. That is 4.68% of the summed timed build duration. GC pauses are a cost paid by Java, not enough hidden work to explain why it is faster.

Java does benefit from cheap bump allocation and batched reclamation, while Rust performs synchronous Arc decrements and destructors. But the more important fact precedes memory management: Java's ordered builder creates and minimizes only the unchecked suffix, whereas Rust repeatedly creates and destroys complete root paths. The GC conjecture is therefore refuted as the primary cause and retained only as a secondary constant-factor effect.

2.5 The resulting structures are not equivalent

The reproducible Java identity probe counts 29,133 physical nodes and 63,431 edges. Rust retains 184,165 nodes for the same terms: 6.32 times as many. Rust's current compact hash-consing excludes final or valued nodes from interning, so equivalent final suffixes cannot seed recursive equivalence. For this corpus, the structure remains trie-shaped instead of becoming the minimal acyclic word graph implied by the public terminology.

The structural matrix makes this visible without relying on English-word accidents:

shapesorted Rustshuffled Rustshuffled / sortedlive Rust nodes
full anchor64.07 ms108.03 ms1.69×184,165
prefix-heavy 25k30.84 ms37.86 ms1.23×26,015
suffix-heavy 25k29.76 ms38.22 ms1.28×326,003
mixed Unicode 25k17.31 ms26.21 ms1.51×55,016

The suffix-heavy shape is the most diagnostic: 25,000 terms leave 326,003 nodes because the shared suffix is duplicated under different prefixes. Sorted Daciuk construction is designed precisely to intern such equivalent right languages while the unchecked suffix is still local.

Construction verdict

causeconfidencerole
per-term persistent path copying and root publicationvery highprimary
synchronous Arc reclamation of obsolete revisionsvery highprimary consequence
no true sorted incremental minimizationvery highprimary
order-dependent edge-Arc cloning/localityhighimportant for unordered streaming
input String cloninghighsecondary
comparison sortinghighminor
JVM garbage collectionhighnot primary

2.6 Post-optimization construction closure

After H-O9 through H-O11 replaced per-term persistent publication with a private, freeze-once minimal builder, the same 30-repetition structural matrix was rerun with uninstrumented release binaries. The current full-anchor medians are:

input and constructortermsmedian build
unordered bytes, from_terms79,34314.702 ms
pre-ordered bytes, from_sorted_terms79,34312.023 ms
unordered packed u64, from_terms79,34310.486 ms
pre-ordered packed u64, from_sorted_terms79,3435.186 ms
legacy Java ordered reference79,34334.207 ms
ordered incremental Rust insert stream79,34367.165 ms

The optimized unordered byte constructor is therefore 2.33× faster than the published Java ordered reference, and the explicit ordered constructor is 2.85× faster. The incremental stream remains intentionally distinct: it publishes an independently observable persistent revision per term and is not the bulk-construction path. The Java value was collected in the original parity window, not time-adjacent to this closing native matrix, so these ratios are closure indicators rather than paired causal estimates. The more important invariant is architectural: unordered and ordered construction now share the same unit-generic minimal-builder kernel, while the ordered API avoids sorting and the unordered API remains free to accept arbitrary input order.

The adversarial shapes remain informative after optimization. Prefix-heavy 25k input builds in 3.705 ms unordered and 1.119 ms ordered; suffix-heavy input builds in 16.149 ms and 13.708 ms respectively; mixed-Unicode input builds in 4.961 ms and 2.380 ms. The suffix-heavy case remains the hardest because its right-language equivalences demand the most registry work, but it no longer creates and reclaims a published root path for each term.

3. Native matching: the true causes

The pure Rust core is already behind Java before any foreign-language boundary. In the pinned standard/d2 profiling controls, Java's median was 427.7 ms per 1,000-query pass and Rust's was 769.1 ms, with identical result signatures. The absolute times include profiler/logging perturbation; their purpose here is to anchor the hotspot and work analyses, not replace JMH.

3.1 Transition and state mechanics dominate

The uninstrumented Rust uProf self-time profile accounts for more than 84% in six named native functions:

hotspotself time
transition_state_pooled_ref28.01%
characteristic_vector27.00%
QueryIterator::queue_children13.33%
State::copy_from6.72%
QueryIterator::advance5.27%
State::insert_with4.20%

Allocator entry points contribute only 2.58%. This corroborates the earlier profile: the gap is principally automaton/state work, not dictionary atomics or general-purpose allocation.

3.2 Characteristic vectors are recomputed per automaton position

For one anchor pass, Rust attempts 6,996,242 dictionary-edge transitions. It first computes epsilon closure, then iterates every position in the expanded state and scans that position's query window to construct another characteristic vector:

work itemcount
epsilon-closure positions14,972,940
characteristic-vector calls14,972,940
query units inspected by those calls44,897,283
successor candidates3,931,103

The Java path constructs the boolean characteristic vector for a dictionary edge and passes it into the state transition function. Rust's position-local windows make the same logical edge/state pair rescan overlapping query regions. The exact call identity and the 27% isolated hotspot make this a high-confidence primary cause.

3.3 State copying and subsumption move hundreds of megabytes

Rust performs 5,535,780 state insertion attempts and 7,695,105 subsumption checks. Every edge attempt calls State::copy_from; the anchor copies 14,972,940 Position values, or 359,350,560 bytes. Retained next states add 79,665,600 bytes to the pending queue.

Position is a general 24-byte representation and State is a sorted set of positions. That generality supports all algorithms, but the common standard-distance-small case pays comparison, insertion, subsumption, and copy costs that a compact state ID, packed position, or generated transition table could avoid. Schulz and Mihov's parametric Levenshtein-automaton work [2] is the principled reference point, not a request for isolated loop micro-tuning.

3.4 The state pool does not own successful queued states

The pool handles the temporary epsilon-closure state and empty next states. A successful next state moves into the queue and is eventually dropped; it is never returned to the iterator's pool. The counters make the consequence plain:

pool itemcount
acquisitions13,992,484
releases12,261,824
misses1,728,311
dictionary intersections1,731,660

Pool misses are within 0.2% of visited intersections. In effect, nearly every retained traversal state still requires fresh backing storage. A slot arena or pooled-state handle in the pending queue is a better ownership model than trying to enlarge the current temporary-state pool.

3.5 Edge enumeration allocates frequently, but is not the main CPU gap

DynamicDawgNode::edges() clones its SmallVec<(label, Arc<Node>)> into an owned vector and returns a boxed iterator. Heaptrack attributes 13,426,944 allocations to queue_children over four complete passes, about 3.357 million per pass and approximately two allocations per visited node.

This is real avoidable work, but the latest uProf capture assigns little self-time to the SmallVec clone and only 2.58% to allocator functions. Borrowed/visitor-based edges are a sound experiment, especially for cache and boundary work, but cannot alone close the Java gap.

Native-query verdict

causeconfidencerole
position-local characteristic-vector recomputationvery highprimary
general position-set transition/subsumption representationvery highprimary
copying retained states into the traversal frontiervery highprimary
pool ownership gap for successful stateshighimportant consequence
owned boxed edge enumeration and Arc cloninghighsecondary
byte versus Unicode unit domainhighrefuted on the ASCII anchor
dictionary atomics during readshighnot a visible hotspot

3.6 Residual profile after the accepted native treatments

A clean production AMD uProf capture of the optimized transposition/d2/tr-d2 path returned 18,524 matches, 85,208 term bytes, 36,314 summed distance, and checksum 84f045e4f4a37a73 on every repetition. Its self-time distribution was:

optimized hotspotself time
QueryIterator::advance45.55%
transition_epsilon_closed_state_pooled_cached43.13%
OSA State::insert_with7.55%
characteristic-cache lookup1.62%

OSA means optimal string alignment, the adjacent-transposition distance implemented by this algorithm. This profile changes the next hypothesis: the old 27% characteristic-vector hotspot has been reduced to 1.62%, so further cache tuning is no longer the principal lever. Nearly 89% of self time now belongs to frontier advancement and the optimized transition kernel itself. A future campaign would therefore need a compact/generated small-distance OSA engine or a materially different frontier/state representation; isolated characteristic or allocation tweaks are no longer supported by the profile.

3.7 Compact frontier and characteristic-class follow-up

The structural wave removed the remaining ownership mismatch between the generated transition table and the traversal frontier. Canonical position slices now live once in the table, while queued built-in unit-cost intersections carry a GeneratedStateId. The ordinary, ordered, priority, ranked-value, value-filtered/value-yielding, and prefix-DFS schedulers share that handle representation. Weighted, contextual, language-product, and zipper-navigation states remain separate where an ID would either lose state information or force materialization at the public navigation boundary.

One instrumented anchor pass contained 6,747,684 generated-table hits and 248,558 misses. Temporary transition-state pool misses fell to zero and the hot traversal frontier no longer copies positional states. A checked sentinel guard prevents an impossible ID from aliasing the EMPTY or UNCOMPUTED transition-table values.

The label cache was then split into a hot classification path and a cold pattern path. Direct byte labels and overflow labels retain only a compact class ID; one central class table owns each characteristic pattern. H-O32 reduced the resource-path median from 204.104 ms to 190.242 ms across 54 samples per arm (6.79%, Welch p = 1.323e-11, Cohen's d = -1.657) and was retained. Two superficially more compact layouts were rejected after exact result-preserving measurements: the first flat generated-transition prototype, which retained the old growth and lookup shape, regressed 153.245 ms to 179.958 ms; structure-of-arrays foreign edges regressed 189.383 ms to 199.141 ms. The later accepted dense target table in § 3.8 changed the row layout and widening invariant rather than reviving that rejected prototype.

The closing independent pure-Rust harness measured 138.927 ms across 51 samples (range 136.889–141.828 ms), compared with 382.407 ms across 30 legacy Java samples (range 380.230–387.886 ms). Both returned the exact anchor signature. Rust is therefore 2.75× faster on the original standard/d2/std-d2 workload; this is a direct-core comparison and should not be conflated with the JVM binding matrix discussed below.

3.8 Exact-cost packed lanes and dense generated transitions

The next headless AMD uProf pass separated two residual transition regimes. Eligible short Standard queries spent repeated work maintaining cumulative edit-budget lanes and recomputing already-seen packed frontiers. The other three unit-cost algorithms still used positional frontiers whose cached target rows were separately allocated and pointer-chased.

For Standard, PackedEditLaneLayout now stores positions at one exact edit cost per lane. One-to-three-query-deletion closure is the fixed expression

T = I \mathbin{\lor} \bigvee_{j=1}^{k}
    \left((I \mathbin{\land} D_j) \ll j(w+1)\right),

where I is the consuming-transition frontier, k is the maximum distance, w is the lane width, and D_j masks the positions from which exactly j deletions remain valid. Every shifted term reads from I, so the kernel avoids the cumulative-lane promotion chain. The production-only comparison reduced the direct Unicode Standard distance-2 median from 77.703 ms to 72.379 ms (6.852%) across 51 alternating pinned samples. Distance controls were d0 +0.154%, d1 -7.156%, and d3 -5.968%; exhaustive tests cover distances 0–3, all three unit domains, and the bit-63 boundary.

The packed frontier is then interned lazily as a dense state identifier. A row-major table indexed by (state, exact-label-class) stores the next state. Only states reached by the concrete dictionary walk are constructed. The Standard distance-2 median fell from 85.694 ms to 80.427 ms (6.146%); an instrumented run observed 6,748,273 table hits and 247,969 misses, a 96.46% hit rate.

For the positional engine, DenseGeneratedTargets retains canonical position slices in stable boxes but stores every target in one row-major allocation. A power-of-two row width grows losslessly only if a substitution policy creates more label classes than the query-length-derived initial bound. This reduced Transposition distance-2 from 132.211 ms to 125.787 ms (4.858%), Merge-and-Split distance-2 from 758.19 ms to 718.33 ms (5.26%), and unrestricted Damerau distance-2 from 154.76 ms to 135.17 ms (12.66%). A follow-up u32 target encoding improved only 0.182%, missed its 3% gate, and was reverted. The result supports dense locality, not narrowing IDs at the cost of checked conversions.

4. Resource and language boundary

The resource path performs exactly the same core logical query work and returns the same checksum as the direct Unicode Rust control. On top of that work, one 1,000-query pass performs:

boundary operationcount
traversal snapshots1,000
arena mutex acquisitions3,500,348
finality calls1,750,174
edge calls and cold edge-cache misses1,731,660 each
native edges enumerated / descriptors cloned6,996,242 each
node handles materialized6,997,242

Each query creates a new TraversalSnapshot, so its edge cache starts empty. Every visited node crosses a vtable callback and mutex. Every child becomes a new arena handle, and the edge descriptor vector is copied back to the consumer. This scales with traversal work—millions of edges—not with the 18,514 matches.

Batch sizes 1, 256, and 65,536 produce identical core, callback, lock, materialization, and descriptor counts. Only the number of nonempty result batches changes. Therefore the earlier hypothesis that C wins because it avoids per-match strings or because result batching is the dominant cost is refuted. The C/resource path does allocate a Rust String before copying bytes into ABI-owned output, and prior uProf runs place snapshot edge materialization—not result packing—at the top of its boundary-specific work.

The binding gap should consequently be decomposed into two experiments:

  1. provider traversal: stable snapshot/node identity, callback count, mutex, edge materialization, and descriptor copying; and
  2. result delivery: string materialization and FFM/ABI batch transport.

Changing only result batch size tests the second while leaving the first untouched, which is why it cannot diagnose the observed gap.

5. Principled solution hypotheses

These were the causal report's preregistered design directions. Section 6 records which concrete treatments survived measurement. They remain here so the implemented mechanisms can be traced back to their causal predictions.

5.1 Construction experiments

  1. Offline sorted minimal builder. Make from_sorted_terms build a local mutable unchecked suffix, intern equivalent right languages as terms arrive, and publish one immutable DynamicDawg root at the end. Acceptance signals: the anchor reaches the Java identity-node count, construction eliminates per-term root CAS/drop, and the suffix-heavy shape collapses.

  2. Fast unordered bulk builder. Let from_terms collect terms and delegate to the minimal builder after byte/radix sorting. Compare this with an unordered arena-trie plus bottom-up acyclic minimization. Sorting is only 0.45 ms on the anchor, so lower peak structure and one-pass minimization are likely more important than avoiding sorting at all costs.

  3. Preserve true incremental mutation separately. insert should retain its concurrency semantics. Bulk construction should not pay for them. A type-compatible constructor can build privately and publish once; a separate immutable/static graph type is warranted only if node ownership or layout cannot satisfy DynamicDawg's mutation contract cleanly.

  4. Owned-input constructor. An overload that consumes Strings can remove the measured 2.42 ms clone cost. It is complementary to the builder change, not a substitute for it.

5.2 Native matching experiments

  1. Edge-level characteristic mask. Compute one compact mask for the dictionary edge and maximum relevant query window, then extract the view needed by each position. The preregistered prediction should target the 27% hotspot and the 44.9 million inspected units, not merely wall time.

  2. Parametric/generated small-distance engine. For standard distance 0–2, represent automaton states as compact IDs and transitions as a function of state ID plus characteristic mask. Keep the general position-set engine for richer algorithms and larger bounds. This directly attacks transition, subsumption, copying, and representation overhead together.

  3. Frontier-owned pooled slots. Store a pool/arena slot ID in each pending intersection and return the state when the intersection is popped. The prediction is that misses fall from approximately one per visited node to a bound near peak queue occupancy.

  4. Borrowed edge visitation. Add a visitor or generic associated iterator path for native nodes so traversal does not allocate a boxed iterator or clone child Arcs. Treat it as a secondary experiment with allocation-count and cache-miss criteria.

5.3 Resource-boundary experiments

  1. Tie an immutable provider snapshot to the query/transducer lifetime rather than creating a cold arena per query.
  2. Expose stable node IDs or a read-only native arena so traversal does not materialize a provider handle for every encountered edge.
  3. Add a combined finality/value/edges page operation, or a multi-node frontier call, to amortize vtable and lock crossings.
  4. Separate the in-process safe-Rust resource adapter from the defensive C ABI adapter while retaining identical validation at trust boundaries.

The boundary gate must continue recording callbacks and descriptors. A faster wall time with unchanged millions-scale boundary counts is likely a constant-factor improvement, not closure of the architectural cause.

6. Optimization experiment outcomes

Every treatment was opened in pgmcp before measurement. Timed arms used uninstrumented release binaries; mechanism counters, AMD uProf, and headless Heaptrack were separate diagnostic runs. A statistically significant result did not override a preregistered practical-magnitude gate. Rejected source changes were reverted while their experiment records remain immutable.

6.1 Construction and native query kernel

IDsingle treatmentmedian resultcampaign decisioncausal interpretation
H-O9freeze-once sorted minimal DAWG builder64.119 → 16.972 ms, 3.778×retainPer-term persistent publication and reclamation were primary; the retained graph reached Java's 29,133 physical nodes
H-O10sort once, then use the same minimal builder for unordered input67.214 → 19.805 ms, 3.394×retainSorting was inexpensive enough that unordered bulk input should share the ordered kernel
H-O11FxHashMap for the private merge registry16.986 → 11.085 ms, 1.532×retainRandomized hashing was a large constant factor after the algorithmic repair
H-O12inline four-edge merge signatures10.990 → 11.753 ms, 6.95% slowerreject and revertLarger hash-table keys and key moves cost more than the removed small allocations
H-O13generic borrowed edge visitor765.688 → 665.948 ms, 13.03% lowerretainOwned vectors and boxed iterators were important secondary traversal work
H-O14unit-generic characteristic cache669.995 → 527.319 ms, 21.30% lowerretainRepeated position-local query scans were a primary cause
H-O15bulk contiguous state copy623.995 → 565.961 ms, 9.30% lowerretainScalar position copying was measurable and representation-independent
H-O16enqueue epsilon-closed states exactly once497.098 → 320.098 ms, 35.61% lowerretainRepeating label-independent closure for every sibling edge was the largest isolated native cost
H-O17labels first, materialize accepted children later39.898 → 38.835 ms, 2.66% lowerreject and revertA second accepted-edge lookup consumed most of the avoided child-handle traffic and missed the 15% gate
H-O20accumulated-cost guard before subsumption dispatch4.722% lower; checks only 1.65% lowerreject and revertIt missed the 5% engineering gate and did not remove meaningful structural work
H-O21batch-sort and normalize raw successors317.033 → 352.388 ms, 11.15% slowerreject and revertSorting tiny candidate sets cost more than the 4.72% reduction in dispatched comparisons
H-O28freeze-build an empty binding-owned dictionary from one validated batch96.271 → 26.547 ms, 3.626×retainOne foreign call had still executed 79,343 incremental insertions internally; the treatment routes byte, Unicode-scalar, and packed-u64 batches through the same unit-generic minimal builder
238production exact-cost packed lanes77.703 → 72.379 ms, 6.852% lowerretainExact-cost lanes and closed-form deletion closure remove cumulative-budget promotion from every eligible Standard transition
242lazy compact packed DFA85.694 → 80.427 ms, 6.146% lowerretainReached packed frontiers and exact label classes form a 96.46%-hit dense query-local transition table
245flat dense positional target tableTransposition 132.211 → 125.787 ms, 4.858% lowerretainOne row-major target matrix removes per-state target allocations and pointer chasing; Merge-and-Split and true Damerau improved 5.26% and 12.66%
247encode positional targets as u32126.388 → 126.157 ms, 0.182% lowerreject and revertSmaller targets did not repay checked conversion and missed the 3% practical gate

H-O2's proposed public Position size of at most eight bytes was cancelled at the feasibility gate rather than implemented lossily. The public structure contains two independently lossless usize fields, already requiring sixteen bytes on the supported 64-bit target before the position kind and auxiliary payload. The API also exposes &[Position] and accepts full-range inputs, so a hidden narrow representation would either break the contract, truncate valid values, or require an overflow representation larger than the target. H-O16 simultaneously reduced measured queued-state copy calls, positions, and bytes to zero, invalidating H-O2's stated performance mechanism. This is a resolved negative feasibility result, not deferred implementation work.

H-O18 and H-O19 applied the same causal method to the double-array trie (DAT) rather than forcing DAWG construction logic onto a different representation. A generic two-phase static DAT builder removed incremental collision relocation; one outer shared node handle then removed four redundant atomic increments and decrements per child traversal, reducing its query median by 27.72%. The public incremental builder remains available for genuinely dynamic construction.

H-O28 closed the corresponding binding construction gap. Its 51-sample control and treatment distributions were completely separated (Mann–Whitney p = 4.83e-73, Cohen's d = -13.90, Cliff's delta = -1). The safe Rust batch operation validates the whole input before publishing, so it remains atomic on failure. The established C ABI instead promises that the valid prefix preceding a malformed descriptor remains applied; its error path therefore freeze-builds precisely that validated prefix before returning the original descriptor error. Successful empty batches publish one graph in both interfaces, while nonempty dictionaries retain incremental update semantics.

6.2 Resource boundary

IDsingle treatmentmedian resultcampaign decisionmechanism result
H-O22stream validated ABI edge pages into the generic visitor887.303 → 841.661 ms, 5.144% lowerretainRemoved consumer aggregate vectors and boxed iteration; boundary counts intentionally unchanged
H-O23explicitly pin one immutable resource snapshot841.661 → 654.813 ms, 22.20% lowerretain, 25% magnitude clause missedSnapshot count became one; cache misses fell 96.61% and node materializations 98.74%
H-O24provider copies only the requested borrowed edge page654.813 → 632.885 ms, 3.35% lowerretain, 5% magnitude clause missedWhole-vector descriptor clones fell from 6,996,242 to zero
H-O25optional fused finality-and-edge ABI visit632.885 → 588.835 ms, 6.96% lowerretain, 10% magnitude clause missedArena locks halved from 3,500,348 to 1,750,174 and standalone finality callbacks became zero
H-O26cache validated nodes by immutable snapshot node ID588.835 → 473.051 ms, 19.66% lowerretain, 20% magnitude clause missedCallbacks fell 96.61%, locks fell 95.59%, and the contemporaneous resource/direct ratio reached 1.132×
H-O27reduce borrowed JVM descriptors instead of materializing Match/String objects68.994 → 69.078 ms, 0.12% slowerreject as parity defaultRepeated foreign-memory descriptor access cost at least as much as the removed allocations; this timing alone does not establish whether scalar replacement contributed
H-O30drain every query through one confined foreign-memory arena and a lexical forEachMatch callback68.994 → 58.327 ms, 15.46% lowerretainPer-query shared arenas had forced 68,001 JVM all-thread handshakes; the lexical path required two
H-O31replace each cached foreign child pointer with only its numeric node ID200.549 → 284.595 ms, 41.91% slowerreject and revertRe-resolving accepted children through the hybrid directory cost more than the pointer saved on every edge
H-O32store only characteristic class IDs in direct and overflow label caches204.104 → 190.242 ms, 6.79% lowerretainRemoved duplicate pattern ownership and kept full pattern access on the cold generated-table miss path
H-O33split cached foreign labels and child metadata into parallel arrays189.383 → 199.141 ms, 5.15% slowerreject and revertExtra indexing and lost edge-record locality outweighed denser label scanning
239direct immutable resource snapshot graph118.325 → 85.916 ms, 27.39% lowerretainOne validated compact graph removes steady-state node callbacks, consumer cache-directory lookup, and atomic child promotion

The retained boundary treatments compose because they remove different work: snapshot construction, provider-side descriptor cloning, redundant property callbacks, repeated inspection of an immutable node, and per-query foreign arena cleanup. Their generic contracts and backend applicability are inventoried in optimization-propagation.md.

The follow-up resource architecture additionally separates one retained query-local provider owner from copy-only node keys, isolates callback faults per cursor, and keys shared immutable caches by snapshot identity plus provider and dictionary vtable lineage. Its dense-prefix/sparse-overflow directory is lock-free on lookup and publication, bounds dense allocation, and immediately reclaims losing publications. Failed producer-arena growth does not consume an ID, so capacity failure cannot leave a permanently unpublishable hole. These changes close concurrency defects as well as removing millions of Arc increments/decrements and lock acquisitions.

Post-H-O26 snapshot-stack hardening

The follow-up snapshot implementation removes three sources of work that were still hidden behind the accepted H-O23 through H-O26 treatments. First, a producer memo retains one immutable snapshot per source revision. Second, the optional vt.snapshot.id.1 interface identifies that revision as the pair (producer, revision), allowing separately minted resource contexts to share a consumer node cache without equating mutable resources. Third, the producer arena is a 256-slot chunked, append-only directory: readers use an atomically published chunk vector and write-once slots, and growth publishes a fallibly allocated geometric directory with compare-and-swap. No arena operation takes a mutex. The last arena owner reclaims all chunks synchronously; no background reclaimer or unbounded deferred queue is involved.

The same 79,343-term, 1,000-query standard/d2/std-d2 causal workload retained the exact result signature (18,514 matches, 82,131 returned term bytes, distance sum 36,201, checksum 7,775,666,136,087,164,888). Its provider work changed as follows relative to the original resource-boundary observation:

causal workoriginal boundaryhardened stackreduction
snapshots created1,000199.90%
arena mutex acquisitions3,500,3480100%
provider edge callbacks/cache misses1,731,66058,67796.61%
native edges enumerated6,996,24288,32698.74%
nodes materialized6,997,24288,32798.74%
descriptor clones6,996,2420100%

These are causal work counters, not a new latency experiment: the counter build is instrumented and the uProf run is a single diagnostic sample, so its timing must not be compared with the uninstrumented 51-sample medians above. A headless AMD uProf 5.3 hotspots capture instead answers the mechanistic question. After the structural wave, predicate-first foreign-edge processing accounted for 30.26% self CPU, generated transitions for 21.49%, cursor advance for 11.40%, and the epsilon kernel for 5.70%. Dense arena lookup and growth each accounted for 0.44%; no arena lock remained. The profile therefore corroborates the counter result: the remaining work is primarily query-transition and frontier work, not repeated snapshot construction or a global producer-arena lock.

A separate teardown-aware counter run released every producer and consumer owner after stopping query_ns. It synchronously reclaimed all 88,327 materialized nodes in 33.61 ms on that diagnostic run. The equality between materialized and reclaimed counts is the bounded-lifetime gate; the single latency observation is diagnostic rather than a distributional performance claim.

Direct immutable graph traversal and packed finality

The warmed callback arena was still an indirect representation of an already immutable DynamicDAWG revision. The accepted successor exposes one optional vt.dict.graph.v1 view: dense node descriptors, sorted flat edges, and opaque value cursors. Producer and consumer each publish their derived representation once per (producer, revision) identity. Snapshot capture remains $\mathcal{O}(1)$; only the first graph request pays $\Theta(\lvert V\rvert + \lvert E\rvert)$ projection and validation, outside the backend and registry locks.

On the same committed Standard distance-2 workload, with only resource-profiling controls enabled and hot-loop counters compiled out, nine runs gave these exact medians:

resource traversalmedian for 1,000 queriesrelative to callback fallback
identity-cached callback/page fallback147.825 mscontrol
immutable flat graph85.639 ms-42.07%

Every run retained 18,514 matches, 82,131 returned bytes, distance sum 36,201, checksum 7,775,666,136,087,164,888, and order checksum 16,014,396,901,440,918,890. The instrumented work run explains the change: one graph projection, one consumer decode, zero arena locks, zero finality or edge callbacks, and exactly one graph-value call per returned match. The callback control made 22,514 finality calls and edge-page calls, copied 53,227 edge descriptors, and reclaimed 26,068 materialized handles; the graph path reclaimed only its root fallback handle.

The same graph format now packs node finality into the high bit of its existing 64-bit edge-range word. This preserves the eight-byte hot descriptor and removes the separate finality allocation and load for every automaton that uses TraversalSession. A before/after diagnostic moved the median from 85.639 ms to 82.076 ms (-4.16%) with the exact signature unchanged. The raw producer ABI also uses checked one-based dense value tokens: zero, out-of-range, and forged cursors fail before any backend pointer is touched.

Why bounded approximate LFU was rejected for product expansions

The product-reuse trace motivated a bounded lossy-cache investigation rather than an unbounded memo table. TinyLFU and W-TinyLFU use approximate frequency sketches and admission to resist one-hit pollution [3]; related low-mutation policies include CLOCK-Pro [5], S3-FIFO [6], and SIEVE [7]. Those algorithms are most valuable when a miss is expensive and frequency predicts saved work. Neither condition held here.

Across 16,807,209 product expansions, 4,557,624 products repeated, but only 572,729 maximum-capacity hits (3.4% of all products) had outgoing edges. 87.3% of captured hits were zero-edge leaves, and the avoided transitions were already cheap generated-table hits. Most decisively, every one of the 433,681 expensive generated-transition misses remained a miss. A full 4 MiB TinyLFU prototype observed 99.3% of repeated keys yet slowed the workload because sketch hashing and counter updates exceeded recomputation cost. The smallest application-specific alternative—a 64 KiB, degree-at-least-four, two-way approximate-LFU table—achieved only a 0.36% hit ratio and avoided 40,813 edge scans, also insufficient to pay for lookup.

This is a workload conclusion, not a claim that approximate LFU is ineffective in general. Count-Min sketches provide a sound fixed-space frequency estimator [4], but frequency estimates cannot recover value when the frequent records are cheap leaves. Production therefore retains no second- level product cache. The accepted query-local transition tables already cache the expensive recurrence at its semantic key, with constant bounded state and no eviction policy.

Why bounded approximate LFU was accepted for complete query results

The complete-query cache is a different reuse boundary. Its miss cost is the entire dictionary/automaton product walk and its keys recur across calls, so frequency can predict substantial saved work. VersionedQueryCache<V> uses a four-row packed 4-bit frequency sketch and two-probe doorkeeper for TinyLFU admission, with periodic aging, plus one SIEVE reference bit per resident for low-mutation victim selection. Entry count and caller-defined logical weight are independent hard bounds; hash collisions still compare the complete query text and distance; distance variants share one Arc<str>; and an immutable result slice is shared only after exact computation. A dictionary-version change clears both residency and policy. The cache is deliberately single-owner and synchronization-free so callers can shard ownership without paying for a lock or atomic protocol on every hit.

The final topology-gated, 51-replicate, 128-entry policy matrix separates scan resistance, adaptation, Zipf locality, and policy overhead:

policyhot entries after scanrounds to 95% in a disjoint phasephase hit rateZipf hit rateZipf ns/op
FIFO010.9843750.54961729.288
LRU010.9843750.60312829.159
SIEVE alone010.9843750.61562227.275
aging exact LFU127250.6096190.661372199.165
TinyLFU + SIEVE128190.8374020.66417261.409

TinyLFU + SIEVE is the only candidate that retained the complete hot set under the one-hit scan while adapting faster than exact LFU and also producing the best Zipf hit rate. It cut exact-LFU Zipf policy cost by 69.17%; pgmcp experiment 299 accepted that preregistered comparison (p = 2.19e-112, 51 samples per arm). SIEVE alone remains a useful latency floor, but its 34.134 ns/op advantage ceases to cover its lower hit rate when a miss costs more than approximately 703 ns. The complete matrix, host ledger, and interpretation are in the policy report; the primary machine-readable evidence is query-cache-policy-performance.csv.

The first allocation-reuse timing was invalidated rather than rationalized. It gave each process an independent randomized AHash key and its treatment moved the SIEVE hand and cleared reference bits even when admission was rejected, whereas the allocating reference rolled the transaction back. The preserved invalid file is named explicitly in the evidence directory.

The corrected planner encodes the transaction as a circular span instead of per-slot generation marks. The first pass considers every unreferenced resident and virtually clears referenced residents; a second pass can consider only those formerly referenced residents. Every live resident has therefore been selected or rejected after at most two passes. Rejection discards only a length and leaves the hand and reference bits untouched. Successful admission replays the first-pass span once to commit reference clears, then removes the small victim list. This removes allocation, two per-slot mark arrays, and the rejected-path write log without weakening rollback semantics.

In 51 alternating, CPU-3-pinned pairs with a fixed benchmark-only AHash seed and topology admission before every pair, the allocating transactional reference averaged 1,553,788 ns and the circular-span planner averaged 1,227,706 ns: a 20.99% reduction. The paired mean delta was -326,082 ns with a 95% confidence interval of [-329,649, -322,516] ns (p = 2.05e-72, paired d_z = -25.71). Every pair agreed on checksum, hot-set retention, resident entries and weight, hits, misses, admissions, rejections, and evictions. The timings and host-load ledger are retained together.

Query-lifetime phonetic mode selection

Character-phonetic queries have two mutually exclusive engines. Unit-cost queries use incremental dictionary/language-product intersection; articulatory costs require the fractional-cost product scanner. The former iterator used to retain the latter engine's cloned ProductAutomatonChar, empty queue, optional traversal session, capacity-64 parent-path arena, and depth guard even though none could be observed. A query-lifetime enum now owns exactly one engine. The value-returning mapped iterator uses the same split and routes incremental matches through MappedLanguageQueryIterator, so compact cursor-native graph traversal is shared instead of reimplemented.

The causal control is a separate benchmark-only concrete type with the exact historical field construction and drop order. Arm selection occurs once before the measured loop, and first-result/full-order checksums are computed outside that loop; consequently the production iterator contains no control Option and the result check does not become part of the treatment.

On the current post-propagation binary, 51 topology-admitted alternating pairs of 1,000,000 construct/drop iterations reduced mean time from 1,061,038,836 ns to 717,876,938 ns (-32.34%). The paired mean delta was -343,161,898 ns with a 95% confidence interval of [-346,665,371, -339,658,426] ns (p = 6.55e-74). Every pair retained identical first-result and full-consume match, byte, distance, checksum, and order signatures. Inline iterator size fell from 712 to 432 bytes.

Fresh headless Heaptrack captures of 10,000 simultaneously live iterators on the same binary reproduced 676,412 to 626,396 allocation calls, 223.79 MiB to 191.07 MiB reported peak heap, and 127.15 MiB to 93.92 MiB reported peak RSS. Both arms used heaptrack --record-only followed by heaptrack_print; no GUI analyzer was launched. The timing CSV, timing host ledger, Heaptrack summary, and Heaptrack host ledger are retained together.

Residual transition and parent-path work

After graph capture and lock removal, instrumented resource traversal recorded zero provider-arena acquisitions. Its remaining Standard distance-2 work was 6,996,242 logical packed transitions, of which 4,400,619 labels belonged to class zero (absent from the query) and 3,083,387 repeated class zero within the same dictionary-node expansion. A source-row-local exact result slot therefore reuses both live targets and the dead sentinel without hashing, allocation, or growth. Across 51 topology-admitted same-binary pairs it reduced physical DFA target probes from 6,996,242 to 3,912,855 (-44.07%) and mean query time from 192.622 ms to 189.100 ms (-1.83%). The paired median reduction was 1.91%, the 95% confidence interval for the mean delta was [-4.509, -2.535] ms, and every result and logical-work signature was identical. The more invasive earlier attempt to broadcast class-zero results through a separate expansion grouping was rejected because its bookkeeping made the same workload 15.16% slower.

Schedulers that own a representation-erased UnitCostMachine formerly matched that enum again in every sibling transition. A crate-private PreparedUnitCostRow<U> seam and centralized macro now select Standard, OSA, merge/split, or positional rows once per dictionary node; the expansion body is monomorphized for the concrete packed type and has no vtable. Fifty-one same-binary pairs reduced mean resource query time from 180.569 ms to 175.904 ms (-2.58%), with a 2.51% paired-median reduction and a mean-delta confidence interval of [-5.548, -3.783] ms. Exact result, transition, graph, value, and zero-lock counters agreed in every pair. The same seam is used by value, ranked-value, ordered, and priority schedulers; the direct query kernel already performs equivalent query-lifetime concrete selection.

Finally, the generic append-only reconstruction arena used machine-word keys and depths even though one query cannot approach that space. Checked u32 keys and depths reduce ParentPathKey from eight to four bytes and the common ParentPathNode<char> from 24 to 12 bytes, reserving u32::MAX as the root sentinel and rejecting exhaustion. Fifty-one alternating, digest-recorded two-binary pairs reduced mean query time from 99.352 ms to 96.873 ms (-2.50%); the paired-median reduction was 2.72% and the confidence interval for the mean delta was [-2.899, -2.061] ms. Headless Heaptrack reported identical 744,467 allocation calls, 249,845 temporary allocations, and 672 leaked bytes; peak heap moved from 18.63 MiB to 18.59 MiB and peak RSS from 27.58 MiB to 27.35 MiB. The class-zero timings, static-dispatch timings, parent-path timings, and headless allocation summary are retained with adjacent host-load ledgers.

A closing headless AMD uProf capture after these changes measured 1.119 s for 15 complete 1,000-query resource passes (74.62 ms/pass). No arena lock appears. The remaining named costs are the packed-DFA class-zero/cache branch (9.13%), flat-graph edge projection (8.70%), queue movement, parent-path insertion, and final-distance calculation, all individually single-digit percentages. This marks the transition from architectural bottlenecks to micro-optimization: further changes require their own causal controls and should not reintroduce grouping, hashing, synchronization, or unbounded memoization merely to remove a predictable branch or array probe.

H-O30 generalized the lexical drain across string, byte, packed-u64, and phonetic query forms. One confined arena now owns the whole synchronous drain, ordinary owned Match values cross the public callback, and cleanup is deterministic even when the consumer throws. Java Flight Recorder showed why this was faster: the former shared-arena/Cleaner path triggered 68,001 HandshakeAllThreads VM operations, totaling approximately 276 ms in the diagnostic run, while the lexical path triggered two totaling approximately 0.019 ms. Its 51-sample control and treatment distributions were completely separated (one-sided Welch p = 1.50e-26, Cohen's d = -3.69, Mann–Whitney p = 1.65e-18, Cliff's delta = -1).

The allocation evidence also refutes garbage collection as the explanation for legacy Java's query lead. In the paired diagnostic workload the optimized Rust-backed JVM path allocated 2,966,440 bytes per operation, whereas legacy Java allocated 150,576,544 bytes per operation—about 50.8× more. Legacy still ran faster before the native and boundary treatments, so allocation volume and garbage collection cannot be the primary causal mechanism.

The completed 60-cell post-H-O26 resource/direct matrix covers every supported algorithm at distances 1–3 and both in-vocabulary and out-of-vocabulary query shapes. All 60 cells preserved match count, returned term bytes, summed distance, and checksum exactly. The resource/direct latency-ratio geometric mean was 1.187× and the median was 1.180×, passing the aggregate 1.35× gate. Individual cells ranged from 1.064× (Damerau distance 2, out-of-vocabulary) to 1.453× (merge-and-split distance 3, std-d3). The isolated worst cell is retained as a tail target rather than hidden by the aggregate result.

6.3 Intermediate JVM parity position (superseded)

This section preserves the 2026-08-15/18 closure state and its rejected experiment as historical evidence. Section 6.5 is the definitive breadth measurement for the final binary; none of its later results are projected back onto the binaries measured here.

A fresh pure-language closing run on 2026-08-18 isolates the Rust core from the JVM binding. Both arms were pinned to CPU 0 and admitted before and after measurement against the complete shared LLC group (CPUs 0--7); unrelated work on other LLC groups was recorded but did not invalidate the pair. For the standard/d2/std-d2 1,000-query pass, pure Rust measured 86.384 ms median (MAD 0.239 ms, 51 samples) versus 456.396 ms for legacy pure Java (MAD 10.448 ms, 51 samples): Rust is 81.07% lower latency, or 5.28x faster. Every sample had the identical 18,514-match, 82,131-byte, distance-sum-36,201 signature and checksum 6be8b7274d8277d8. Construction from the same pre-sorted 79,343-term in-memory list measured 14.751 ms for Rust (MAD 0.271 ms) versus 66.881 ms for legacy Java (MAD 21.384 ms), ten in-process builds each: Rust is 77.94% lower latency, or 4.53x faster. The large Java construction MAD is reported rather than filtered; it is consistent with JIT and collection variability and does not alter the direction of the result. The committed closing summary and retained raw distributions replace the earlier contaminated Rust-only attempt, whose post-run LLC gate failed and which is explicitly quarantined.

This resolves the original native comparison: optimized liblevenshtein-rust is no longer slower than liblevenshtein-java in either construction or matching on the parity workload. Any remaining JVM-to-JVM deficit is therefore binding, cursor-delivery, and managed-materialization overhead, not a slower Rust dictionary or transition engine.

The post-H-O16 direct Rust standard/d1/hits median was approximately 39.976 ms per full 1,000-query pass. The final H-O7 compiler-guarded rerun measured 42.057 ms across 51 samples, with a bootstrap median interval of [41.959, 42.396] ms, compared with the legacy Java parity threshold of 51.2 ms. The direct core is therefore 17.9% faster at the closing gate and passes the epic's core latency threshold. The optimized JVM resource path, in the same-build H-O27 materialized control, measured 68.994 ms over three JMH forks and 51 measurement iterations. H-O30 subsequently reduced the same managed-materialization path to 58.327 ms by changing arena lifetime and drain shape, a 15.46% reduction. Borrowed result reduction measured 69.078 ms and was rejected (p = 0.820, Cohen's d = 0.182; robust Mann–Whitney p = 0.862). The public borrowed API remains useful when a caller needs lazy decoding or explicit allocation control, but the parity harness keeps managed materialization because it is the faster measured path and matches the cross-language sample contract. The remaining JVM gap is therefore Java foreign-function-interface and cursor-delivery work layered on an already-faster Rust core, rather than the original native transition gap.

The unchanged closing matrix contains 45 paired coordinates and 90 timed cells. All 45 pairs agree exactly on match count, returned term bytes, summed distance, and checksum. The geometric mean of legacy time divided by Vinary time is 0.965, the median is 0.974, and the range is [0.766, 1.109]. Vinary wins 16 coordinates and legacy wins 29. By algorithm, Vinary is 1.8% slower on the standard geometric mean, 1.9% faster on transposition, and 11.2% slower on merge-and-split. Practical parity is therefore achieved overall, while the preregistered requirement of at least 3x Vinary throughput is not achieved.

The original standard/d1/hits coordinate now measures 54.543 ms through the Vinary JVM binding versus 51.256 ms for legacy, a 6.4% residual. At the H-J1 transposition/d2/tr-d2 coordinate, the breadth run measures 445.499 ms for Vinary versus 448.694 ms for legacy, a 0.7% Vinary lead. The breadth matrix is the unchanged workload closure. The formal 51-sample pair measured 466.581 ms for Vinary (bootstrap median interval [463.029, 468.978] ms) and 455.861 ms for legacy ([455.223, 456.856] ms). Vinary was 2.35% slower; the one-sided Welch test gave p = 0.664 and Cohen's d = +0.084. Experiment 205 is rejected both statistically and by its mandatory practical clause requiring Vinary median latency no greater than one third of legacy. The exact signature remained 18,524 matches, 85,208 returned bytes, distance sum 36,314, and checksum 84f045e4f4a37a73 in every fork.

6.4 Definitive paired native closure (2026-08-19)

The final direct-language gate strengthens §6.3 with 51 independent processes per arm, alternating pair order and admitting each process immediately before and after timing against CPU 3 and its complete LLC group. Seven attempted query admissions were rejected and excluded. Every accepted standard/d1/hits sample returned the same 3,620 matches, 24,726 bytes, distance sum 2,620, and checksum 3bdc59281f42611a:

direct query armmedian pass95% bootstrap CImedian per query
optimized pure Rust14.135 ms[14.113, 14.185] ms14.135 µs
legacy pure Java46.759 ms[46.496, 47.283] ms46.759 µs

The intervals do not overlap. Legacy/Rust median latency is 3.308×, Rust is 69.77% lower, the pooled Cohen effect is -24.292, and the paired median difference is -32.600 ms. The preregistered post-optimization experiment was accepted after correcting its arm-label mapping from generic control/treatment names to the recorded legacy_java/pure_rust labels; the criterion and all samples were unchanged. The accepted Welch result is t = -122.666, p = 1.11e-66, with Cliff's delta -1. This is the definitive closure of the epic's 51.2 µs/query native gate: Rust is 72.4% below the threshold and every Rust sample is faster than every Java sample.

The corresponding construction pair measures cold, one-shot construction: each sample is a fresh process, and the Java arm deliberately receives no in-process builder warmup. That answers the common build-once workload and is not interchangeable with the older warmed multi-build Java profile in §2.6. All accepted samples performed 79,343 successful membership checks and agreed on semantic checksum 8da9c6f99f82a731.

construction APIRust medianRust CI95Java medianJava CI95Java / Rust
arbitrary-order from_terms17.246 ms[17.080, 17.359] ms175.798 ms[171.181, 180.142] ms10.193×
ordered from_sorted_terms13.868 ms[13.818, 13.967] ms175.528 ms[172.427, 179.886] ms12.657×

One and three attempted admissions respectively were rejected and excluded; each accepted arm has 51 samples and non-overlapping intervals. Because Java's builder requires ordered input, both causal pairs use the same manifest-proven ordered corpus. A separate 30-repetition structural matrix exercises the Rust arbitrary-order path with a seeded shuffle. On the full 79,343-term byte corpus, unordered construction is 22.065 ms versus 12.302 ms ordered (1.794×); it is still approximately 7.97× faster than the time-adjacent cold Java median. The same unit-generic builder also closes the packed-u64 pair at 10.377 ms unordered and 5.466 ms ordered. Prefix-heavy, suffix-heavy, and mixed-Unicode cells preserve exact term membership and show that the remaining ordered versus unordered spread follows input structure rather than a byte-only specialization.

The machine-readable evidence is the direct query analysis, the from_terms construction analysis](../../../benchmarks/causal/evidence/2026-08-19/direct-construction-from-terms/analysis.json), the [from_sorted_termsconstruction analysis, and the structural construction matrix.

6.5 Definitive JVM breadth closure (2026-08-19)

The final binary was then measured across the complete, unchanged 45-cell JVM matrix: Standard, OSA/transposition, and merge-and-split; distances one through three; and five query shapes per algorithm. Every cell uses the same 79,343-term dictionary, 1,000-query full-materialization pass, fixed 2 GiB heap, JDK, cpuset, and 2-fork x 10-iteration JMH protocol as the historical baseline. All 45 pairs agree exactly on result count and checksum. Each timed cell passed selected-CPU, SMT-sibling, and shared-LLC admission both before and after measurement.

final breadth groupingcellslegacy / Vinary geomeanmedianrangeVinary wins
all shared cells454.882x4.923x[2.910x, 11.828x]45 / 45
standard154.223x4.298x[2.910x, 6.457x]15 / 15
transposition154.449x4.820x[2.956x, 6.880x]15 / 15
merge-and-split156.193x5.267x[4.323x, 11.828x]15 / 15

This breadth result changes the engineering conclusion from the intermediate matrix in §6.3: managed boundary delivery is no longer a residual performance deficit, merge-and-split is no longer the lagging algorithm, and the original 3x practical target is exceeded on aggregate. The old experiment remains rejected for its measured binary and locked criterion; the post-optimization experiment is accepted for the final binary and evidence.

The authoritative jvm-parity-full summary has SHA-256 d067689800c32fd76f2f1572c0481f7e874740627dc83a1535cccc3d8b54c4ba. Together with §6.4, it proves both sides of the closure: the native engine is 3.308x faster than legacy pure Java at the original anchor, and the complete Rust-backed JVM product wins every shared end-to-end coordinate.

6.6 Closing headless AMD uProf profiles (2026-08-20)

The final source was rebuilt with -C target-cpu=native and captured with AMD uProf 5.3.521.0 in CLI-only hotspot mode, pinned to CPU 3. The first post-build admission sample was rejected because its LLC mean was 11.49%; it was retained in the ledger and no profile was started. The retry and both pre/post profile gates passed the 10% selected/SMT/LLC-mean and 20% LLC-peer limits. No Heaptrack or profiler GUI was opened.

The query profile ran 500 complete standard/d1/hits passes for 7.558 seconds. Its remaining samples are concentrated in the fused work that the algorithm must perform:

optimized query symbol/groupself CPU timeinclusive CPU time
packed expand / queue_children_and_finality47.47%55.28%
QueryIterator::next36.47%95.80%
append compact parent path2.89%2.89%
frontier hash-map insertion1.45%3.33%
frontier rehash/reserve1.16%1.88%

No arena lock, provider lock, RwLock, Mutex, or graph-projection builder has a nonzero sample. The earlier transition-processing and arena-locking bottlenecks have therefore not merely moved to another named synchronization path: the residual is the fused dictionary/DFA product expansion and iterator state machine itself. Parent reconstruction and frontier-table maintenance are already individually below 3%; further work there is micro-optimization, not another architectural campaign.

The unordered-construction profile ran five warmups and 300 complete builds for 8.560 seconds. Its leading named self-time samples are:

optimized construction symbol/groupself CPU timeinclusive CPU time
SortedDawgBuilder::minimize_to31.35%42.06%
SortedDawgBuilder::insert8.99%51.06%
unstable quicksort partitioning6.88%27.51%
final frozen-node destruction5.16%7.28%
malloc4.10%4.10%
merge-registry reserve/rehash3.70%4.10%

There is no per-term root publication, arena lock, CAS retry loop, or ArcSwap debt hotspot. The remaining dominant work is exactly unordered construction's required sort followed by right-language minimization. This confirms the earlier causal conclusion: reclamation was dominant only when the old builder created obsolete persistent root paths; it is now a small final-graph cost.

The authoritative query report has SHA-256 fc8bc953a3d94b733b4bb4dc6b45628ba1d2ffe09d923335039c6becbe070810; the construction report has SHA-256 c370d90ab6049bc500c5ad52b54b4db7ee38d75d4f061d96346407a888efa16e. Their exact binary hashes and the immutable host-admission ledger are retained beside the reports.

7. Experiment order and stop conditions

The evidence suggests this order:

  1. sorted minimal construction builder;
  2. unordered bulk strategy comparison;
  3. edge-level characteristic mask;
  4. compact/generated small-distance state engine;
  5. frontier slot ownership and borrowed edges;
  6. stable resource snapshots and bulk provider traversal; then
  7. result-transport refinements.

Every experiment must preserve the four-field result signature, run the structural shapes as well as the anchor, and compare uninstrumented timing with instrumented work in separate executions. An experiment is rejected if it merely moves reclamation off the timed thread, increases unbounded retained memory, specializes to the benchmark dictionary, or changes concurrency/API semantics without an explicit design decision.

The reusable identity gate is validate_gate.py. It checks native/resource result equivalence, core-work identity, provider/consumer descriptor accounting, sorted-constructor path-copy equivalence, and the batch-size controls.

8. Threats to validity

  • AMD uProf's 10 ms timer is coarse for individual builds, so profiles use repeated builds and conclusions require counter/Heaptrack corroboration.
  • Relaxed atomic counters perturb hot loops; no counter-run duration is used as a production performance estimate.
  • The direct Rust and Java profiling medians are not a replacement for the published paired JMH comparison. They establish that the profiled executions retained the known gap and exact results.
  • Minimal graph size primarily changes construction work, resident memory, and locality. It does not automatically eliminate all logical query paths, because distinct terms still need distinct outputs.
  • The synthetic corpora isolate structural dimensions; they are diagnostic, not claims about production term distributions.

References

  1. J. Daciuk, S. Mihov, B. W. Watson, and R. E. Watson. “Incremental Construction of Minimal Acyclic Finite-State Automata.” Computational Linguistics 26(1), 2000. ACL Anthology, doi:10.1162/089120100561601.
  2. K. U. Schulz and S. Mihov. “Fast String Correction with Levenshtein Automata.” International Journal on Document Analysis and Recognition 5(1), 2002. doi:10.1007/s10032-002-0082-8.
  3. G. Einziger, R. Friedman, and B. Manes. “TinyLFU: A Highly Efficient Cache Admission Policy.” ACM Transactions on Storage 13(4), 2017. doi:10.1145/3149371.
  4. G. Cormode and S. Muthukrishnan. “An Improved Data Stream Summary: The Count-Min Sketch and Its Applications.” Journal of Algorithms 55(1),
    1. doi:10.1016/j.jalgor.2003.12.001.
  5. S. Jiang, F. Chen, and X. Zhang. “CLOCK-Pro: An Effective Improvement of the CLOCK Replacement.” USENIX Annual Technical Conference, 2005. USENIX.
  6. J. Yang et al. “FIFO Queues Are All You Need for Cache Eviction.” SOSP,
    1. doi:10.1145/3600006.3613147.
  7. Y. Zhang et al. “SIEVE Is Simpler than LRU: An Efficient Turn-Key Eviction Algorithm for Web Caches.” NSDI, 2024. USENIX.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close