champollion 0.3.4 → 0.4.0

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.
Files changed (132) hide show
  1. package/README.md +41 -26
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +632 -125
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +15 -9
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
@@ -27,7 +27,7 @@ the method modules themselves, which bring their own dependencies).
27
27
  Why self-contained:
28
28
  The bridge inlines the method_loader logic (~60 lines) so the user
29
29
  does NOT need to pip install the Arena. Only the method module
30
- (e.g., pip install crk-translate) is required.
30
+ (e.g., python3 -m pip install crk-translate) is required.
31
31
 
32
32
  Maintained alongside the Arena's method_loader.py — if the manifest
33
33
  format changes there, it must be updated here. Both live in the same
@@ -58,6 +58,13 @@ logger = logging.getLogger("champollion.bridge")
58
58
 
59
59
  _REQUIRED_MANIFEST_FIELDS = {"name", "entry_point"}
60
60
 
61
+ # Temperature a plugin receives when the CLI config sets none. It MUST equal
62
+ # the harness RunConfig default (arena/mt_eval_harness/config.py
63
+ # DEFAULT_TEMPERATURE) — a plugin validated in the Arena must see the same
64
+ # config here. cli/test/external-method.test.js checks the two against each
65
+ # other. (This was 0.3: a CLI-only default the plugin never saw in the Arena.)
66
+ HARNESS_DEFAULT_TEMPERATURE = 0.0
67
+
61
68
 
62
69
  class MethodLoadError(Exception):
63
70
  """Raised when a method plugin cannot be loaded."""
@@ -145,7 +152,7 @@ def _load_method(method_dir: Path) -> tuple:
145
152
  raise MethodLoadError(
146
153
  f"Failed to load {module_file}: {type(e).__name__}: {e}\n"
147
154
  f" Check that the method module's dependencies are installed.\n"
148
- f" Try: pip install -e {method_dir.parent}"
155
+ f" Try: {sys.executable} -m pip install -e '{method_dir.parent}'"
149
156
  ) from e
150
157
 
151
158
  if not hasattr(module, class_name):
@@ -280,7 +287,12 @@ async def _handle_translate(method, request: dict) -> dict:
280
287
  cfg.model_id = config_overrides.get("model") # CrkPipelineMethod reads this
281
288
  cfg.source_lang = request.get("source_locale", "en")
282
289
  cfg.target_lang = request.get("target_locale", "")
283
- cfg.temperature = config_overrides.get("temperature", 0.3)
290
+ # The CLI sends null (or omits the key) when the config sets no
291
+ # temperature; either way the plugin gets the harness default.
292
+ temperature = config_overrides.get("temperature")
293
+ cfg.temperature = (
294
+ HARNESS_DEFAULT_TEMPERATURE if temperature is None else float(temperature)
295
+ )
284
296
 
285
297
  # Call the method's translate — same interface the Arena uses
286
298
  results = await method.translate(entries, cfg)
@@ -122,6 +122,40 @@ export function isDisputed(value) {
122
122
  || value.agreement === AGREEMENT.INCOMMENSURABLE);
123
123
  }
124
124
 
125
+ /**
126
+ * Every attribution envelope on a card, with the dotted path it sits at.
127
+ *
128
+ * A display layer that lists the envelopes it knows by name goes stale the
129
+ * day the atlas starts attributing another field — and the new field's
130
+ * disagreement then never reaches a reader. Walking the card finds them all,
131
+ * so a surface can show every disputed field, including ones it was not
132
+ * written for. `_`-prefixed keys are bookkeeping (provenance, build stamps),
133
+ * not facts, and are skipped; an envelope's own values are not walked into.
134
+ *
135
+ * @param {object} card a raw or normalized card
136
+ * @returns {{path: string, value: object}[]}
137
+ */
138
+ export function attributedFields(card) {
139
+ const found = [];
140
+ const walk = (v, p) => {
141
+ if (!v || typeof v !== 'object') return;
142
+ if (isAttributed(v)) {
143
+ found.push({ path: p, value: v });
144
+ return;
145
+ }
146
+ if (Array.isArray(v)) {
147
+ v.forEach((x, i) => walk(x, `${p}[${i}]`));
148
+ return;
149
+ }
150
+ for (const [k, x] of Object.entries(v)) {
151
+ if (k.startsWith('_')) continue;
152
+ walk(x, p ? `${p}.${k}` : k);
153
+ }
154
+ };
155
+ walk(card, '');
156
+ return found;
157
+ }
158
+
125
159
  /**
126
160
  * Read one card. Returns null when the language has no card — which is a real
127
161
  * answer: a language with no asserted value gets no card, rather than an empty
@@ -262,6 +262,15 @@ export function deriveRegistersFromFormality(formality) {
262
262
  * be worse than omitting them.
263
263
  */
264
264
  const DETAIL_PASSTHROUGH = [
265
+ // Attributed envelope, same shape as the card's ({agreement, values:
266
+ // [{value, source, note}]}); the reader derives vitality from it by source.
267
+ // It was published but not passed through, so every long-tail card lost
268
+ // its endangerment (MCP get_language on abp, 2026-10-03).
269
+ 'endangerment',
270
+ // Dictionaries / grammars / wordlists — "what exists for this language".
271
+ // Published by build-trading-card-data.mjs from 2026-10-03 on; absent on
272
+ // rows uploaded before that, in which case the field simply stays unset.
273
+ 'lexicalResources',
265
274
  'classification',
266
275
  'vitality',
267
276
  'speakerEstimates',
@@ -290,6 +299,10 @@ const DETAIL_PASSTHROUGH = [
290
299
  'macrolanguage',
291
300
  'members',
292
301
  'taxonomyNotes',
302
+ // Per-field citations (build-trading-card-data.mjs from 2026-10-04 on).
303
+ // Absent on rows uploaded before that: readers then report the source as
304
+ // not carried rather than inventing one.
305
+ '_fieldSources',
293
306
  ];
294
307
 
295
308
  /**
@@ -352,7 +365,9 @@ export function buildCardFromRemote(indexRow, detailRow, { aliases } = {}) {
352
365
  if (derived.defaultKey && !card.formality.default) {
353
366
  card.formality = { ...card.formality, default: derived.defaultKey };
354
367
  }
368
+ // Merged, never replaced: the detail blob's own citations came first.
355
369
  card._fieldSources = {
370
+ ...(card._fieldSources || {}),
356
371
  registers: `derived-from-formality (${card.formality.source || 'unknown'})`,
357
372
  };
358
373
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * search-names.js — the names a language is FOUND by, for the bundled manifest.
3
+ *
4
+ * An npm install ships the full card for ~1,150 core languages only; every
5
+ * other language is a manifest entry in shared/cards-fallback.json. That entry
6
+ * used to carry the displayed name and code aliases and nothing else, so an
7
+ * install could not find Innu (moe) by "Montagnais" — the alternate name
8
+ * ISO 639-3 records — nor 1,543 languages by their endonyms nor 411 by the
9
+ * name another registry gives them (Round 11). The repo corpus found them all.
10
+ *
11
+ * This module turns a card's recorded names into the manifest's compact
12
+ * search-name list, and back:
13
+ *
14
+ * - which names: every value of the `name` envelope (each registry's name),
15
+ * every value of the `endonym` envelope, and `alternateNames` (ISO 639-3's
16
+ * list) — read through the card adapter's `attributions()`, each with the
17
+ * source that records it;
18
+ * - deduplicated the way search compares names (`foldForSearch`, the same
19
+ * fold as the MCP server's normalizeForMatch): a name that folds to the
20
+ * displayed name or a code alias is dropped (search already finds it), and
21
+ * names that fold alike are kept once — the first spelling, cited to the
22
+ * sources that record exactly that spelling (never to a source that wrote
23
+ * it differently);
24
+ * - each name's sources kept compactly: `[text, ref, ref, …]`, where a ref
25
+ * indexes the bundle's `nameRefs` table of `[field, source]` pairs
26
+ * (three sources × three fields today — a source id is written once, not
27
+ * once per name).
28
+ *
29
+ * A search name is for FINDING a language. Anything a result shows from one
30
+ * carries its field and source (decodeSearchNames returns both), so the
31
+ * result can say how it matched and cite it.
32
+ */
33
+
34
+ import { attributions, isAttributed } from './reader.js';
35
+
36
+ /** Card fields whose values are names a language is known by, in card order. */
37
+ export const SEARCH_NAME_FIELDS = Object.freeze(['name', 'endonym', 'alternateNames']);
38
+
39
+ /**
40
+ * Fold a string the way language search compares names: Unicode-decompose,
41
+ * drop combining marks, lowercase, collapse every run of non-letters/digits
42
+ * to one space. "Èdè Yorùbá" → "ede yoruba"; "Ta'Izzi-Adeni" → "ta izzi adeni".
43
+ * Must stay identical to normalizeForMatch in mcp-server/src/tools/languages.js
44
+ * (a test there holds the two together).
45
+ *
46
+ * @param {unknown} s
47
+ * @returns {string}
48
+ */
49
+ export function foldForSearch(s) {
50
+ return String(s ?? '')
51
+ .normalize('NFD')
52
+ .replace(/\p{M}+/gu, '')
53
+ .toLowerCase()
54
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
55
+ .trim();
56
+ }
57
+
58
+ /** A bare lowercase 2–3 letter string is a code, not a name ("Ata" is a name). */
59
+ const CODE_LIKE = /^[a-z]{2,3}$/;
60
+
61
+ /** The sources a card stamps on a flat field (`_fieldSources`). */
62
+ function stampedSources(card, field) {
63
+ const v = card?._fieldSources?.[field];
64
+ return (Array.isArray(v) ? v : [v]).filter((s) => typeof s === 'string' && s);
65
+ }
66
+
67
+ /**
68
+ * Every recorded name on a RAW card (before normalizeCard flattens `name`),
69
+ * each with the field and source that record it.
70
+ *
71
+ * @param {object} raw a card as stored (attribution envelopes intact)
72
+ * @returns {Array<{text: string, field: string, source: string|null}>}
73
+ */
74
+ export function searchNameClaims(raw) {
75
+ const out = [];
76
+ for (const field of SEARCH_NAME_FIELDS) {
77
+ const value = raw?.[field];
78
+ if (value === null || value === undefined) continue;
79
+ // An envelope names each value's source; a flat value (alternateNames is
80
+ // a plain list) carries the sources the card stamps on the field.
81
+ const stamped = isAttributed(value) ? null : stampedSources(raw, field);
82
+ for (const claim of attributions(value)) {
83
+ const values = Array.isArray(claim?.value) ? claim.value : [claim?.value];
84
+ const sources = stamped ?? [claim?.source];
85
+ for (const v of values) {
86
+ if (typeof v !== 'string' || !v.trim()) continue;
87
+ const text = v.trim();
88
+ if (field === 'alternateNames' && CODE_LIKE.test(text)) continue;
89
+ for (const s of sources.length ? sources : [null]) {
90
+ out.push({ text, field, source: typeof s === 'string' && s ? s : null });
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * The `nameRefs` table for a set of claims: every `[field, source]` pair that
100
+ * occurs, sorted (so the bundle is deterministic), and the lookup that turns
101
+ * a pair into its index.
102
+ *
103
+ * @param {Iterable<{field: string, source: string|null}>} claims
104
+ * @returns {{ table: Array<[string, string|null]>, ref: (field: string, source: string|null) => number }}
105
+ */
106
+ export function buildRefTable(claims) {
107
+ const keys = new Set();
108
+ for (const { field, source } of claims) keys.add(JSON.stringify([field, source ?? null]));
109
+ const sorted = [...keys].sort();
110
+ const index = new Map(sorted.map((k, i) => [k, i]));
111
+ return {
112
+ table: sorted.map((k) => JSON.parse(k)),
113
+ ref: (field, source) => {
114
+ const i = index.get(JSON.stringify([field, source ?? null]));
115
+ if (i === undefined) throw new Error(`no name ref for [${field}, ${source}] — build the table from the same claims`);
116
+ return i;
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * The compact search-name list for one manifest entry.
123
+ *
124
+ * @param {Array<{text: string, field: string, source: string|null}>} claims
125
+ * from searchNameClaims()
126
+ * @param {{ exclude?: string[], ref: (field: string, source: string|null) => number }} opts
127
+ * exclude: names search already reaches (the displayed name, code aliases)
128
+ * @returns {Array<Array<string|number>>} [[text, ref, …], …] — empty when
129
+ * the card records no name search would not already find
130
+ */
131
+ export function encodeSearchNames(claims, { exclude = [], ref }) {
132
+ const reached = new Set(exclude.map(foldForSearch).filter(Boolean));
133
+ const byFold = new Map();
134
+ for (const { text, field, source } of claims) {
135
+ const key = foldForSearch(text);
136
+ if (!key || reached.has(key)) continue;
137
+ let entry = byFold.get(key);
138
+ if (!entry) {
139
+ entry = { text, refs: [] };
140
+ byFold.set(key, entry);
141
+ }
142
+ // Cited only to the sources that record THIS spelling: a source that wrote
143
+ // "kweyol" is not quoted as writing "Kwéyòl". The other spelling folds the
144
+ // same, so search loses nothing by keeping one.
145
+ if (text !== entry.text) continue;
146
+ const r = ref(field, source);
147
+ if (!entry.refs.includes(r)) entry.refs.push(r);
148
+ }
149
+ return [...byFold.values()].map((e) => [e.text, ...e.refs]);
150
+ }
151
+
152
+ /**
153
+ * Read a manifest entry's search names back, each with its field and sources.
154
+ *
155
+ * @param {object} entry a manifest entry ({ n, a, d, s })
156
+ * @param {Array<[string, string|null]>} refs the bundle's `nameRefs`
157
+ * @returns {Array<{text: string, fields: Array<{field: string, sources: string[]}>}>}
158
+ * fields in first-cited order; an unknown ref is skipped, never guessed
159
+ */
160
+ export function decodeSearchNames(entry, refs) {
161
+ const out = [];
162
+ for (const tuple of Array.isArray(entry?.s) ? entry.s : []) {
163
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string') continue;
164
+ const fields = [];
165
+ for (const r of tuple.slice(1)) {
166
+ const pair = Array.isArray(refs) ? refs[r] : undefined;
167
+ if (!Array.isArray(pair) || typeof pair[0] !== 'string') continue;
168
+ let f = fields.find((x) => x.field === pair[0]);
169
+ if (!f) {
170
+ f = { field: pair[0], sources: [] };
171
+ fields.push(f);
172
+ }
173
+ if (typeof pair[1] === 'string' && pair[1] && !f.sources.includes(pair[1])) f.sources.push(pair[1]);
174
+ }
175
+ if (fields.length) out.push({ text: tuple[0], fields });
176
+ }
177
+ return out;
178
+ }