Skip to main content
The defining technical commitment of astilba is provable round-trip fidelity: for a given bundle, import → export → render is lossless. This page explains what “lossless” actually means here, and how the harness proves it.

Lossless is on render output, not bytes

A round-trip takes a resource bundle B, 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 compares B 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).
For a pluralised key, the harness generates one vector per CLDR category that has a value, each at a representative count for that category. So 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).
input ──► adapter.parse() ──► canonical model ──► adapter.export() ──► exported files

                            ┌─────────────────────────────────┘

        for each vector:  oracle.render(sourceFiles, v)  ===?  oracle.render(exportedFiles, v)
The driver, runRoundTrip, knows only the canonical model. It cannot render — that is syntax-specific — so it asks the oracle to:
  1. enumerate vectors for the model, and
  2. render one vector against a set of files.
The driver then asserts every vector renders identically from 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 source B, 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:
ExportRole
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, RoundTripMismatchThe data shapes the driver flows.
See the core API reference for the exact signatures.

How i18next binds to it

The i18next-v4 adapter supplies the FormatAdapter (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.