champollion 0.3.3

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 (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Method recommendation for a language pair — the routing evidence surface.
3
+ *
4
+ * JS port of arena/mt_eval_harness/recommend.py (the spec — keep the two in
5
+ * sync; its test contract lives in arena/tests/test_recommend.py and is
6
+ * mirrored in test/recommend.test.js). `champollion recommend SRC TGT`
7
+ * answers the practical question a translator-integrator actually has:
8
+ * "I need to translate SRC→TGT — what are my options, what does the published
9
+ * evidence say, and what can I actually run right now?" — while refusing to
10
+ * overclaim.
11
+ *
12
+ * Three evidence tiers are consulted, all offline (shared/ artifacts — no
13
+ * network, no credentials):
14
+ *
15
+ * 1. Dispatchable methods — shared/method-registry.json (the cross-runtime
16
+ * SSOT, via lib/method-manifest.js): every engine/provider with per-
17
+ * method AVAILABILITY resolved live (is its API key set? is it
18
+ * harness-only? is its license commercial-ready?). Key resolution reads
19
+ * the registry's credential_env metadata (key pairs require ALL vars;
20
+ * config vars like AWS_REGION never count) reconciled with
21
+ * lib/methods/provider-env.js so the verdict matches exactly what the
22
+ * method loaders read (canonical name + aliases, process.env AND .env
23
+ * files) — see resolveAvailability for the precedence.
24
+ * 2. Curated cited results — shared/catalogue/external-results.json
25
+ * (bundled into the npm package via sync:shared): hand-verified
26
+ * published datapoints (cited ≠ reproduced), direction-exact.
27
+ * 3. Bulk cited results — shared/catalogue/external-mt-index.json: the
28
+ * machine-imported best-published-score index (OPUS-MT leaderboard
29
+ * family), RELATIVE-ONLY lane by construction. NOT bundled into the
30
+ * npm package (1.6 MB) — available in monorepo checkouts; packaged
31
+ * installs degrade to an explicit note.
32
+ *
33
+ * HONESTY CONTRACT (the whole point):
34
+ * - Cited evidence orders methods relative to each other on a memorized
35
+ * public benchmark — never absolute quality, never a deployment ranking.
36
+ * - Evidence and dispatchability are DIFFERENT axes: the best-evidenced
37
+ * model for a pair may not be runnable in Champollion, and may be
38
+ * NC-licensed (excluded from commercial-lane recommendations). The two
39
+ * are joined only with explicit caveats.
40
+ * - No evidence → say exactly that, and point at the runnable corpora so
41
+ * the user can MEASURE rather than guess.
42
+ * - Missing artifacts (packaged installs) → each tier degrades to an
43
+ * explicit "not available" note, never silently.
44
+ *
45
+ * Pure logic over the shared artifacts; loaders take explicit fixtures so
46
+ * tests can inject them.
47
+ */
48
+
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import { fileURLToPath } from 'node:url';
52
+
53
+ import { loadMethodManifest, cliNameFor } from './method-manifest.js';
54
+ import { providerEnvNames } from './methods/provider-env.js';
55
+ import { getEnvOrFileVar } from './api-key.js';
56
+ import { LANE_RELATIVE_ONLY, laneForGrade, normalizeGrade } from './contamination-lane.js';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Shared-artifact resolution (mirrors method-manifest.js's loader)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Find shared/catalogue/<filename>: package-bundled copy first
64
+ * (cli/shared/catalogue/, shipped in the npm package), then the
65
+ * monorepo-root SSOT. Returns null when neither exists — callers render an
66
+ * explicit "not available" note, never a silent skip.
67
+ *
68
+ * @param {string} filename
69
+ * @returns {string|null}
70
+ */
71
+ function cataloguePath(filename) {
72
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
73
+ const candidates = [
74
+ path.resolve(__dirname, '..', 'shared', 'catalogue', filename),
75
+ path.resolve(__dirname, '..', '..', 'shared', 'catalogue', filename),
76
+ ];
77
+ for (const p of candidates) {
78
+ if (fs.existsSync(p)) return p;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** @returns {object|null} parsed JSON, or null when the path is null/unreadable */
84
+ function loadCatalogueJson(filename) {
85
+ const p = cataloguePath(filename);
86
+ if (p === null) return null;
87
+ try {
88
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Tier 1 — dispatchable methods + live availability
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Resolve one method-registry entry to a live availability verdict.
100
+ *
101
+ * Returns { status, detail } where status ∈:
102
+ * ready — invocable right now (needed env key present, or none needed)
103
+ * needs-key — adapter exists; set one of the named env vars
104
+ * local-setup — local engine; needs the optional install/extra
105
+ *
106
+ * Readiness is judged on the entry's CREDENTIAL vars only — the registry
107
+ * `env` list also names non-credential config vars (AWS_REGION, *_ENDPOINT)
108
+ * the adapter reads, and those must never make a method read "ready" (a
109
+ * region is not auth). The var list is picked in this order:
110
+ * 1. `credential_env` + `credential_env_all: true` (key-pair auth: EVERY
111
+ * var required — AWS id + secret, Lara id + secret). Beats PROVIDER_ENV,
112
+ * whose any-of semantics fit alias lists, not pairs.
113
+ * 2. PROVIDER_ENV (lib/methods/provider-env.js) when the provider is
114
+ * registered there: exactly the surface the method loaders read
115
+ * (canonical + aliases, incl. names the registry doesn't carry), any-of.
116
+ * 3. `credential_env` (any-of) — the registry's declared credential subset,
117
+ * for providers with no PROVIDER_ENV entry (harness-only engines).
118
+ * 4. The raw registry `env` list (every var is a credential).
119
+ *
120
+ * @param {string} name - Canonical registry name (e.g. 'google-translate')
121
+ * @param {object} entry - The method-registry entry
122
+ * @param {object} [opts]
123
+ * @param {Object<string,string>|null} [opts.env] - Fixture env for tests;
124
+ * null/omitted = live resolution via process.env AND .env files
125
+ * @param {string} [opts.cwd] - Project root for .env lookup (live mode)
126
+ * @returns {{status: string, detail: string}}
127
+ */
128
+ function resolveAvailability(name, entry, { env = null, cwd = undefined } = {}) {
129
+ const credVars = Array.isArray(entry.credential_env) ? entry.credential_env : null;
130
+ const needAll = entry.credential_env_all === true && credVars !== null;
131
+ const providerNames = providerEnvNames(name);
132
+ const envVars = needAll ? credVars
133
+ : providerNames.length > 0 ? providerNames
134
+ : (credVars ?? entry.env ?? []);
135
+ const extra = entry.optional_extra;
136
+ const extraNote = extra ? ` (requires pip extra '${extra}')` : '';
137
+
138
+ if (entry.kind === 'local-model') {
139
+ return {
140
+ status: 'local-setup',
141
+ detail: extra
142
+ ? `local engine — install extra '${extra}'`
143
+ : 'local engine — see homepage for setup',
144
+ };
145
+ }
146
+ if (envVars.length === 0) {
147
+ return { status: 'ready', detail: `no credentials required${extraNote}` };
148
+ }
149
+ const get = env === null ? (n) => getEnvOrFileVar(n, cwd) : (n) => env[n];
150
+ const present = envVars.filter((n) => get(n));
151
+ if (needAll) {
152
+ const missing = envVars.filter((n) => !get(n));
153
+ if (missing.length === 0) {
154
+ return { status: 'ready', detail: `${envVars.join(' + ')} are set${extraNote}` };
155
+ }
156
+ const qualifier = present.length > 0
157
+ ? ` (${present[0]} alone is not enough)`
158
+ : ' (all required)';
159
+ return { status: 'needs-key', detail: `set ${missing.join(' + ')}${qualifier}${extraNote}` };
160
+ }
161
+ if (present.length > 0) {
162
+ return { status: 'ready', detail: `${present[0]} is set${extraNote}` };
163
+ }
164
+ return { status: 'needs-key', detail: `set ${envVars.join(' or ')}${extraNote}` };
165
+ }
166
+
167
+ /**
168
+ * Tier-1 rows: every registry method with availability + lane verdicts.
169
+ *
170
+ * useContext ∈ {'non-commercial', 'commercial'}: the commercial lane is
171
+ * STRICT (mirrors the harness's license lane) — a method whose
172
+ * commercialReady is not true is excluded-with-reason, never silently
173
+ * dropped. Entries whose runtimes exclude 'cli' are flagged harness-only
174
+ * and point at mt-eval (mirrors translate.js's findHarnessOnlyEntry).
175
+ *
176
+ * @param {string} useContext
177
+ * @param {object} [opts]
178
+ * @param {object|null} [opts.manifest] - Fixture manifest for tests
179
+ * @param {Object<string,string>|null} [opts.env] - Fixture env for tests
180
+ * @param {string} [opts.cwd]
181
+ * @returns {object[]}
182
+ */
183
+ function dispatchableMethods(useContext, { manifest = undefined, env = null, cwd = undefined } = {}) {
184
+ const m = manifest !== undefined ? manifest : loadMethodManifest();
185
+ if (!m || !m.entries) return [];
186
+ const rows = [];
187
+ for (const [name, entry] of Object.entries(m.entries)) {
188
+ const avail = resolveAvailability(name, entry, { env, cwd });
189
+ const runtimes = entry.runtimes || ['harness', 'cli'];
190
+ const harnessOnly = Array.isArray(entry.runtimes) && !entry.runtimes.includes('cli');
191
+ let laneOk = true;
192
+ let laneNote = null;
193
+ if (useContext === 'commercial' && entry.commercialReady !== true) {
194
+ laneOk = false;
195
+ laneNote = `excluded from the commercial lane — license: ${entry.license || 'unknown'}`;
196
+ }
197
+ rows.push({
198
+ method: name,
199
+ cli_name: cliNameFor(name, entry),
200
+ kind: entry.kind ?? null,
201
+ paradigm: entry.paradigm ?? null,
202
+ license: entry.license ?? null,
203
+ commercial_ready: entry.commercialReady === true,
204
+ runtimes,
205
+ harness_only: harnessOnly,
206
+ runtime_note: harnessOnly
207
+ ? `harness-only — no CLI adapter; run it with: mt-eval run --method ${name}`
208
+ : null,
209
+ availability: avail.status,
210
+ availability_detail: avail.detail,
211
+ lane_ok: laneOk,
212
+ lane_note: laneNote,
213
+ cost_note: entry.cost_note ?? null,
214
+ });
215
+ }
216
+ const order = { 'ready': 0, 'needs-key': 1, 'local-setup': 2 };
217
+ rows.sort((a, b) =>
218
+ (a.lane_ok === b.lane_ok ? 0 : a.lane_ok ? -1 : 1)
219
+ || ((order[a.availability] ?? 9) - (order[b.availability] ?? 9))
220
+ || a.method.localeCompare(b.method));
221
+ return rows;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Tier 2 — curated cited results (direction-exact)
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /**
229
+ * Direction-exact curated datapoints + the cited-methods index.
230
+ *
231
+ * Rows carry per-datapoint provenance; ranking across different
232
+ * (benchmark, metric) buckets is deliberately NOT performed — metric
233
+ * variants are incomparable (metric_variant_flag).
234
+ *
235
+ * @param {string} src - ISO 639-3 source code
236
+ * @param {string} tgt - ISO 639-3 target code
237
+ * @param {object|null} [catalogue] - Fixture for tests; omit to load the
238
+ * shared artifact
239
+ * @returns {{rows: object[], methodsIndex: Object<string, object>}}
240
+ */
241
+ function curatedEvidence(src, tgt, catalogue = undefined) {
242
+ const cat = catalogue !== undefined
243
+ ? catalogue
244
+ : loadCatalogueJson('external-results.json');
245
+ if (!cat || Object.keys(cat).length === 0) return { rows: [], methodsIndex: {} };
246
+ const methodsIndex = {};
247
+ for (const m of cat.methods || []) {
248
+ if (m && m.id != null) methodsIndex[m.id] = m;
249
+ }
250
+ const rows = [];
251
+ for (const r of cat.results || []) {
252
+ const pair = r.pair || {};
253
+ if (pair.source !== src || pair.target !== tgt) continue;
254
+ const grade = normalizeGrade((r.signal_strength || {}).contamination);
255
+ rows.push({
256
+ tier: 'curated',
257
+ model: r.model ?? null,
258
+ benchmark: r.benchmark ?? null,
259
+ metric: r.metric ?? null,
260
+ value: r.value ?? null,
261
+ lower_is_better: r.lower_is_better === true,
262
+ grade: (r.signal_strength || {}).grade ?? null,
263
+ lane: grade ? laneForGrade(grade) : LANE_RELATIVE_ONLY,
264
+ verified: r.verified === true,
265
+ citation: r.citation ?? null,
266
+ source_url: r.source_url ?? null,
267
+ method_ref: r.method_ref ?? null,
268
+ });
269
+ }
270
+ return { rows, methodsIndex };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Tier 3 — bulk cited index (relative-only by construction)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /** Strip a FLORES-style script suffix: 'ace_Arab' → 'ace'. */
278
+ function baseCode(code) {
279
+ return code.split('_', 1)[0];
280
+ }
281
+
282
+ /**
283
+ * Best-published rows for the pair from the bulk index.
284
+ *
285
+ * Pair keys are upstream codes (may carry script suffixes); we match on the
286
+ * script-stripped base and surface the exact upstream key on each row so
287
+ * nothing is silently relabelled.
288
+ *
289
+ * @param {string} src
290
+ * @param {string} tgt
291
+ * @param {object} [opts]
292
+ * @param {object|null} [opts.index] - Fixture for tests
293
+ * @param {number} [opts.maxRows]
294
+ * @returns {{rows: object[], meta: object}}
295
+ */
296
+ function bulkEvidence(src, tgt, { index = undefined, maxRows = 8 } = {}) {
297
+ const idx = index !== undefined ? index : loadCatalogueJson('external-mt-index.json');
298
+ if (!idx || Object.keys(idx).length === 0) return { rows: [], meta: {} };
299
+ const models = idx.models || [];
300
+ const posture = idx.contamination_posture || {};
301
+ const rows = [];
302
+ for (const [key, cells] of Object.entries(idx.pairs || {})) {
303
+ const sep = key.indexOf('-');
304
+ const s = sep === -1 ? key : key.slice(0, sep);
305
+ const t = sep === -1 ? '' : key.slice(sep + 1);
306
+ if (baseCode(s) !== src || baseCode(t) !== tgt) continue;
307
+ for (const [testset, metrics] of Object.entries(cells)) {
308
+ for (const [metric, cell] of Object.entries(metrics)) {
309
+ const [modelIdx, value] = cell;
310
+ const model = (Number.isInteger(modelIdx) && modelIdx < models.length)
311
+ ? models[modelIdx]
312
+ : String(modelIdx);
313
+ rows.push({
314
+ tier: 'bulk',
315
+ pair_key: key,
316
+ model,
317
+ benchmark: testset,
318
+ metric,
319
+ value,
320
+ lane: LANE_RELATIVE_ONLY,
321
+ posture: posture[testset] ?? null,
322
+ });
323
+ }
324
+ }
325
+ }
326
+ rows.sort((a, b) =>
327
+ a.benchmark.localeCompare(b.benchmark)
328
+ || a.metric.localeCompare(b.metric)
329
+ || a.pair_key.localeCompare(b.pair_key));
330
+ const meta = {
331
+ provider: idx.provider ?? null,
332
+ provenance: idx.provenance ?? null,
333
+ truncated: rows.length > maxRows,
334
+ total_rows: rows.length,
335
+ };
336
+ return { rows: rows.slice(0, maxRows), meta };
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Tier 4 — metric-reliability evidence (which metric to BELIEVE for the target)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Per-target-family metric↔human correlation evidence (workstream B3).
345
+ *
346
+ * Answers a different question from tiers 1–3: not "which SYSTEM scores
347
+ * best" but "which METRIC can you trust to score output in this target
348
+ * language". Sourced from shared/catalogue/metric-reliability.json — the
349
+ * champollion-derived correlations between automatic metrics and the WMT
350
+ * Metrics-task human judgments (wmt19–wmt25); methodology spec:
351
+ * https://champollion.dev/docs/network/specifications/metric-reliability
352
+ *
353
+ * Fail-honest: an absent index, or a target no WMT campaign ever judged,
354
+ * yields an explicit note — never a silent skip, never borrowed numbers.
355
+ *
356
+ * @param {string} tgt
357
+ * @param {object|null} [reliability] - Fixture for tests
358
+ * @returns {{section: object|null, notes: string[]}}
359
+ */
360
+ function metricReliabilityEvidence(tgt, reliability = undefined) {
361
+ const rel = reliability !== undefined
362
+ ? reliability
363
+ : loadCatalogueJson('metric-reliability.json');
364
+ if (!rel) {
365
+ return {
366
+ section: null,
367
+ notes: ['Metric-reliability index not bundled in the npm package — '
368
+ + 'metric-trust tier skipped explicitly. See the methodology spec at '
369
+ + 'https://champollion.dev/docs/network/specifications/metric-reliability, '
370
+ + 'or run from a monorepo checkout (or `mt-eval recommend`) for the '
371
+ + 'full evidence.'],
372
+ };
373
+ }
374
+ const languages = rel.languages || {};
375
+ let code = null;
376
+ let info = null;
377
+ for (const key of Object.keys(languages).sort()) {
378
+ const entry = languages[key] || {};
379
+ if (tgt === key || tgt === entry.iso639_3) {
380
+ code = key;
381
+ info = entry;
382
+ break;
383
+ }
384
+ }
385
+ if (info === null) {
386
+ return {
387
+ section: null,
388
+ notes: [`No WMT human-judgment meta-evaluation covers target '${tgt}' `
389
+ + '(directly or via its family) — metric choice for this language is '
390
+ + 'UNMEASURED. Treat every metric as unvalidated there; prefer '
391
+ + 'metrics with morphology-robust behaviour and validate locally '
392
+ + 'where possible.'],
393
+ };
394
+ }
395
+ const notes = [];
396
+ const family = info.family || 'Unclassified';
397
+ const famBlock = (rel.families || {})[family] || {};
398
+ const metricsOut = [];
399
+ for (const [metricId, levels] of Object.entries(famBlock.metrics || {}).sort()) {
400
+ const sysE = levels.sys || {};
401
+ const segE = levels.seg || {};
402
+ metricsOut.push({
403
+ metric: metricId,
404
+ sys_pearson: sysE.pearson_weighted_mean ?? null,
405
+ sys_pairwise_accuracy: sysE.pairwise_accuracy_weighted_mean ?? null,
406
+ sys_n_pairs: sysE.n_pairs ?? null,
407
+ seg_kendall: segE.kendall_tau_b_weighted_mean ?? null,
408
+ seg_n_pairs: segE.n_pairs ?? null,
409
+ });
410
+ }
411
+ metricsOut.sort((a, b) =>
412
+ (b.sys_pearson ?? -2) - (a.sys_pearson ?? -2)
413
+ || a.metric.localeCompare(b.metric));
414
+ const exactPairs = [...new Set((rel.cells || [])
415
+ .filter((c) => c.tgt === code && c.preferred)
416
+ .map((c) => c.pair))].sort();
417
+ if (exactPairs.length === 0) {
418
+ notes.push(`Family-level metric evidence only: the '${family}' `
419
+ + `correlations come from other ${family} target languages, never from `
420
+ + `'${tgt}' itself — transfer within a family is an assumption, not a `
421
+ + 'measurement.');
422
+ }
423
+ if ((rel.license_lane || {}).commercial_ok === false) {
424
+ notes.push('Metric-reliability evidence rides a non-commercial hold (the '
425
+ + 'upstream WMT judgment data license is unstated, founder review '
426
+ + 'pending) — cite it in research lanes only.');
427
+ }
428
+ return {
429
+ section: {
430
+ target_code: code,
431
+ target_iso639_3: info.iso639_3 ?? null,
432
+ target_family: family,
433
+ exact_pairs_measured: exactPairs,
434
+ family_metrics: metricsOut,
435
+ provenance: rel.provenance ?? null,
436
+ },
437
+ notes,
438
+ };
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Assembly
443
+ // ---------------------------------------------------------------------------
444
+
445
+ /**
446
+ * Assemble the full recommendation payload for one directed pair.
447
+ *
448
+ * The payload shape mirrors the harness's `mt-eval recommend --json` output
449
+ * (snake_case keys) so machine consumers see one cross-runtime contract.
450
+ *
451
+ * @param {string} src
452
+ * @param {string} tgt
453
+ * @param {object} [opts]
454
+ * @param {string} [opts.useContext]
455
+ * @param {object|null} [opts.manifest] - Fixture for tests
456
+ * @param {object|null} [opts.curated] - Fixture for tests
457
+ * @param {object|null} [opts.bulk] - Fixture for tests
458
+ * @param {object|null} [opts.reliability] - Fixture for tests
459
+ * @param {Object<string,string>|null} [opts.env] - Fixture env for tests
460
+ * @param {string} [opts.cwd]
461
+ * @returns {object}
462
+ */
463
+ function recommend(src, tgt, {
464
+ useContext = 'non-commercial',
465
+ manifest = undefined,
466
+ curated = undefined,
467
+ bulk = undefined,
468
+ reliability = undefined,
469
+ env = null,
470
+ cwd = undefined,
471
+ } = {}) {
472
+ const methods = dispatchableMethods(useContext, { manifest, env, cwd });
473
+ const { rows: curatedRows, methodsIndex: citedMethods } = curatedEvidence(src, tgt, curated);
474
+ const { rows: bulkRows, meta: bulkMeta } = bulkEvidence(src, tgt, { index: bulk });
475
+ const { section: reliabilitySection, notes: reliabilityNotes } =
476
+ metricReliabilityEvidence(tgt, reliability);
477
+
478
+ // Join: which cited models are dispatchable / commercially deployable?
479
+ const evidencedModels = [];
480
+ const seen = new Set();
481
+ for (const row of curatedRows) {
482
+ const ref = row.method_ref;
483
+ if (!ref || seen.has(ref)) continue;
484
+ seen.add(ref);
485
+ const m = citedMethods[ref] || {};
486
+ evidencedModels.push({
487
+ id: ref,
488
+ name: m.name ?? null,
489
+ runnable_in_champollion: m.runnable_in_champollion === true,
490
+ commercial_use: m.commercial_use ?? null,
491
+ license: m.license ?? null,
492
+ });
493
+ }
494
+
495
+ const notes = [
496
+ 'Cited evidence orders methods RELATIVE to each other on memorized '
497
+ + 'public benchmarks — it is never absolute quality and never a '
498
+ + 'deployment ranking (relative-comparison-only lane).',
499
+ 'Evidence and runnability are different axes: the best-evidenced '
500
+ + 'model may not be dispatchable here, and NC-licensed weights are '
501
+ + 'excluded from commercial-lane recommendations.',
502
+ ];
503
+ if (curated === undefined && cataloguePath('external-results.json') === null) {
504
+ notes.push('Curated cited-results index not available in this install — '
505
+ + 'tier skipped explicitly.');
506
+ }
507
+ if (bulk === undefined && cataloguePath('external-mt-index.json') === null) {
508
+ notes.push('Bulk published-results index not bundled in the npm package '
509
+ + '(1.6 MB) — tier skipped explicitly. Browse it at '
510
+ + 'https://champollion.dev/catalogue, or run from a monorepo checkout '
511
+ + '(or `mt-eval recommend`) for the full index.');
512
+ }
513
+ if (curatedRows.length === 0 && bulkRows.length === 0) {
514
+ notes.push(
515
+ `NO published evidence indexed for ${src}→${tgt}. That is the `
516
+ + 'honest answer — measure instead of guessing: '
517
+ + `\`mt-eval corpora --source ${src} --target ${tgt}\` lists runnable `
518
+ + 'benchmarks, `mt-eval run` produces your own scored evidence.');
519
+ }
520
+ notes.push(...reliabilityNotes);
521
+
522
+ return {
523
+ pair: { source: src, target: tgt },
524
+ use_context: useContext,
525
+ runnable_methods: methods,
526
+ curated_evidence: curatedRows,
527
+ bulk_evidence: bulkRows,
528
+ bulk_meta: bulkMeta,
529
+ evidenced_models: evidencedModels,
530
+ metric_reliability: reliabilitySection,
531
+ notes,
532
+ };
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Rendering
537
+ // ---------------------------------------------------------------------------
538
+
539
+ /**
540
+ * Human-readable rendering of a recommend() payload (mirrors the harness's
541
+ * render_text, plus CLI-name and harness-only annotations).
542
+ *
543
+ * @param {object} payload
544
+ * @returns {string}
545
+ */
546
+ function renderText(payload) {
547
+ const p = payload.pair;
548
+ const out = [
549
+ `Method guidance — ${p.source} → ${p.target} (lane: ${payload.use_context})`,
550
+ '',
551
+ ];
552
+
553
+ out.push('Runnable methods (shared/method-registry.json):');
554
+ if (payload.runnable_methods.length === 0) {
555
+ out.push(' (method registry not available in this install)');
556
+ }
557
+ const badges = {
558
+ 'ready': 'READY ',
559
+ 'needs-key': 'NEEDS KEY ',
560
+ 'local-setup': 'LOCAL ',
561
+ };
562
+ for (const m of payload.runnable_methods) {
563
+ // Show the name a champollion.config.json actually uses when it differs
564
+ // from the canonical registry name (e.g. openrouter → llm).
565
+ const shown = m.cli_name && m.cli_name !== m.method
566
+ ? `${m.method} (cli: ${m.cli_name})`
567
+ : m.method;
568
+ let line;
569
+ if (!m.lane_ok) {
570
+ line = ` EXCLUDED ${shown.padEnd(22)}${m.lane_note}`;
571
+ } else {
572
+ const badge = badges[m.availability] || '? ';
573
+ line = ` ${badge}${shown.padEnd(22)}${m.availability_detail}`;
574
+ if (m.license) line += ` [${m.license}]`;
575
+ }
576
+ out.push(line);
577
+ if (m.runtime_note) out.push(` ↳ ${m.runtime_note}`);
578
+ }
579
+
580
+ out.push('');
581
+ out.push('Published evidence for this pair (cited — never reproduced '
582
+ + 'by us; relative ordering only):');
583
+ if (payload.curated_evidence.length === 0 && payload.bulk_evidence.length === 0) {
584
+ out.push(' none indexed.');
585
+ }
586
+ for (const r of payload.curated_evidence) {
587
+ const v = r.verified ? 'verified' : 'UNVERIFIED';
588
+ out.push(` [curated/${v}] ${r.benchmark} ${r.metric}=${r.value} — `
589
+ + `${r.model} (grade ${r.grade}; ${r.citation})`);
590
+ }
591
+ for (const r of payload.bulk_evidence) {
592
+ out.push(` [bulk] ${r.benchmark} ${r.metric}=${r.value} — ${r.model} `
593
+ + `(pair key ${r.pair_key})`);
594
+ }
595
+ if (payload.bulk_meta.truncated) {
596
+ out.push(` … ${payload.bulk_meta.total_rows} bulk rows total `
597
+ + '(showing best-per-bucket head).');
598
+ }
599
+
600
+ if (payload.evidenced_models.length > 0) {
601
+ out.push('');
602
+ out.push('Evidenced models vs. dispatchability:');
603
+ for (const m of payload.evidenced_models) {
604
+ const bits = [];
605
+ bits.push(m.runnable_in_champollion
606
+ ? 'runnable in Champollion'
607
+ : 'NOT dispatchable here yet');
608
+ if (m.commercial_use === false) bits.push('weights NC — non-commercial only');
609
+ out.push(` ${m.name || m.id}: ${bits.join('; ')}`);
610
+ }
611
+ }
612
+
613
+ const rel = payload.metric_reliability;
614
+ if (rel) {
615
+ const fmt = (v) => (v === null || v === undefined
616
+ ? ' —'
617
+ : `${v >= 0 ? '+' : ''}${v.toFixed(2)}`);
618
+ out.push('');
619
+ out.push(`Metric trust for the target (family: ${rel.target_family} — `
620
+ + 'WMT human-judgment correlations; which metric to believe):');
621
+ for (const m of rel.family_metrics) {
622
+ const n = m.sys_n_pairs ?? m.seg_n_pairs ?? 0;
623
+ out.push(` ${m.metric.padEnd(18)} sys-Pearson ${fmt(m.sys_pearson)} `
624
+ + `seg-Kendall ${fmt(m.seg_kendall)} (${n} pair(s))`);
625
+ }
626
+ const measured = rel.exact_pairs_measured.length > 0
627
+ ? rel.exact_pairs_measured.join(', ')
628
+ : 'none — family-level only';
629
+ out.push(` directly measured pairs for this target: ${measured}`);
630
+ }
631
+
632
+ out.push('');
633
+ for (const n of payload.notes) {
634
+ out.push(`⚠ ${n}`);
635
+ }
636
+ return out.join('\n');
637
+ }
638
+
639
+ export {
640
+ cataloguePath,
641
+ resolveAvailability,
642
+ dispatchableMethods,
643
+ curatedEvidence,
644
+ bulkEvidence,
645
+ metricReliabilityEvidence,
646
+ recommend,
647
+ renderText,
648
+ };