ADR 0026: Use CLDR Root Catalog Order by Default
Accepted architecture decision record: Ferrocat orders catalog identities with the CLDR root order used by Intl.Collator(en-US), backed by a compact generated table instead of ICU4X.
- Status: Accepted
- Date: 2026-07-29
- Relates to: ADR 0013
Context
Ferrocat originally ordered message and context identities by Unicode code
point. Lingui instead orders messages and then contexts with
Intl.Collator("en-US"). English has no collation tailoring, so this is the
unmodified CLDR root order.
The difference is common, not limited to unusual accented text. Punctuation,
case, accents, and digits all participate. In particular, CLDR root order puts
an ICU MessageFormat message beginning with { before markup beginning with
<, while code-point order does the reverse. Passing an existing Lingui
catalog through a code-point writer can therefore churn most of the file.
Delegating a comparator to every caller would duplicate catalog semantics and
would not help file combine operations. Linking the full ICU4X collator would
make the behavior precise over all Unicode, but the Palamedes implementation
that established this approach measured a 1,301,440-byte release-binary
increase. Its generated-table implementation added 65,512 bytes and matched
Intl.Collator("en-US") over the project corpora.
Ferrocat's current catalog consumer is Palamedes, so carrying two catalog-output orders would add configuration and migration ambiguity without preserving a required external contract. The clean boundary is one identity order throughout the catalog layer.
Decision
Keep the existing public OrderBy shape:
pub enum OrderBy {
Msgid,
Origin,
}Change OrderBy::Msgid, which remains the default, to sort by msgid and then
msgctxt using CLDR root order. There is no code-point catalog-output mode.
Raw message identity and obsolete state break collation ties so output remains
deterministic for canonically equivalent spellings and duplicate-looking
entries.
OrderBy::Origin remains a PO presentation option. It sorts by the first source
origin, then applies the same collated message/context identity and obsolete
tie-break. The rule applies to in-memory and file-based update and combine
flows.
Compact generated data
The implementation ports the proven Palamedes table and key shape:
scripts/generate-collation-table.mjsderives primary weights, secondary accent ranks, and canonical decompositions fromIntl.Collator("en-US")- the checked-in Rust table covers printable ASCII, common typographic UI punctuation, Latin-1 Supplement, and Latin Extended-A
node scripts/generate-collation-table.mjs --checkverifies provenance in CI- the key builder has a direct-indexed ASCII path
- sparse secondary keys retain each accent's primary position, and the generator checks 90,951 NFC/NFD accent-position pairs against the Intl oracle
- sorting first compares a packed 32-byte primary prefix without allocation, then builds full primary/secondary/tertiary keys only for entries sharing a prefix
No new Rust dependency or lockfile entry is required.
FCL representation
FCL owns a storage order independent of PO presentation options. Every newly written FCL file:
- adds
order=collatedto the%FCL1header - sorts the serialized
id, followed byctxt, with the same collation
This remains true when a caller selects OrderBy::Origin; source origins do not
change FCL's line-order invariant. The reader accepts existing FCL files with no
order tag under their original bytewise (id, ctxt) contract. The next write
upgrades those files to the declared collated contract. Unknown and duplicate
order tags remain hard errors.
Declared limits
This is a deliberately partial collation:
- Ligatures and digraphs such as
fiandDŽexpand to several collation elements in the complete algorithm. The flat table cannot express that, so they sort by their own primary weight instead of asfianddz. - Characters outside the covered repertoire sort after it by code point. Their placement after Latin matches root collation, but ordering within non-Latin scripts does not.
These cases are outside normal source-message corpora. A mismatch affects entry position and therefore a diff, not translation meaning or runtime lookup.
Measured cost
The published file-to-file po-update/gettext-ui-de-10000/ferrocat scenario
measured 108.997 MiB/s before this change. The new Rust-only
catalog-order-default-v1 profile repeats that exact operation with 10 measured
runs, 3 warmups, and a 1,000 ms minimum sample. The branch report captured
while this change was under review measured 101.005 MiB/s, which looked like a
7.3% reduction at the time.
A rerun of the same profile on the merged commit with byte-identical collation
code measured 112.45 MiB/s, with a 1.72% coefficient of variation and a 5.42%
span, and the independent gettext-workflows-ecosystem-v1 run from the same
session measured 111.70 MiB/s for the same scenario. Two agreeing fresh runs
put the branch report down to measurement conditions on the shared reference
host rather than a code cost. Against the 108.997 MiB/s pre-change baseline,
the collated default is within cross-run drift on this file-to-file scenario;
the direct update-catalog probe below remains the clearest isolated cost
signal. The README headline was refreshed accordingly; later allocation-focused
optimization work moved it further, so the current published number lives in
benchmark/published-numbers.json rather than in this record.
A separate, earlier base-commit versus pre-optimization updated-default run
through the direct update-catalog harness used the
catalog-modern-de-10000 fixture, 100
iterations per run, 10 measured runs, and 3 warmups. Median throughput changed
from 109.55 MiB/s (75.1 updates/s) to 94.75 MiB/s (65.0 updates/s), a 13.5%
reduction.
A local release probe exercising a complete update of 4,950 varied messages measured the previous code-point path at 2.135–2.197 ms median and the collated path at 2.668–2.675 ms median across two runs. Observed spans were 2.045–2.653 ms and 2.093–2.380 ms for code-point order, versus 2.505–3.114 ms and 2.488–3.230 ms for collated order. The default hot path therefore paid about 0.48–0.54 ms in this probe.
Using identical release settings (lto = "fat", codegen-units = 1, and
strip = true), a fresh consumer build grew from 666,768 bytes at the base
commit to 749,360 bytes with the accent-aware default, an 82,592-byte (12.39%)
increase. The probe invokes update_catalog with differently ordered messages
and consumes the result with black_box, so the linked collation implementation
is exercised rather than dead-code eliminated.
Consequences
Positive:
- Ferrocat output matches Lingui catalog order without a parse, re-sort, and stringify post-pass
- PO, FCL, update, and combine flows share one message-identity order
- no new public enum variant is needed
- the implementation adds no ICU collation dependency
- checked-in generated data is reproducible and enforced in CI
Negative:
- the meaning of the existing default
OrderBy::Msgidchanges, so the first write can reorder an existing code-point-sorted catalog - every new FCL file adds a header tag and is rejected by older strict FCL readers that treat unknown header keys as errors
- the default full-update hot path now pays the measured collation cost
- the implementation owns a generated Unicode subset and its explicit limitations