Lossless is on render output, not bytes
A round-trip takes a resource bundleB, parses it to the canonical model C, and exports
C back to a bundle B'. The naive test would be byte equality: B === B'. astilba does
not use that test.
Instead, equivalence is measured on resolved t() output: for every probe vector, the
string i18next renders from B' must be byte-identical to the string it renders from B.
This is deliberate. An adapter may renormalise freely — flattening or re-nesting keys,
reordering keys, re-deriving plural suffixes for the target language — because none of those
changes affect what the end user sees. Byte equality would forbid correct, useful
normalisation; render equivalence permits it while still proving nothing was lost.
Concretely, a key stored nested as
account.friend.name is free to round-trip as a flat
key, or vice versa, as long as t("account.friend.name") resolves to the same string.
Key ordering is similarly free.What a probe vector is
The harness comparesB and B' not key-by-key but vector-by-vector. A vector is one
resolved t() call — a combination of:
- a key (with its namespace),
- a context value (or none),
- a plural classification (none / cardinal / ordinal) and, for plurals, a category,
- the args the adapter’s render oracle needs (for i18next:
{ count, ordinal, context }, plus sample bindings for any other interpolation variables).
item_one / item_other in
English produces two vectors; the same model in Arabic (six categories) can produce up to
six.
The harness: driver + contracts
The fidelity check is split into a format-neutral driver (in@astilba/core) and a
per-syntax oracle + adapter (supplied by a syntax adapter like
@astilba/adapter-i18next-v4).
runRoundTrip, knows only the canonical model. It cannot render — that is
syntax-specific — so it asks the oracle to:
- enumerate vectors for the model, and
- render one vector against a set of files.
B and from B'.
The render oracle is a test-support capability. In production, the end user’s own
i18next does the rendering — never the oracle. The oracle exists so the harness can
compare bundles deterministically, without depending on the host’s ICU.
One important detail: the source must resolve
The model was built from the sourceB, so B is guaranteed to resolve every vector the
model produced. If it ever does not, that is a bug in the harness, not the export — and
the mismatch is flagged with the note "source did not resolve a vector the model produced"
rather than reading as a (false) match where undefined === undefined.
The driver and contracts live in core
These are part of the published@astilba/core surface, so any syntax adapter — today the
i18next one, tomorrow an ICU one — can plug in:
| Export | Role |
|---|---|
runRoundTrip(adapter, oracle, input) | The generic driver. |
formatMismatches(report) | Render a non-lossless report as a readable failure message. |
FormatAdapter<Files, Input> | Contract: parse(input) → model, export(model) → files. |
RenderOracle<Files> | Contract: vectors(model), render(files, vector, language). |
Vector, RoundTripReport, RoundTripMismatch | The data shapes the driver flows. |
How i18next binds to it
The i18next-v4 adapter supplies theFormatAdapter (its parse / exportModel) and the
RenderOracle (its render function plus vector enumeration), and exposes the ergonomic
pair roundTrip(input) / assertLossless(input) on top. Because that adapter is not yet
published as its own package, those entry points currently live in-repo — see
the adapter reference.
Related
- The canonical model — what
parseproduces andexportconsumes. - CLDR plural rules — how vectors pick a representative count.