Localisation of the engine
The engine's human-facing text is in 17 languages beside English: nl, pl, pt-BR, pt-PT, fi, sv, da, nb, cs, uk,
ru, de, fr, es, et, ja, zh-Hans. English is the source; en-XA is a pseudo-locale that accents, brackets and
pads every message, so a test can see whether text moved to the catalogue. The framework is
Project Fluent (fluent-bundle, unic-langid); the code is src/i18n/.
What is localised and what never is#
| Localised (a person reads it) | Never localised (a program reads it) |
|---|---|
| command-line messages and errors | JSON keys and values of result.json and report.json |
| the summary line of every run | CSV headers and rows |
| the text of SVG charts (titles, axes, legends, captions) | file names |
report.html: headings, tables, notes, captions |
error codes (E-CLOCK-VERSION) |
| verdict words (VALIDATED, MODELLED, PARTIAL) | the verification matrix, units definitions, provenance |
the descriptions of the scenario kinds (list_kinds(lang)) |
|
advisories, disclaimers and notices (engine-legal-*) |
the same texts inside JSON, which stay English |
report.html of the iq campaign, the study comparison page, the bench leaderboard, the resilience report |
summary.html of an evidence pack and the observatory PNG: both are hashed into a manifest (the pack's is signed too), so they are built in English whatever the language |
Numbers in human text use the language's decimal mark (a comma in 15 of the 18 languages; a point in en, ja, zh-Hans and en-XA).
Numbers in machine files always use the point. A count (a variable named n, count, n_*, num_*
or *_count) reaches Fluent as a number, so a translation may write { $n -> [one] ... [few] ... *[other] ... }.
Choosing the language#
- Command line:
--lang <tag>, thenKSHANA_LANG,LC_ALL,LC_MESSAGES,LANG, then English.ptis pt-BR; onlypt-PTis Portugal's.zhis Simplified; Traditional is refused with the list of supported tags. - Library:
api::run_toml_lang(toml, lang),run_toml_bilingual,run_toml_human,run_scenario_lang,list_scenario_kinds_json_lang(lang). - Python:
lang=Noneonrun,run_full,run_typed,validate_toml,error_kind,list_kinds,scenario_kinds;run_human(toml, lang);error_info(toml, lang). - WebAssembly: an optional trailing
langonrun_typed,run,chart_svg,summary,table_csv,run_all,error_kind,list_kinds,scenario_kinds;run_human(toml, lang);error_info(toml, lang);register_catalogue(tag, ftl_text). - MCP:
langonrun_scenarioandlist_scenario_kinds.
Errors and their codes#
Every error message has a stable code, the message id in capitals (e-clock-version is E-CLOCK-VERSION),
the same in every language. English command-line text is unchanged. In another language the line is
text [E-CODE]; with --verbose a second line code: E-CODE follows in every language. The API gives
KshanaError::code() and error_info ({kind, code, message}); MCP errors carry {kind, code, message} as
error data. Match on the code, never on the text.
How a run works in another language (the run-twice rule)#
A machine file must not depend on the language. A run in another language therefore executes the scenario twice:
once in English, whose result.json, report.json and CSV are the machine files, and once in the user's
language, for the summary, the chart and report.html. English is one run, byte for byte what it was before.
Measured, native, release build, English then --lang de (seconds): leo-navmsg-fit-interval-trade 9.2 -> 17.9,
cislunar-arc-recovery 7.8 -> 14.1, lunar-station-coverage 7.8 -> 15.0, clock-ensemble 5.8 -> 11.6,
leo-navmsg-model-comparison 5.1 -> 10.5, leo-ppp-convergence 2.8 -> 6.2; every other bundled scenario is under
4 s; the 173 scenarios bundled when this was measured took 64 s in English together. The cost is exactly twice for another language. WebAssembly
was not measured (no wasm-bindgen here); at the browser factor of about 4 it is a minute for the slowest kind.
A page avoids the wait by showing the English result at once and calling run_human(toml, lang) afterwards: that
is the second run alone, it returns {summary, svg}, and the Studio can say "translating..." until it arrives.
0.41 item: one run. Render both from a record of (message id, arguments). It was judged and not built:
t! results are inspected, measured and hashed after they are made (chart text widths, provenance hashes over
output text), and num() formats at the call site, so a record would put placeholder tokens into machine files
and make their bytes depend on the replay. It needs token-returning t!/num, a finaliser at every writer, and a
byte-identity proof per writer.
WebAssembly#
The wasm build embeds the English catalogue only. The other sixteen would add about 150 kB gzip each. A page
fetches i18n/<lang>/engine.ftl on a language choice and calls register_catalogue(tag, text); if the fetch or
the registration fails the page stays in English. Native, the command line and the Python wheel embed all 18.
Size, unoptimised .wasm from cargo build --release --target wasm32-unknown-unknown --features wasm --lib
(before wasm-bindgen and wasm-opt), base bc04e98f against this branch: 18.71 MB -> 19.88 MB raw, 5.43 MB ->
5.79 MB gzip, +357 kB gzip. The English catalogue is 521 kB raw, 153 kB gzip of it; the rest is fluent-bundle
and its dependencies. This is accepted as measured.
The catalogue#
i18n/<tag>/engine.ftl holds the messages, ids prefixed engine-. i18n/en/engine.ftl is the source; the other
files are the translators' and may lack messages (each falls back to English per message). Companion lists in
i18n/en/: engine-counts.txt (messages with count variables, for plural forms), engine-numbers.txt
(numbers passed as placeables), engine-fragments.txt (messages that are pieces of a sentence assembled at run
time: to be merged into whole-sentence messages, never to grow).
Tests#
tests/i18n_catalogue.rs proves English: every message has a recorded original template and renders to exactly
that text; every id is used; variables match; the pseudo-locale renders every message. i18n_api.rs,
i18n_kinds.rs, i18n_legal.rs cover the entry points, the kind descriptions and the legal texts.
i18n_ratchet.rs fails if a new string literal that a person would read bypasses the catalogue: per-file
allowance in tests/fixtures/i18n/allow.json, which may fall and never rise. scripts/i18n_compare.py runs whole
scenarios with a baseline binary and the current one and compares every output file.
How to move a string into the catalogue: docs/design/i18n-migration.md.
Dependencies and licences#
The framework adds fluent-bundle 0.16 (Apache-2.0 OR MIT) and unic-langid 0.9 (MIT OR Apache-2.0), with their
transitive crates: fluent-syntax and fluent-langneg (Apache-2.0 OR MIT), intl-memoizer (Apache-2.0 OR MIT),
intl_pluralrules (Apache-2.0/MIT), type-map (MIT/Apache-2.0), self_cell (Apache-2.0 OR GPL-2.0-only: the
Apache-2.0 alternative is the one used, which deny.toml allows), tinystr and zerovec (Unicode-3.0),
rustc-hash, smallvec, thiserror, displaydoc (MIT OR Apache-2.0). Every one is permissive under the
allow list of deny.toml; thiserror appears in two versions (1 and 2), which multiple-versions = "warn" only
reports. The licence list was read from cargo metadata; cargo deny check is not installed in the authoring
environment and runs in the integration CI.