champollion 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,9 +16,9 @@ npx champollion sync
16
16
 
17
17
  Champollion auto-detects your locale files, their format, and the target languages. It translates missing keys, skips what's already done, and writes the results. That's it.
18
18
 
19
- > **Part of Champollion** — infrastructure for trustworthy machine translation
20
- > across every language, source-available and free for noncommercial use (the
21
- > evaluation harness and shared registries are open source). This CLI is the
19
+ > **Part of Champollion** — infrastructure for trustworthy machine-translation
20
+ > evaluation across every language, source-available and free for noncommercial
21
+ > use (the evaluation harness and shared registries are open source). This CLI is the
22
22
  > deployment end of a larger
23
23
  > project that builds the test sets and the map showing who can translate what,
24
24
  > how good each method is on each kind of text, and where the gaps still are. It
@@ -26,8 +26,8 @@ Champollion auto-detects your locale files, their format, and the target languag
26
26
  > every method welcome) and sovereign benchmarks — secret test sets that
27
27
  > communities create, own, and control, and that we never see. The infrastructure
28
28
  > is source-available and singly stewarded; the test sets and the methods for a
29
- > community's language belong to that community. Built with communities, never
30
- > scraped from them — they hold the keys. Every method is welcome, human and
29
+ > community's language belong to that community. Designed to work with communities,
30
+ > never hosting their corpora — the design puts the keys in their hands. Every method is welcome, human and
31
31
  > machine. Explore the network at
32
32
  > [champollion.dev/docs/network](https://champollion.dev/docs/network/).
33
33
 
@@ -37,7 +37,7 @@ You could write a quick script that loops through your English keys and calls Go
37
37
 
38
38
  - **No change detection.** When you update an English string, the translation stays stale forever. Champollion tracks every source value with SHA-256 hashes and re-translates only what changed.
39
39
  - **No batching.** One API call per key means 200 keys = 200 round trips. Champollion batches intelligently (configurable, default 80 keys/batch for LLM, 128 for Google).
40
- - **No quality gate.** Machine translation hallucinates, echoes the source back, or outputs in the wrong script. Champollion validates every translation before writing it — wrong-script, length inflation, and source echoes are caught and rejected.
40
+ - **No quality gate.** Machine translation hallucinates, echoes the source back, or outputs in the wrong script. Champollion checks every translation before writing it — empty output, source echoes, repetition loops, length inflation, deleted content and wrong script are caught and rejected. The gate catches broken output, not wrong meaning.
41
41
  - **No format awareness.** Hardcoded to JSON? Champollion handles JSON, TOML, YAML, and Hugo Markdown (frontmatter + body) with auto-detection.
42
42
  - **No safety.** Champollion guards against prototype pollution, path traversal via crafted locale codes, and code block corruption during Markdown translation.
43
43
 
@@ -118,17 +118,17 @@ You handle the i18n framework (next-intl, i18next, Hugo). Champollion handles th
118
118
  - **Multi-format** — JSON, TOML, YAML, Hugo Markdown (front matter + body), and XLIFF 1.2
119
119
  - **Incremental** — Only translates what changed (SHA-256 hash tracking)
120
120
  - **Cached** — Translation Memory stores previous results; re-running sync costs nothing for unchanged keys
121
- - **Quality-gated** — Validates every translation: catches hallucinations, wrong-script output, source echoes, and length inflation
121
+ - **Quality-gated** — Checks every translation for broken output: repetition loops, wrong-script output, source echoes, length inflation and deleted content (it does not check meaning)
122
122
  - **Content-aware** — LLM methods shield code blocks, shortcodes, links, and interpolation variables during Markdown translation
123
123
  - **Pipeline tools** — `lint`, `audit`, `integrity`, `seo` for CI gates
124
124
  - **XLIFF interop** — Export translations for professional review in CAT tools (memoQ, SDL Trados, Phrase), import them back
125
- - **Minimal dependencies** — two runtime dependencies (better-sqlite3 for the bundled language database, CLDR locale names); no provider SDKs. Requires Node 20+
125
+ - **Minimal dependencies** — two runtime dependencies: `@translated/lara` (the Lara provider SDK) and `cree-sro-syllabics` (ALTLab/NRC's Plains Cree SRO ⇄ syllabics converter). Requires Node 20+
126
126
 
127
127
  ## Beyond Google Translate
128
128
 
129
129
  The quick start gets you running with an LLM or Google Translate. But Google Translate supports ~130 languages. There are over 7,000.
130
130
 
131
- **Champollion's core idea: the translation method is configurable per language pair.** Use Google Translate for French, an LLM with morphological coaching for Plains Cree, and a community-hosted API for Quechua — all in the same project, all with the same CLI.
131
+ **Champollion's core idea: the translation method is configurable per language pair.** Use Google Translate for French, an LLM coached with your own grammar notes and dictionary for Plains Cree, and an API you host for a language no service covers — all in the same project, all with the same CLI.
132
132
 
133
133
  ```json
134
134
  {
@@ -136,7 +136,7 @@ The quick start gets you running with an LLM or Google Translate. But Google Tra
136
136
  "pairs": {
137
137
  "en:fr": { "method": "google-translate" },
138
138
  "en:ja": { "method": "llm" },
139
- "en:crk": { "methodPlugin": "crk-coached-v1" }
139
+ "en:crk": { "method": "llm-coached" }
140
140
  }
141
141
  }
142
142
  ```
@@ -188,7 +188,7 @@ champollion sync --method deepl
188
188
  "pairs": {
189
189
  "en:fr": { "method": "deepl" },
190
190
  "en:ja": { "method": "openai", "model": "gpt-4o" },
191
- "en:crk": { "methodPlugin": "crk-coached-v1" }
191
+ "en:crk": { "method": "llm-coached" }
192
192
  }
193
193
  }
194
194
  ```
@@ -386,10 +386,24 @@ export function normalizeCard(card) {
386
386
  if (scales) {
387
387
  const claims = attributions(out.endangerment);
388
388
  for (const source of scales.authorityOrder) {
389
- const hit = claims.find((c) => String(c.source ?? '').startsWith(source));
389
+ // One source can hold several assessments (ELCat keeps one per
390
+ // record, each with its own certainty). The projector stores them
391
+ // alphabetically, which says nothing about confidence — reading the
392
+ // first one showed Plains Cree as "endangered" from a 0.2-certainty,
393
+ // BC-only record over ELCat's 0.8-certainty "threatened". Take the
394
+ // assessment the source is most certain of; ties keep stored order.
395
+ let hit = null;
396
+ let tier = null;
397
+ let best = -Infinity;
398
+ for (const c of claims) {
399
+ if (!String(c.source ?? '').startsWith(source)) continue;
400
+ const t = scales.scales[source]?.map?.[String(c.value).trim()];
401
+ if (!t) continue;
402
+ const m = /Certainty:\s*([0-9.]+)/.exec(String(c.note ?? ''));
403
+ const certainty = m ? Number(m[1]) : -1;
404
+ if (certainty > best) { best = certainty; hit = c; tier = t; }
405
+ }
390
406
  if (!hit) continue;
391
- const tier = scales.scales[source]?.map?.[String(hit.value).trim()];
392
- if (!tier) continue;
393
407
  out.vitality = {
394
408
  unescoStatus: tier,
395
409
  assessedBy: hit.source,
@@ -195,9 +195,9 @@ const COMMAND_HELP = {
195
195
  ' your text is never uploaded or hosted; you keep custody.',
196
196
  ' public — publish a metadata card + fetch-from-source pointer; text never',
197
197
  ' hosted by us, and gated to redistribution-cleared licenses.',
198
- ' sealed — encrypt the corpus ON YOUR DEVICE under the custodian group’s',
199
- ' threshold key; we receive ciphertext + a content-free card only',
200
- ' and cannot decrypt it. Paired with a public qualifier a method',
198
+ ' sealed — encrypt the corpus ON YOUR DEVICE to the custodian group’s',
199
+ ' key; the ciphertext stays with you and we receive only a',
200
+ ' content-free card. Paired with a public qualifier a method',
201
201
  ' must clear before any sealed run can be proposed.',
202
202
  '',
203
203
  'Champollion never reads, uploads, or hosts your corpus plaintext in ANY tier.',
@@ -218,7 +218,7 @@ async function runInteractive(rl, args) {
218
218
  builder = await ask(rl, 'Builder adapter id (rebuilds from source, e.g. tatoeba-challenge)', builder || '');
219
219
  }
220
220
 
221
- // Sealed tier — encrypt on this device, hand us ciphertext + a content-free card.
221
+ // Sealed tier — encrypt on this device; the ciphertext stays local, we get a content-free card.
222
222
  let custodianGroupId = args['custodian-group'] || null;
223
223
  let thresholdPubkey = args['threshold-pubkey'] || null;
224
224
  let sealInput = args['seal-input'] || null;
@@ -228,8 +228,8 @@ async function runInteractive(rl, args) {
228
228
  const keyScheme = args['key-scheme'] || null;
229
229
  if (tier.key === 'sealed') {
230
230
  console.log('');
231
- console.log(' Sealed sets are encrypted ON THIS DEVICE before anything leaves.');
232
- console.log(' We receive ciphertext + a content-free card only — we cannot read it.');
231
+ console.log(' Sealed sets are encrypted ON THIS DEVICE, and the ciphertext stays');
232
+ console.log(' where you write it. We receive only a content-free card.');
233
233
  console.log('');
234
234
  sealInput = await ask(rl, 'Path to the corpus file to seal (your plaintext, stays local)', sealInput || '');
235
235
  thresholdPubkey = await ask(rl, 'Custodian group threshold public key (path or PEM/base64)', thresholdPubkey || '');
@@ -307,7 +307,7 @@ function fromFlags(args) {
307
307
  * the custodian group's threshold PUBLIC key, write a ciphertext-ONLY artifact,
308
308
  * and return the content-free `sealed` card block. The plaintext never leaves
309
309
  * this machine in readable form; we hold only ciphertext + metadata, and we
310
- * cannot decrypt it (no single party can — see docs/governance/OCAP_MULTISIG_PLAN.md).
310
+ * cannot decrypt it (no single party can — see the community-custodian multisig plan in docs/governance).
311
311
  *
312
312
  * @returns {{cardBlock:object, artifactPath:string}|{error:string}}
313
313
  */
@@ -582,14 +582,14 @@ async function run(args, cwd) {
582
582
  console.log(' the public queue/leaderboard (custodian-controlled, WMT-style held-out).');
583
583
  console.log('');
584
584
  console.log(' Next: rebuild the registry, then evaluate privately:');
585
- console.log(' npm run generate:registries # from the repo root (arena)');
585
+ for (const line of REGISTRY_REBUILD_HINT) console.log(line);
586
586
  console.log(` mt-eval run --corpus ${id} --private # publish scores without exposing text`);
587
587
  } else {
588
588
  console.log(' A metadata card + fetch-from-source pointer is registered — your corpus');
589
589
  console.log(' TEXT is never hosted by Champollion; it is fetched from source on demand.');
590
590
  console.log('');
591
591
  console.log(' Next: rebuild the registry so the pair enters the public queue:');
592
- console.log(' npm run generate:registries # from the repo root (arena)');
592
+ for (const line of REGISTRY_REBUILD_HINT) console.log(line);
593
593
  }
594
594
  console.log('');
595
595
  return 0;
@@ -616,9 +616,9 @@ function showHelp() {
616
616
  text never uploaded or hosted; you keep custody.
617
617
  public Publish a metadata card + fetch-from-source pointer; text never
618
618
  hosted by us. Requires a redistribution-cleared license (gated).
619
- sealed Encrypt the corpus ON YOUR DEVICE under the custodian group's
620
- threshold key; we receive ciphertext + a content-free card only
621
- and cannot decrypt it. Catalogued + quarantined; paired with a
619
+ sealed Encrypt the corpus ON YOUR DEVICE to the custodian group's
620
+ key; the ciphertext stays with you and we receive only a
621
+ content-free card. Catalogued + quarantined; paired with a
622
622
  public qualifier a method must clear before any sealed run.
623
623
 
624
624
  LICENSES (--license)
@@ -675,4 +675,16 @@ function showHelp() {
675
675
  `);
676
676
  }
677
677
 
678
+ /**
679
+ * The registry-rebuild step a registrant runs after a card lands. The registry
680
+ * (arena/datasets/registry*.json + the wheel's bundled copy) is BUILT from the
681
+ * cards by the Python builder — there is no npm script for it, and a printed
682
+ * next-step that does not exist is a broken door. Tested against the builder's
683
+ * real path in test/corpus-registration.test.js.
684
+ */
685
+ export const REGISTRY_REBUILD_HINT = Object.freeze([
686
+ ' python3 arena/scripts/build_registry.py --diff # from the monorepo root: preview the registry change',
687
+ ' python3 arena/scripts/build_registry.py # rebuild arena/datasets/registry*.json (+ the bundled wheel copy)',
688
+ ]);
689
+
678
690
  export { run };
@@ -438,7 +438,7 @@ async function runPrune(args, cwd) {
438
438
  }
439
439
  try {
440
440
  // Case-sensitive, no flags: banned-term patterns are usually
441
- // case-shaped ("OCAP" vs a word that merely contains "ocap").
441
+ // case-shaped ("NASA" vs a word that merely contains "nasa").
442
442
  // Encode any case-insensitivity in the pattern itself ([Cc]…).
443
443
  matching = new RegExp(matchingRaw);
444
444
  } catch (err) {
@@ -191,7 +191,7 @@ export const EXPOSURE_TIERS = [
191
191
  uploadsCiphertext: true, // an encrypted, content-free artifact MAY be produced
192
192
  tracked: true,
193
193
  explanation:
194
- 'Encrypts your corpus ON YOUR DEVICE under the custodian group’s threshold key BEFORE a single byte leaves. Champollion receives ciphertext + a content-free card only and CANNOT decrypt it (no single party can — it takes M-of-N custodians). Catalogued but quarantined; pair it with a public qualifier that methods must clear before any sealed run can be proposed.',
194
+ 'Encrypts your corpus ON YOUR DEVICE to the custodian group’s key. The ciphertext is written where you choose and stays with you; Champollion receives only a content-free card and CANNOT decrypt anything. Catalogued but quarantined; pair it with a public qualifier that methods must clear before any sealed run can be proposed.',
195
195
  },
196
196
  ];
197
197
 
@@ -448,7 +448,7 @@ export function buildCorpusCard(o) {
448
448
  (isPublic
449
449
  ? 'Author-asserted at registration. Public fetch-from-source corpus; review upstream exposure before relying on this rating.'
450
450
  : isSealed
451
- ? 'Sealed at registration — corpus encrypted client-side under the custodian group’s threshold key; only ciphertext + a content-free card exist on our side, so the plaintext is not present in any training set via this project.'
451
+ ? 'Sealed at registration — corpus encrypted client-side to the custodian group’s key; the ciphertext stays with the holder and only a content-free card reaches this project, so the plaintext is not present in any training set via this project.'
452
452
  : 'Private/unpublished at registration — text never uploaded or hosted, so not present in any training set via this project.');
453
453
 
454
454
  // source block — a fetch-from-source pointer ONLY for the public tier.
@@ -530,7 +530,7 @@ export function buildCorpusCard(o) {
530
530
  if (isSealed) {
531
531
  card.quarantine = true;
532
532
  card.quarantineReason =
533
- 'Sealed / community-controlled secret test set — corpus encrypted client-side under the custodian group’s threshold key (exposureTier=sealed). Only ciphertext + this content-free card exist on our side; no single party can decrypt. Catalogued but never publicly runnable; a sealed run requires M-of-N custodian approval (see OCAP multisig plan).';
533
+ 'Sealed / community-controlled secret test set — corpus encrypted client-side under the custodian group’s threshold key (exposureTier=sealed). Only this content-free card reaches our side; the ciphertext stays with the holder, and no single party can decrypt it. Catalogued but never publicly runnable; a sealed run requires M-of-N custodian approval (see the community-custodian multisig plan).';
534
534
  if (o.sealed) card.sealed = o.sealed;
535
535
  }
536
536
 
package/lib/scripts.js CHANGED
@@ -12,9 +12,11 @@
12
12
  * - Japanese: Romaji → Hiragana/Katakana
13
13
  * - Hindi: Romanized → Devanagari
14
14
  *
15
- * All converters here are DETERMINISTIC — no LLM needed, pure lookup tables.
16
- * They run as a post-translation hook: translate in working script, then
17
- * convert to display script.
15
+ * All converters here are DETERMINISTIC — no LLM needed: lookup tables, or a
16
+ * community-maintained converter where one exists (Plains Cree). They run as a
17
+ * post-translation hook: translate in working script, then convert to display
18
+ * script — and only over the spans of a string that are language, never over
19
+ * placeholders, ICU syntax, tags or URLs (see splitConvertible).
18
20
  *
19
21
  * ADDING A NEW CONVERTER:
20
22
  * 1. Add the conversion map below
@@ -23,115 +25,53 @@
23
25
  * 4. Add the `scripts` field to the language's register entry in registers.js
24
26
  */
25
27
 
28
+ import creeSroSyllabics from 'cree-sro-syllabics';
29
+
26
30
  // -----------------------------------------------------------------
27
31
  // Plains Cree: SRO → Syllabics
28
32
  // -----------------------------------------------------------------
29
33
 
30
34
  /**
31
- * SRO to Cree Syllabics conversion table.
32
- *
33
- * This is the standard mapping used by the University of Alberta's
34
- * ALTLab and documented in Wolvengrey's Cree: Words dictionary.
35
+ * Plains Cree SRO ⇄ syllabics is delegated to `cree-sro-syllabics` (MIT;
36
+ * Eddie Antonio Santos, NRC — the converter ALTLab's itwêwina uses). The
37
+ * hand-written table this replaced mapped 62 of the 64 consonant+w+vowel
38
+ * syllables to the wrong glyph and had no bare w-series at all, so
39
+ * "nêhiyawêwin" came out as ᓀᐦᐃᔭᐤᐁᐤᐃᐣ instead of ᓀᐦᐃᔭᐍᐏᐣ, and no test
40
+ * checked a single real spelling. Cree spelling is the community converter's
41
+ * call; this module only decides which spans of a UI string it may touch
42
+ * (see splitConvertible / convertScript).
35
43
  *
36
- * The mapping is context-sensitive: consonant+vowel combinations map
37
- * to specific syllabic characters, while standalone consonants use
38
- * finals (small superscript forms).
39
- *
40
- * ORDER MATTERS: Longer sequences must be matched before shorter ones
41
- * (e.g., "twê" before "tw" before "t").
44
+ * Library conventions are kept as they are: a hyphen between preverb and
45
+ * verb becomes U+202F NARROW NO-BREAK SPACE, and a full stop after a syllabic
46
+ * becomes ᙮ (U+166E CANADIAN SYLLABICS FULL STOP).
42
47
  */
43
- const SRO_TO_SYLLABICS_MAP = [
44
- // Long vowels with w-glide (must come before short vowel w-glide)
45
- ['pwê', 'ᐻ'], ['pwî', 'ᐽ'], ['pwô', 'ᐿ'], ['pwâ', 'ᑁ'],
46
- ['twê', 'ᑗ'], ['twî', 'ᑙ'], ['twô', 'ᑛ'], ['twâ', 'ᑝ'],
47
- ['kwê', 'ᑵ'], ['kwî', 'ᑷ'], ['kwô', 'ᑹ'], ['kwâ', 'ᑻ'],
48
- ['cwê', 'ᒑ'], ['cwî', 'ᒓ'], ['cwô', 'ᒕ'], ['cwâ', 'ᒗ'],
49
- ['mwê', 'ᒫ'], ['mwî', 'ᒭ'], ['mwô', 'ᒯ'], ['mwâ', 'ᒱ'],
50
- ['nwê', 'ᓇ'], ['nwî', 'ᓉ'], ['nwô', 'ᓋ'], ['nwâ', 'ᓍ'],
51
- ['swê', 'ᓭ'], ['swî', 'ᓯ'], ['swô', 'ᓱ'], ['swâ', 'ᓳ'],
52
- ['ywê', 'ᔋ'], ['ywî', 'ᔍ'], ['ywô', 'ᔏ'], ['ywâ', 'ᔑ'],
53
-
54
- // Short vowels with w-glide
55
- ['pwe', 'ᐺ'], ['pwi', 'ᐼ'], ['pwo', 'ᐾ'], ['pwa', 'ᑀ'],
56
- ['twe', 'ᑖ'], ['twi', 'ᑘ'], ['two', 'ᑚ'], ['twa', 'ᑜ'],
57
- ['kwe', 'ᑴ'], ['kwi', 'ᑶ'], ['kwo', 'ᑸ'], ['kwa', 'ᑺ'],
58
- ['cwe', 'ᒐ'], ['cwi', 'ᒒ'], ['cwo', 'ᒔ'], ['cwa', 'ᒖ'],
59
- ['mwe', 'ᒪ'], ['mwi', 'ᒬ'], ['mwo', 'ᒮ'], ['mwa', 'ᒰ'],
60
- ['nwe', 'ᓈ'], ['nwi', 'ᓊ'], ['nwo', 'ᓌ'], ['nwa', 'ᓎ'],
61
- ['swe', 'ᓬ'], ['swi', 'ᓮ'], ['swo', 'ᓰ'], ['swa', 'ᓲ'],
62
- ['ywe', 'ᔊ'], ['ywi', 'ᔌ'], ['ywo', 'ᔎ'], ['ywa', 'ᔐ'],
63
-
64
- // Long vowels (macron forms — these must come before short vowels)
65
- ['pê', 'ᐯ'], ['pî', 'ᐲ'], ['pô', 'ᐴ'], ['pâ', 'ᐹ'],
66
- ['tê', 'ᑌ'], ['tî', 'ᑏ'], ['tô', 'ᑑ'], ['tâ', 'ᑖ'],
67
- ['kê', 'ᑫ'], ['kî', 'ᑮ'], ['kô', 'ᑰ'], ['kâ', 'ᑳ'],
68
- ['cê', 'ᒉ'], ['cî', 'ᒌ'], ['cô', 'ᒎ'], ['câ', 'ᒑ'],
69
- ['mê', 'ᒣ'], ['mî', 'ᒦ'], ['mô', 'ᒨ'], ['mâ', 'ᒫ'],
70
- ['nê', 'ᓀ'], ['nî', 'ᓃ'], ['nô', 'ᓅ'], ['nâ', 'ᓈ'],
71
- ['sê', 'ᓭ'], ['sî', 'ᓰ'], ['sô', 'ᓲ'], ['sâ', 'ᓵ'],
72
- ['yê', 'ᔦ'], ['yî', 'ᔩ'], ['yô', 'ᔫ'], ['yâ', 'ᔮ'],
73
-
74
- // Short vowels (consonant+vowel)
75
- ['pe', 'ᐯ'], ['pi', 'ᐱ'], ['po', 'ᐳ'], ['pa', 'ᐸ'],
76
- ['te', 'ᑌ'], ['ti', 'ᑎ'], ['to', 'ᑐ'], ['ta', 'ᑕ'],
77
- ['ke', 'ᑫ'], ['ki', 'ᑭ'], ['ko', 'ᑯ'], ['ka', 'ᑲ'],
78
- ['ce', 'ᒉ'], ['ci', 'ᒋ'], ['co', 'ᒍ'], ['ca', 'ᒐ'],
79
- ['me', 'ᒣ'], ['mi', 'ᒥ'], ['mo', 'ᒧ'], ['ma', 'ᒪ'],
80
- ['ne', 'ᓀ'], ['ni', 'ᓂ'], ['no', 'ᓄ'], ['na', 'ᓇ'],
81
- ['se', 'ᓭ'], ['si', 'ᓯ'], ['so', 'ᓱ'], ['sa', 'ᓴ'],
82
- ['ye', 'ᔦ'], ['yi', 'ᔨ'], ['yo', 'ᔪ'], ['ya', 'ᔭ'],
83
-
84
- // Standalone vowels (long first)
85
- ['ê', 'ᐁ'], ['î', 'ᐄ'], ['ô', 'ᐆ'], ['â', 'ᐋ'],
86
- ['e', 'ᐁ'], ['i', 'ᐃ'], ['o', 'ᐅ'], ['a', 'ᐊ'],
87
-
88
- // Digraphs (must come before single-char finals)
89
- ['th', 'ᖧ'],
90
-
91
- // Finals (standalone consonants — no following vowel)
92
- ['p', 'ᑊ'], ['t', 'ᐟ'], ['k', 'ᐠ'], ['c', 'ᐨ'],
93
- ['m', 'ᒼ'], ['n', 'ᐣ'], ['s', 'ᐢ'], ['y', 'ᐩ'],
94
-
95
- // Special characters
96
- ['h', 'ᐦ'], ['w', 'ᐤ'], ['l', 'ᓬ'], ['r', 'ᕒ'],
97
- ];
48
+ const { sro2syllabics, syllabics2sro } = creeSroSyllabics;
98
49
 
99
50
  /**
100
- * Convert SRO text to Cree Syllabics.
101
- *
102
- * This is a greedy left-to-right scan: at each position, try the longest
103
- * possible match first. Characters that don't match any pattern (spaces,
104
- * punctuation, numbers) pass through unchanged.
51
+ * Convert SRO text to Cree Syllabics. Letters the library does not recognise
52
+ * as SRO pass through unchanged; creeLeftoverLetters() reports them.
105
53
  *
106
54
  * @param {string} sro - SRO text to convert
107
55
  * @returns {string} Syllabics text
108
56
  */
109
57
  function sroToSyllabics(sro) {
110
- const input = sro.toLowerCase();
111
- let result = '';
112
- let i = 0;
58
+ return sro2syllabics(sro);
59
+ }
113
60
 
114
- while (i < input.length) {
115
- let matched = false;
61
+ /** The Unified Canadian Aboriginal Syllabics blocks (base and extended). */
62
+ const SYLLABICS_CHAR = /[\u1400-\u167F\u18B0-\u18FF]/u;
116
63
 
117
- // Try longest matches first (up to 3 characters)
118
- for (const [from, to] of SRO_TO_SYLLABICS_MAP) {
119
- if (input.startsWith(from, i)) {
120
- result += to;
121
- i += from.length;
122
- matched = true;
123
- break;
124
- }
125
- }
126
-
127
- // No match — pass character through (space, punctuation, etc.)
128
- if (!matched) {
129
- result += input[i];
130
- i++;
131
- }
64
+ /**
65
+ * Letters the Cree converter left unconverted, derived from its behaviour:
66
+ * convert, then report every letter in the output that is not syllabics.
67
+ * Coverage therefore cannot drift from what the library actually does.
68
+ */
69
+ function creeLeftoverLetters(text) {
70
+ const out = [];
71
+ for (const ch of sroToSyllabics(text)) {
72
+ if (/\p{L}/u.test(ch) && !SYLLABICS_CHAR.test(ch) && !out.includes(ch)) out.push(ch);
132
73
  }
133
-
134
- return result;
74
+ return out;
135
75
  }
136
76
 
137
77
  // -----------------------------------------------------------------
@@ -456,9 +396,13 @@ const SCRIPT_CONVERTERS = {
456
396
  fromScript: 'Latn',
457
397
  toScript: 'Cans',
458
398
  type: 'deterministic',
459
- map: SRO_TO_SYLLABICS_MAP,
399
+ // No table of our own: conversion, coverage and reversal all come from
400
+ // the cree-sro-syllabics library (see the Plains Cree section above).
401
+ map: null,
460
402
  puaRange: null,
461
403
  converter: sroToSyllabics,
404
+ leftoverLetters: creeLeftoverLetters,
405
+ reverse: syllabics2sro,
462
406
  },
463
407
  sr: {
464
408
  from: 'Latin',
@@ -815,6 +759,9 @@ function unmappedLetters(text, converterKey) {
815
759
  const conv = SCRIPT_CONVERTERS[converterKey];
816
760
  if (!conv || typeof text !== 'string' || text === '') return [];
817
761
 
762
+ // Library-backed converters report their own leftovers.
763
+ if (conv.leftoverLetters) return conv.leftoverLetters(text);
764
+
818
765
  // Kryptonian is arithmetic over A–Z with no table; every letter outside
819
766
  // the basic Latin alphabet is unmapped.
820
767
  if (!conv.map) {
@@ -830,9 +777,7 @@ function unmappedLetters(text, converterKey) {
830
777
  const sequences = mappedSequences(converterKey);
831
778
  // Converters that lowercase (or uppercase) their input before matching must
832
779
  // be probed in that same normalised form, or every capital reads as unmapped.
833
- const probe = conv.converter === latinToTengwar || conv.converter === sroToSyllabics
834
- ? text.toLowerCase()
835
- : text;
780
+ const probe = conv.converter === latinToTengwar ? text.toLowerCase() : text;
836
781
 
837
782
  const out = [];
838
783
  let i = 0;
@@ -871,6 +816,11 @@ function reverseScript(text, converterKey) {
871
816
 
872
817
  const caseLossy = conv.converter !== romanizationToPiqad;
873
818
 
819
+ // Library-backed converters bring their own inverse (Cree: syllabics2sro).
820
+ if (conv.reverse) {
821
+ return { reversed: conv.reverse(text), caseLossy, unreversed: [] };
822
+ }
823
+
874
824
  if (!conv.map) {
875
825
  // Kryptonian: U+E100–E119 → A–Z.
876
826
  const [lo, hi] = conv.puaRange;
@@ -916,13 +866,136 @@ function isPrivateUse(codePoint) {
916
866
  || (codePoint >= 0x100000 && codePoint <= 0x10FFFD);
917
867
  }
918
868
 
869
+ // Tokens inside ordinary message text that are code, not language: double-
870
+ // brace and ${…} placeholders, printf tokens, HTML/XML tags, URLs, e-mail.
871
+ const FLAT_PROTECTED = new RegExp([
872
+ String.raw`\{\{[^{}]*\}\}`,
873
+ String.raw`\$\{[^{}]*\}`,
874
+ String.raw`%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?[sdifuxXoeEgGc@]`,
875
+ String.raw`%\([A-Za-z_][A-Za-z0-9_]*\)[sd]`,
876
+ String.raw`<\/?[A-Za-z][^<>]*>`,
877
+ String.raw`(?:https?:\/\/|www\.)[^\s<>{}"']+`,
878
+ String.raw`[\w.+-]+@[\w-]+\.[\w.-]+`,
879
+ ].join('|'), 'g');
880
+
881
+ const ICU_BRANCHING = new Set(['plural', 'select', 'selectordinal']);
882
+
883
+ /**
884
+ * Split a UI string into the spans a script converter may touch and the spans
885
+ * it must leave byte-for-byte.
886
+ *
887
+ * WHY: converters used to run over the whole value, so "tânisi, {name}!"
888
+ * became "ᑖᓂᓯ, {ᓇᒣ}!" and Serbian "{name}" became "{наме}" — a broken
889
+ * placeholder that crashes the app at render time and that nothing caught.
890
+ *
891
+ * Protected: ICU argument names, types, styles and plural/select selectors
892
+ * (the TEXT of each branch stays convertible, recursively), `#` in plural
893
+ * branches, and the FLAT_PROTECTED tokens. Concatenating the segments'
894
+ * `text` always reproduces the input exactly.
895
+ *
896
+ * @param {string} text
897
+ * @returns {{ text: string, convert: boolean }[]}
898
+ */
899
+ function splitConvertible(text) {
900
+ const segments = [];
901
+ const push = (s, convert) => {
902
+ if (!s) return;
903
+ const last = segments[segments.length - 1];
904
+ if (last && last.convert === convert) last.text += s;
905
+ else segments.push({ text: s, convert });
906
+ };
907
+ const pushText = (s) => {
908
+ let at = 0;
909
+ for (const m of s.matchAll(FLAT_PROTECTED)) {
910
+ push(s.slice(at, m.index), true);
911
+ push(m[0], false);
912
+ at = m.index + m[0].length;
913
+ }
914
+ push(s.slice(at), true);
915
+ };
916
+ // Index of the brace that closes the one at `i`, or -1.
917
+ const matchingBrace = (i) => {
918
+ let depth = 0;
919
+ for (let k = i; k < text.length; k++) {
920
+ if (text[k] === '{') depth++;
921
+ else if (text[k] === '}' && --depth === 0) return k;
922
+ }
923
+ return -1;
924
+ };
925
+
926
+ // Message text from `i` until the end or (inBranch) the branch's closing
927
+ // brace; returns the index of that brace, or text.length.
928
+ function message(i, inBranch) {
929
+ let chunk = '';
930
+ while (i < text.length) {
931
+ const ch = text[i];
932
+ if (ch === '}' && inBranch) break;
933
+ if (ch === '{') {
934
+ pushText(chunk); chunk = '';
935
+ i = argument(i);
936
+ continue;
937
+ }
938
+ if (ch === '#' && inBranch) {
939
+ pushText(chunk); chunk = '';
940
+ push('#', false); i++;
941
+ continue;
942
+ }
943
+ chunk += ch; i++;
944
+ }
945
+ pushText(chunk);
946
+ return i;
947
+ }
948
+
949
+ // An ICU argument starting at the brace at `i`; returns the index after it.
950
+ function argument(i) {
951
+ const close = matchingBrace(i);
952
+ if (close === -1) { push(text[i], false); return i + 1; } // stray brace
953
+ // {{mustache}} and ${…} (the "$" was already pushed as text) stay whole.
954
+ if (text[i + 1] === '{' || text[i - 1] === '$') {
955
+ push(text.slice(i, close + 1), false);
956
+ return close + 1;
957
+ }
958
+ // Greedy name: a lazy one lets the optional ", plural," group match nothing.
959
+ const head = /^\{[^,{}]*(?:,\s*([A-Za-z]+)\s*,?)?/.exec(text.slice(i, close + 1));
960
+ const type = head?.[1];
961
+ if (!type || !ICU_BRANCHING.has(type)) {
962
+ push(text.slice(i, close + 1), false); // {name}, {n, number}, {d, date, short}
963
+ return close + 1;
964
+ }
965
+ // plural / select: protect the header and every selector, convert branches.
966
+ let k = i + head[0].length;
967
+ push(text.slice(i, k), false);
968
+ while (k < close) {
969
+ const sel = /^\s*[^\s{}]*\s*/.exec(text.slice(k, close))[0]; // "one ", "=0", "offset:1 "
970
+ push(sel, false); k += sel.length;
971
+ if (text[k] === '{') {
972
+ push('{', false);
973
+ k = message(k + 1, true);
974
+ push('}', false);
975
+ k += 1;
976
+ } else if (sel === '') {
977
+ break; // malformed; the remainder is protected below
978
+ }
979
+ }
980
+ push(text.slice(k, close + 1), false);
981
+ return close + 1;
982
+ }
983
+
984
+ message(0, false);
985
+ return segments;
986
+ }
987
+
919
988
  /**
920
989
  * Convert text using the registered converter for a locale.
921
990
  *
991
+ * Only the spans splitConvertible() marks as language are converted;
992
+ * placeholders, ICU syntax, tags and URLs come back byte-for-byte.
993
+ *
922
994
  * `unmapped` lists letters the converter could not translate and passed
923
- * through as-is. A non-empty `unmapped` means the input was not valid text in
924
- * the converter's working script, and the output is a mix of both scripts —
925
- * callers must treat it as a failure rather than writing it out.
995
+ * through as-is, counted over the converted spans only. A non-empty
996
+ * `unmapped` means the input was not valid text in the converter's working
997
+ * script, and the output is a mix of both scripts — callers must treat it as
998
+ * a failure rather than writing it out.
926
999
  *
927
1000
  * @param {string} text - Text in the source script
928
1001
  * @param {string} localeCode - Locale code (e.g., "crk", "sr")
@@ -933,11 +1006,19 @@ function convertScript(text, localeCode) {
933
1006
  if (!converter) {
934
1007
  return { converted: text, converterUsed: null, unmapped: [] };
935
1008
  }
1009
+ const converterUsed = `${converter.from} → ${converter.to}`;
1010
+ if (typeof text !== 'string' || text === '') {
1011
+ return { converted: converter.converter(text ?? ''), converterUsed, unmapped: [] };
1012
+ }
936
1013
 
1014
+ const segments = splitConvertible(text);
937
1015
  return {
938
- converted: converter.converter(text),
939
- converterUsed: `${converter.from} → ${converter.to}`,
940
- unmapped: unmappedLetters(text, localeCode),
1016
+ converted: segments.map((s) => (s.convert ? converter.converter(s.text) : s.text)).join(''),
1017
+ converterUsed,
1018
+ unmapped: unmappedLetters(
1019
+ segments.filter((s) => s.convert).map((s) => s.text).join(' '),
1020
+ localeCode,
1021
+ ),
941
1022
  };
942
1023
  }
943
1024
 
@@ -990,5 +1071,6 @@ export {
990
1071
  unmappedLetters,
991
1072
  reverseScript,
992
1073
  isPrivateUse,
1074
+ splitConvertible,
993
1075
  SCRIPT_CONVERTERS,
994
1076
  };
package/lib/seal.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * seal.mjs — CLIENT-SIDE encryption for the SEALED exposure tier.
3
3
  *
4
4
  * This is the cryptographic front door for the zero-knowledge sovereign-eval
5
- * PoC (docs/governance/OCAP_MULTISIG_PLAN.md, mechanism M1). A community that
5
+ * PoC (community-custodian multisig plan, docs/governance, mechanism M1). A community that
6
6
  * chooses to make a held-out set centrally evaluable-on-demand encrypts it
7
7
  * **on their own machine, before a single byte leaves**, under a *threshold
8
8
  * public key* the platform cannot decrypt alone. Champollion ever only receives
@@ -47,7 +47,7 @@
47
47
  * format and the card block do NOT change under either model; that is the
48
48
  * whole point of the seam. Historical note: earlier docs named FROST here —
49
49
  * FROST is threshold *signing*, the wrong primitive for this decryption path;
50
- * see docs/governance/OCAP_MULTISIG_PLAN.md for the corrected framing.
50
+ * see the community-custodian multisig plan (docs/governance) for the corrected framing.
51
51
  *
52
52
  * @module seal
53
53
  */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A sealed (community-controlled secret) test set is precious: every evaluation
5
5
  * against it leaks a little signal, and custodian approval is a real,
6
- * low-frequency act (M-of-N, docs/governance/OCAP_MULTISIG_PLAN.md M2). So a
6
+ * low-frequency act (M-of-N, community-custodian multisig plan M2, docs/governance). So a
7
7
  * method must EARN the right to even *propose* a sealed run by first clearing a
8
8
  * disjoint, fully public twin — the **qualifier**.
9
9
  *