champollion 0.3.3 → 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 (142) hide show
  1. package/README.md +52 -37
  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 +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  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 +649 -130
  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 +16 -10
  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 +197 -38
  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 +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  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 +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -13,6 +13,17 @@
13
13
  * return byte-identical output)
14
14
  * 4. TM store: cache only gate-validated API translations
15
15
  *
16
+ * ICU MESSAGES get prompt guidance (keep the syntax; the target language's
17
+ * CLDR plural categories) and the gate's structure check (lib/validate.js).
18
+ * A TM hit that fails the gate is evicted where it actually lives — with
19
+ * model carry-over that can be another model's entry (lib/tm-evict.js).
20
+ *
21
+ * KEYS THE MODEL MUST NOT SEE. gettext keys are msgids — sentences, some
22
+ * with newlines, some carrying a msgctxt behind a U+0004. A model asked to
23
+ * "keep the keys exactly as-is" reliably mangles those, so such keys travel
24
+ * under short aliases and are mapped back; ordinary dotted keys are sent as
25
+ * they are (their names are prompt context).
26
+ *
16
27
  * ORDER MATTERS: the TM must only ever hold gate-validated values. An
17
28
  * earlier version stored API output before validation; one degenerate
18
29
  * response (e.g. "for translating" → "吗") then poisoned the cache — every
@@ -23,10 +34,139 @@
23
34
  * those details differ between sync paths.
24
35
  */
25
36
 
26
- import { translateBatch } from './translate.js';
27
- import { validateTranslations, logGateFailures } from './validate.js';
28
- import { partitionByTM, storeTM, evictTM, tmMethodKey } from './tm.js';
37
+ import { translateBatch, getMethod } from './translate.js';
38
+ import { validateTranslations, logGateFailures, sharedOutputReason, sharedOutputItems } from './validate.js';
39
+ import { partitionByTM, storeTM, tmMethodKey, lookupTM, servingMethodKey } from './tm.js';
40
+ import { tmSourceText, createTMEvictor } from './tm-evict.js';
41
+ import { icuGuidance, pluralGaps, describeCategories } from './icu-structure.js';
29
42
  import { output } from './output.js';
43
+ import { captureRequests } from './methods/request-capture.js';
44
+ import { refusalCategory, notePrimaryReason } from './refusal-category.js';
45
+
46
+ /** A key the model should not be shown: control characters, or a whole paragraph. */
47
+ function needsAlias(key) {
48
+ // eslint-disable-next-line no-control-regex
49
+ return /[\u0000-\u001f\u007f]/.test(key) || key.length > 120;
50
+ }
51
+
52
+ /**
53
+ * Accepted plural messages that lack CLDR categories the target uses
54
+ * (lib/icu-structure.js pluralGaps), keyed by key.
55
+ *
56
+ * @param {object} values - key → accepted translation
57
+ * @param {object} sourceFlat
58
+ * @param {string} targetCode
59
+ * @returns {Object<string, { everyday: string[], rare: string[], type: string }>}
60
+ */
61
+ function pluralGapsOf(values, sourceFlat, targetCode, slots = null) {
62
+ const out = {};
63
+ for (const [k, v] of Object.entries(values)) {
64
+ if (typeof sourceFlat[k] !== 'string' || !sourceFlat[k].includes('{') || typeof v !== 'string') continue;
65
+ const gaps = pluralGaps(sourceFlat[k], v, targetCode, slots);
66
+ if (gaps.length === 0) continue;
67
+ out[k] = {
68
+ everyday: [...new Set(gaps.flatMap(g => g.everyday))],
69
+ rare: [...new Set(gaps.flatMap(g => g.rare))],
70
+ type: gaps[0].type,
71
+ };
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * translateBatch, with unpromptable keys (see header) sent under aliases
78
+ * and mapped back. Descriptions follow their keys.
79
+ */
80
+ async function translateBatchAliased(keys, sourceFlat, pairConfig, options) {
81
+ if (!keys.some(needsAlias)) return translateBatch(keys, sourceFlat, pairConfig, options);
82
+ const taken = new Set(keys);
83
+ const toAlias = new Map();
84
+ const fromAlias = new Map();
85
+ let n = 0;
86
+ for (const key of keys) {
87
+ if (!needsAlias(key)) continue;
88
+ let alias;
89
+ do { alias = `msg_${++n}`; } while (taken.has(alias) || Object.prototype.hasOwnProperty.call(sourceFlat, alias));
90
+ toAlias.set(key, alias);
91
+ fromAlias.set(alias, key);
92
+ }
93
+ const aliasedKeys = keys.map(k => toAlias.get(k) ?? k);
94
+ const aliasedSource = { ...sourceFlat };
95
+ for (const [key, alias] of toAlias) aliasedSource[alias] = sourceFlat[key];
96
+ let descriptions = options.descriptions;
97
+ if (descriptions) {
98
+ descriptions = { ...descriptions };
99
+ for (const [key, alias] of toAlias) {
100
+ if (descriptions[key] !== undefined) descriptions[alias] = descriptions[key];
101
+ }
102
+ }
103
+ const result = await translateBatch(aliasedKeys, aliasedSource, pairConfig, { ...options, descriptions });
104
+ if (!result) return result;
105
+ const out = {};
106
+ for (const [k, v] of Object.entries(result)) out[fromAlias.get(k) ?? k] = v;
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * The per-key notes the method is given: the caller's (Docusaurus
112
+ * descriptions, i18next plural forms, ARB descriptions, gettext msgctxt and
113
+ * `#.` comments) plus, for an ICU plural/select message, what is syntax and
114
+ * which plural categories the target uses. ONE builder for the real call and
115
+ * for `sync --dry --show-prompt`, so the preview is what is sent.
116
+ *
117
+ * @returns {object|null}
118
+ */
119
+ function buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions) {
120
+ let promptDescriptions = descriptions || null;
121
+ let ownCopy = false;
122
+ for (const k of stringKeys) {
123
+ const guidance = icuGuidance(sourceFlat[k], targetCode, pairConfig.name || targetCode);
124
+ if (!guidance) continue;
125
+ if (!ownCopy) { promptDescriptions = { ...(descriptions || {}) }; ownCopy = true; }
126
+ promptDescriptions[k] = promptDescriptions[k] ? `${promptDescriptions[k]} — ${guidance}` : guidance;
127
+ }
128
+ return promptDescriptions;
129
+ }
130
+
131
+ /**
132
+ * The options translateBatch gets — shared by the real call and the preview.
133
+ * `cwd` is the PROJECT directory: methods read their key (.env, .env.local),
134
+ * endpoint, coaching and glossary from it. Without it they fell back to
135
+ * process.cwd(), which is the project only when the caller runs there (the
136
+ * MCP server had to swap process.cwd() for the call).
137
+ */
138
+ function buildBatchOptions(pairConfig, apiKey, promptDescriptions, onProgress = null, cwd = null) {
139
+ return {
140
+ apiKey,
141
+ ...(cwd && { cwd }),
142
+ model: pairConfig.model,
143
+ batchSize: pairConfig.batchSize,
144
+ onProgress,
145
+ // Docusaurus passes descriptions for disambiguation context
146
+ ...(promptDescriptions && { descriptions: promptDescriptions }),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * The exact request(s) the pair's method would send for these keys — built
152
+ * by the method's own code and handed over at its transport, never sent
153
+ * (lib/methods/request-capture.js). Nothing is read from or written to the
154
+ * cache: this shows the request a cache miss produces.
155
+ *
156
+ * @param {string[]} stringKeys
157
+ * @param {object} sourceFlat
158
+ * @param {object} pairConfig
159
+ * @param {{ apiKey?: string|null, targetCode: string, descriptions?: object|null }} options
160
+ * @returns {Promise<{ supported: boolean, requests: Array<{ url: string, method: string, headers: object, body: unknown }> }>}
161
+ */
162
+ export async function previewRequests(stringKeys, sourceFlat, pairConfig, { apiKey = null, targetCode, descriptions = null, cwd = null } = {}) {
163
+ const method = getMethod(pairConfig.method || 'llm', pairConfig);
164
+ if (!method.supportsRequestPreview) return { supported: false, requests: [] };
165
+ const promptDescriptions = buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions);
166
+ const batchOptions = buildBatchOptions(pairConfig, apiKey, promptDescriptions, null, cwd);
167
+ const requests = await captureRequests(() => translateBatchAliased(stringKeys, sourceFlat, pairConfig, batchOptions));
168
+ return { supported: true, requests };
169
+ }
30
170
 
31
171
  /**
32
172
  * Translate a set of string keys through the TM + API + quality gate pipeline.
@@ -40,7 +180,18 @@ import { output } from './output.js';
40
180
  * @param {object} options.tm - Translation Memory object (mutable — entries are stored in-place)
41
181
  * @param {string} options.targetCode - Target language code
42
182
  * @param {object} [options.descriptions] - Optional key descriptions (Docusaurus format)
183
+ * @param {string} [options.cwd] - The project directory: where the method reads
184
+ * its key (.env / .env.local), endpoint, coaching and glossary. Omitted:
185
+ * process.cwd()
43
186
  * @param {Function} [options.onProgress] - Progress callback: (completed, total) => void
187
+ * @param {Object<string, string>} [options.pluralForms] - Borrowed i18next
188
+ * plural forms (lib/plurals.js `borrowed`): key → form; each is cached
189
+ * under its own identity (lib/tm-evict.js tmSourceText)
190
+ * @param {Set<string>} [options.noSendKeys] - Keys the method must NOT be
191
+ * asked for (lib/locale-state.js: refused before). The cache still serves
192
+ * them; what it does not hold comes back in `heldKeys`, unsent and unbilled.
193
+ * @param {import('./validate.js').SharedOutputIndex} [options.sharedOutputs] -
194
+ * The locale's different-inputs-same-output index (accepted values are added)
44
195
  * @returns {Promise<TranslateResult>}
45
196
  *
46
197
  * @typedef {object} TranslateResult
@@ -49,25 +200,55 @@ import { output } from './output.js';
49
200
  * @property {Array} failures - Quality gate failures (for caller logging)
50
201
  * @property {boolean} apiCalled - Whether the API was actually invoked
51
202
  * @property {boolean} apiReturnedNull - Whether the API was called but returned null
203
+ * @property {number} sentCount - Keys sent to the method (TM misses) — what the
204
+ * run asks a model/API for, as opposed to tmHitCount served from the cache
205
+ * @property {number} retriedCount - Keys sent a second time with the gate's feedback
206
+ * @property {Object<string, { everyday: string[], rare: string[], type: string }>} pluralGaps -
207
+ * Accepted plural messages that lack CLDR categories the target uses
208
+ * (lib/icu-structure.js pluralGaps), keyed by key — the caller reports them
209
+ * @property {string[]} sentKeys - Keys sent to the method (TM misses not held back)
210
+ * @property {string[]} answeredKeys - Accepted keys whose value is the method's
211
+ * own answer this run (not served from the cache)
212
+ * @property {string[]} heldKeys - noSendKeys the cache did not hold: not sent, not translated
213
+ * @property {string[]} refusedKeys - Final failures the METHOD produced (it was
214
+ * asked and the gate refused its answer) — what a refusal record remembers
52
215
  */
53
216
  export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, options) {
54
217
  const { apiKey, tm, targetCode, descriptions } = options;
55
218
  const method = pairConfig.method || 'llm';
219
+ const noSend = options.noSendKeys instanceof Set ? options.noSendKeys : new Set();
220
+ const sharedOutputs = options.sharedOutputs || null;
56
221
 
57
222
  // TM entries are keyed on the FULL method key (method|model|register|coaching),
58
223
  // not the bare method name: switching model, register, or coaching must be a
59
224
  // cache miss, never a silent re-serve of old-style translations. See tm.js.
60
225
  const tmKey = tmMethodKey(pairConfig);
61
226
 
227
+ // The text each key is cached under: its source text, with a gettext
228
+ // msgctxt folded in (lib/tm-evict.js) so two contexts never share an entry.
229
+ // A borrowed i18next plural form (options.pluralForms: key → form) is
230
+ // cached under its own text, apart from the form it borrows from.
231
+ const pluralForms = options.pluralForms || null;
232
+ const tmText = {};
233
+ for (const k of stringKeys) {
234
+ if (typeof sourceFlat[k] === 'string') tmText[k] = tmSourceText(k, sourceFlat[k], pluralForms?.[k] || null);
235
+ }
236
+ const evictor = createTMEvictor(tm);
237
+
238
+ const promptDescriptions = buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions);
239
+
62
240
  // Step 1: TM partition — serve cached hits, identify API misses
63
241
  const { hits: tmHits, misses: tmMisses } = partitionByTM(
64
- tm, sourceFlat, stringKeys, targetCode, tmKey
242
+ tm, tmText, stringKeys, targetCode, tmKey
65
243
  );
66
244
 
67
245
  const tmHitCount = Object.keys(tmHits).length;
68
246
  if (tmHitCount > 0) {
69
247
  output.info(`[TM] ${tmHitCount} key(s) served from cache`);
70
248
  }
249
+ // The entry each hit was served from (exact, or another model's).
250
+ const tmHitBy = {};
251
+ for (const k of Object.keys(tmHits)) tmHitBy[k] = servingMethodKey(tm, tmText[k], targetCode, tmKey) || tmKey;
71
252
 
72
253
  // Start with TM hits as the base
73
254
  const translated = { ...tmHits };
@@ -79,28 +260,26 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
79
260
  // non-API (TM-served) failures need cache eviction.
80
261
  const apiKeys = new Set();
81
262
 
82
- const batchOptions = {
83
- apiKey,
84
- model: pairConfig.model,
85
- batchSize: pairConfig.batchSize,
86
- onProgress: options.onProgress || null,
87
- };
88
-
89
- // Docusaurus passes descriptions for disambiguation context
90
- if (descriptions) {
91
- batchOptions.descriptions = descriptions;
92
- }
263
+ const batchOptions = buildBatchOptions(pairConfig, apiKey, promptDescriptions, options.onProgress || null, options.cwd || null);
93
264
 
94
- // Step 2: API call for misses
95
- if (tmMisses.length > 0) {
96
- output.progress(` Translating ${tmMisses.length} key(s) to ${pairConfig.name} (${method})...`);
265
+ // Step 2: API call for misses — except keys held back (refused before by
266
+ // this method; lib/locale-state.js). They stay untranslated, unbilled.
267
+ const toSend = tmMisses.filter(k => !noSend.has(k));
268
+ const heldKeys = tmMisses.filter(k => noSend.has(k));
269
+ let sentCount = 0;
270
+ let retriedCount = 0;
271
+ // Keys whose current value the METHOD produced (initial call or retry).
272
+ const askedKeys = new Set();
273
+ if (toSend.length > 0) {
274
+ sentCount = toSend.length;
275
+ output.progress(` Translating ${toSend.length} key(s) to ${pairConfig.name} (${method})...`);
97
276
 
98
- const apiResult = await translateBatch(tmMisses, sourceFlat, pairConfig, batchOptions);
277
+ const apiResult = await translateBatchAliased(toSend, sourceFlat, pairConfig, batchOptions);
99
278
  apiCalled = true;
100
279
 
101
280
  if (apiResult) {
102
281
  Object.assign(translated, apiResult);
103
- for (const k of Object.keys(apiResult)) apiKeys.add(k);
282
+ for (const k of Object.keys(apiResult)) { apiKeys.add(k); askedKeys.add(k); }
104
283
  } else {
105
284
  apiReturnedNull = true;
106
285
  }
@@ -115,14 +294,74 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
115
294
  failures = result.failures;
116
295
  }
117
296
 
297
+ // Different inputs, same output (lib/validate.js SharedOutputIndex): one
298
+ // text answering 3+ different source strings — a memorized sentence. A
299
+ // soft failure: the retry asks again, then the fallback; never accepted
300
+ // silently. Checked against what this locale accepted earlier in the run.
301
+ // Counted over EVERY answer the method gave (`pool`), not only the ones
302
+ // that passed the other checks: a third copy refused for a lost
303
+ // placeholder is still a third copy, and its two siblings must not pass
304
+ // (Round 5, hospital persona). ICU branches count one by one
305
+ // (validate.js sharedOutputItems). Only keys in `vals` are failed.
306
+ const sharedFailure = (vals, pool = vals) => {
307
+ if (!sharedOutputs) return [];
308
+ const items = Object.entries(pool)
309
+ .flatMap(([k, v]) => (typeof v === 'string' && typeof sourceFlat[k] === 'string' ? sharedOutputItems(k, sourceFlat[k], v) : []));
310
+ const out = [];
311
+ for (const [k, group] of sharedOutputs.suspects(items)) {
312
+ if (!(k in vals)) continue;
313
+ out.push({ key: k, reason: sharedOutputReason(group), value: vals[k], sharedOutput: true });
314
+ }
315
+ return out;
316
+ };
317
+ for (const f of sharedFailure(validated, translated)) {
318
+ delete validated[f.key];
319
+ failures.push(f);
320
+ }
321
+
322
+ // A TM-served short Latin-script value was already settled as a name by
323
+ // an earlier run (that is how it got into the TM). Re-asking would evict
324
+ // it and re-bill the same answer on every sync — accept it as confirmed.
325
+ // Same for a TM-served plural message without some plural forms: it was
326
+ // accepted (after one corrective ask) by the run that cached it. Serving
327
+ // it is free; re-asking would bill every sync. It is still reported
328
+ // (pluralGaps below), with the command that asks again.
329
+ failures = failures.filter((f) => {
330
+ if ((f.nameOrLabel || f.pluralGap) && f.key in tmHits && !apiKeys.has(f.key)) {
331
+ validated[f.key] = translated[f.key];
332
+ return false;
333
+ }
334
+ return true;
335
+ });
336
+
337
+ // A method that reads no per-key instructions (a machine translation
338
+ // engine) cannot be told which plural forms to add: asking again would
339
+ // bill the same answer. Accept the message as it is; it is reported.
340
+ const takesInstructions = (() => {
341
+ try { return getMethod(method, pairConfig).acceptsKeyInstructions === true; } catch { return false; }
342
+ })();
343
+ if (!takesInstructions) {
344
+ failures = failures.filter((f) => {
345
+ if (f.pluralGap) { validated[f.key] = translated[f.key]; return false; }
346
+ return true;
347
+ });
348
+ }
349
+
118
350
  // Step 3a: Evict poisoned TM entries. A TM-served value that fails the
119
351
  // gate would otherwise be re-served (and re-fail) on every future sync
120
- // without the API ever being consulted again.
352
+ // without the API ever being consulted again. Evicted where it LIVES: a
353
+ // model-carry-over hit is another model's entry (lib/tm-evict.js).
121
354
  for (const f of failures) {
122
- if (f.key in tmHits && !apiKeys.has(f.key) && typeof sourceFlat[f.key] === 'string') {
123
- evictTM(tm, sourceFlat[f.key], targetCode, tmKey);
355
+ if (f.key in tmHits && !apiKeys.has(f.key) && typeof tmText[f.key] === 'string') {
356
+ evictor.evictProducing(tmText[f.key], targetCode, tmHits[f.key], [tmKey]);
124
357
  }
125
358
  }
359
+ // A held-back key whose cached value the gate refused is not sent either:
360
+ // it is held, not retried (a paid call is exactly what holding back stops).
361
+ failures = failures.filter((f) => {
362
+ if (noSend.has(f.key) && !apiKeys.has(f.key)) { heldKeys.push(f.key); return false; }
363
+ return true;
364
+ });
126
365
 
127
366
  // Step 3b: Feedback retry — one corrective round for gate failures.
128
367
  // The rejection reason is injected as per-key context so the prompt
@@ -135,24 +374,84 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
135
374
  // evicted by the gate (step 3a) was only re-billed on the NEXT sync — a
136
375
  // two-pass heal nobody asked for. A method that genuinely cannot run
137
376
  // returns null here and the failures simply stand.
377
+ // Keys whose ONLY problem was a missing plural form: if the retry does not
378
+ // improve on them (no answer, or one refused for something else), their
379
+ // first answer stands — see below.
380
+ const pluralGapKeys = new Set(failures.filter(f => f.pluralGap).map(f => f.key));
138
381
  if (failures.length > 0) {
139
382
  const retryKeys = failures
140
383
  .map(f => f.key)
141
- .filter(k => typeof sourceFlat[k] === 'string');
384
+ .filter(k => typeof sourceFlat[k] === 'string' && !noSend.has(k));
142
385
 
143
- if (retryKeys.length > 0) {
144
- output.progress(` Quality gate rejected ${retryKeys.length} key(s) — retrying with feedback...`);
386
+ // What the second ask can carry. An LLM method reads the gate's
387
+ // feedback; a machine-translation engine or an `api` endpoint gets the
388
+ // same text again (the api contract carries the source text — and the
389
+ // feedback only when the endpoint declares "acceptsInstructions": true).
390
+ // An endpoint that declares it follows no instructions (a trained NMT
391
+ // model) would answer the same: it is not asked again — its first
392
+ // answers are judged as a second answer would be (a name kept as written
393
+ // is accepted), and the rest go to the fallback (Round 4 school persona).
394
+ const declaredDeaf = pairConfig.acceptsInstructions === false;
395
+ // Why each key was refused, under the line that says it was — the line
396
+ // alone said "rejected 1 key(s)" and nothing about why (Round 5, Django
397
+ // persona). A key that passes on the second ask is never in the final
398
+ // [GATE] block, so this is the only place its reason is printed.
399
+ const reasonLines = () => {
400
+ const byKey = new Map(failures.filter(f => retryKeys.includes(f.key)).map(f => [f.key, f.reason]));
401
+ const shown = [...byKey].slice(0, 10).map(([k, r]) => ` - ${k}: ${r}`);
402
+ if (byKey.size > 10) shown.push(` … and ${byKey.size - 10} more`);
403
+ for (const line of shown) output.progress(line);
404
+ };
405
+ if (retryKeys.length > 0 && declaredDeaf) {
406
+ output.progress(` ${pairKey}: quality gate rejected ${retryKeys.length} key(s) — not asked again: `
407
+ + `${method === 'api' ? 'the endpoint' : method} declares it does not follow instructions, so it would return the same text`
408
+ + `${pairConfig.fallback ? ' (the fallback gets them)' : ''}.`);
409
+ reasonLines();
410
+ const firstAnswers = {};
411
+ for (const k of retryKeys) if (typeof translated[k] === 'string') firstAnswers[k] = translated[k];
412
+ const second = validateTranslations(firstAnswers, sourceFlat, pairConfig, { acceptLatinNames: true, acceptPluralGaps: true });
413
+ const shared = new Set(failures.filter(f => f.sharedOutput).map(f => f.key));
414
+ for (const [k, v] of Object.entries(second.validated)) {
415
+ if (shared.has(k)) continue;
416
+ validated[k] = v;
417
+ }
418
+ const passed = new Set(Object.keys(validated));
419
+ failures = failures.filter(f => !passed.has(f.key));
420
+ } else if (retryKeys.length > 0) {
421
+ const how = takesInstructions
422
+ ? 'retrying with feedback'
423
+ : method === 'api'
424
+ ? 'asking once more (the endpoint may ignore feedback: the api request carries it only when the pair declares "acceptsInstructions": true)'
425
+ : `asking once more (${method} takes no instructions, so it gets the same text again)`;
426
+ output.progress(` ${pairKey}: quality gate rejected ${retryKeys.length} key(s) — ${how}...`);
427
+ reasonLines();
145
428
 
146
- const feedbackDescriptions = { ...(descriptions || {}) };
429
+ const feedbackDescriptions = { ...(promptDescriptions || {}) };
147
430
  for (const f of failures) {
148
431
  const rejected = String(f.value ?? '').slice(0, 60);
149
432
  const base = feedbackDescriptions[f.key] ? `${feedbackDescriptions[f.key]} — ` : '';
150
- feedbackDescriptions[f.key] =
151
- `${base}RETRY: a previous attempt ("${rejected}") was rejected by the quality gate: ${f.reason}. ` +
152
- `Provide a complete, self-contained ${pairConfig.name} translation of this exact string.`;
433
+ feedbackDescriptions[f.key] = f.sharedOutput
434
+ ? `${base}RETRY: a previous attempt returned the same text ("${rejected}") for several different source strings. `
435
+ + `Translate THIS string into ${pairConfig.name} — its own meaning, not a sentence you used elsewhere.`
436
+ : f.nameOrLabel
437
+ // A short value kept in Latin script: maybe a name, maybe a label
438
+ // the model skipped. Ask once, plainly; if it answers the same
439
+ // again, the retry validation below accepts it as a name.
440
+ ? `${base}RETRY: a previous attempt kept this in Latin script ("${rejected}"). ` +
441
+ `If it is a descriptive label or phrase, translate it into ${pairConfig.name}. ` +
442
+ 'Only if it is a proper name (a person, company, product or brand) return it exactly as written.'
443
+ : f.pluralGap
444
+ // A plural message without forms the language needs: name them,
445
+ // with the counts that select each, and ask for the whole message.
446
+ ? `${base}RETRY: the previous translation had no ${f.pluralGap.missing.map(c => `"${c}"`).join(', ')} `
447
+ + `branch. ${pairConfig.name} uses ${describeCategories(targetCode, f.pluralGap.missing, f.pluralGap.type)}. `
448
+ + 'Return the whole message again with a branch for each of these forms, keeping every other branch and "other".'
449
+ : `${base}RETRY: a previous attempt ("${rejected}") was rejected by the quality gate: ${f.reason}. ` +
450
+ `Provide a complete, self-contained ${pairConfig.name} translation of this exact string.`;
153
451
  }
154
452
 
155
- const retryResult = await translateBatch(retryKeys, sourceFlat, pairConfig, {
453
+ retriedCount = retryKeys.length;
454
+ const retryResult = await translateBatchAliased(retryKeys, sourceFlat, pairConfig, {
156
455
  ...batchOptions,
157
456
  onProgress: null, // avoid double-counting progress
158
457
  descriptions: feedbackDescriptions,
@@ -160,7 +459,21 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
160
459
  apiCalled = true;
161
460
 
162
461
  if (retryResult) {
163
- const retryValidation = validateTranslations(retryResult, sourceFlat, pairConfig);
462
+ // Only keys the method actually answered count as refused by it.
463
+ for (const k of Object.keys(retryResult)) askedKeys.add(k);
464
+ // acceptLatinNames: a value the model keeps in Latin script after
465
+ // being asked once is a name by its own account — accept and cache it.
466
+ // acceptPluralGaps: asked once for the missing forms; a second answer
467
+ // without them is accepted and reported, never asked a third time.
468
+ const retryValidation = validateTranslations(retryResult, sourceFlat, pairConfig,
469
+ { acceptLatinNames: true, acceptPluralGaps: true });
470
+ // The second answer is held to the shared-output rule too (against
471
+ // what was accepted so far): the same memorized sentence again stays
472
+ // refused.
473
+ for (const f of sharedFailure({ ...retryValidation.validated }, retryResult)) {
474
+ delete retryValidation.validated[f.key];
475
+ retryValidation.failures.push(f);
476
+ }
164
477
  Object.assign(validated, retryValidation.validated);
165
478
  for (const k of Object.keys(retryValidation.validated)) apiKeys.add(k);
166
479
 
@@ -175,17 +488,45 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
175
488
  }
176
489
  }
177
490
 
491
+ // A plural message the retry did not improve on (no answer, or one the
492
+ // gate refused for another reason) keeps its first answer: it passed every
493
+ // check except the missing forms, and it was paid for. Reported below.
494
+ failures = failures.filter((f) => {
495
+ if (pluralGapKeys.has(f.key) && typeof translated[f.key] === 'string' && apiKeys.has(f.key)) {
496
+ validated[f.key] = translated[f.key];
497
+ return false;
498
+ }
499
+ return true;
500
+ });
501
+
178
502
  if (failures.length > 0) {
179
503
  logGateFailures(failures, pairKey);
180
504
  }
181
505
 
506
+ // Every accepted plural message that lacks forms the target uses — the
507
+ // caller says so (and a gettext catalog marks the repeated forms).
508
+ const gapsByKey = pluralGapsOf(validated, sourceFlat, targetCode, pairConfig.pluralSlots || null);
509
+
182
510
  // Step 4: TM store — only gate-validated values that came from the API.
183
511
  // TM hits are already cached; unvalidated output must never enter the TM.
184
512
  for (const [k, v] of Object.entries(validated)) {
185
- if (apiKeys.has(k) && typeof v === 'string' && typeof sourceFlat[k] === 'string') {
186
- storeTM(tm, sourceFlat[k], targetCode, tmKey, v);
513
+ if (apiKeys.has(k) && typeof v === 'string' && typeof tmText[k] === 'string') {
514
+ storeTM(tm, tmText[k], targetCode, tmKey, v);
187
515
  }
188
516
  }
517
+ // Which method key produced each accepted value: this pair's on a fresh
518
+ // answer, the serving entry's on a cache hit (another model's, for a
519
+ // model carry-over). The lock records it (lib/locale-state.js `by`), so
520
+ // `status` names the model that wrote a value instead of guessing from
521
+ // identical cache entries (Round 6, Next.js persona).
522
+ const producedBy = {};
523
+ for (const k of Object.keys(validated)) {
524
+ producedBy[k] = apiKeys.has(k) ? tmKey : (tmHitBy[k] || tmKey);
525
+ }
526
+ if (sharedOutputs) {
527
+ sharedOutputs.add(Object.entries(validated)
528
+ .flatMap(([k, v]) => (typeof v === 'string' && typeof sourceFlat[k] === 'string' ? sharedOutputItems(k, sourceFlat[k], v) : [])));
529
+ }
189
530
 
190
531
  return {
191
532
  translated: Object.keys(validated).length > 0 ? validated : null,
@@ -193,5 +534,269 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
193
534
  failures,
194
535
  apiCalled,
195
536
  apiReturnedNull,
537
+ sentCount,
538
+ retriedCount,
539
+ pluralGaps: gapsByKey,
540
+ producedBy,
541
+ // Accepted values that are the method's own answer THIS run (not a
542
+ // cache hit): with options.pluralForms, a borrowed plural form here was
543
+ // asked for under its own identity — the lock records that (sync).
544
+ answeredKeys: Object.keys(validated).filter(k => apiKeys.has(k)),
545
+ sentKeys: toSend,
546
+ heldKeys: [...new Set(heldKeys)].filter(k => !(k in validated)),
547
+ refusedKeys: failures.filter(f => askedKeys.has(f.key) && f.value !== undefined).map(f => f.key),
548
+ };
549
+ }
550
+
551
+ /**
552
+ * translateAndValidate with the pair's FALLBACK method (lib/fallback.js).
553
+ *
554
+ * The ladder, per key:
555
+ * 1. the pair's own TM entry (served by translateAndValidate, gate-checked);
556
+ * 2. the fallback's TM entry — text the fallback translated on an earlier
557
+ * run is reused (gate-checked; a failing entry is evicted), so the
558
+ * primary is not re-asked, and re-billed, for it on every re-run;
559
+ * 3. the pair's own method (with its one corrective retry);
560
+ * 4. for every key 3 left untranslated — gate-refused, or missing from the
561
+ * response — ONE pass through the fallback's method, through the SAME
562
+ * quality gate (with its own corrective retry). Accepted values are
563
+ * cached under the fallback's own TM key.
564
+ *
565
+ * A key both methods fail stays failed exactly as without a fallback: the
566
+ * caller leaves it untranslated and keeps its lock entry, and verify lists it.
567
+ *
568
+ * A primary that returned nothing at all is exactly when a fallback helps:
569
+ * the fallback still runs, and that the primary failed wholesale is said
570
+ * out loud. `skipPrimary` sends everything the pair's own cache does not
571
+ * hold straight to the fallback (the primary already returned nothing for an
572
+ * earlier file of this locale; its cached translations are still good).
573
+ *
574
+ * --max-cost: the fallback batch is priced (its cache misses only) before it
575
+ * runs; `budget` refuses it when the run's committed spend plus this batch
576
+ * would pass the cap, or when the fallback has no price (lib/fallback.js
577
+ * createFallbackBudget). A refused batch leaves the keys failed.
578
+ *
579
+ * Without `pairConfig.fallback` this IS translateAndValidate (plus a null
580
+ * `fallback` report).
581
+ *
582
+ * @param {string[]} stringKeys
583
+ * @param {object} sourceFlat
584
+ * @param {object} pairConfig - Resolved pair (lib/pairs.js); `fallback` optional
585
+ * @param {string} pairKey
586
+ * @param {object} options - translateAndValidate options, plus:
587
+ * @param {boolean} [options.skipPrimary=false]
588
+ * @param {object|null} [options.budget=null] - createFallbackBudget()
589
+ * @param {Set<string>} [options.noSendPrimary] - Keys the pair's own method
590
+ * refused before (lib/locale-state.js): its cache is consulted, it is not
591
+ * asked; with a fallback, they go to the fallback.
592
+ * @param {Set<string>} [options.noSendFallback] - Keys the fallback refused before
593
+ * @returns {Promise<TranslateResult & { fallback: import('./fallback.js').FallbackReport|null,
594
+ * refusedBy: Object<string, string[]> }>} refusedBy: key → the method keys
595
+ * whose answer the gate refused this run
596
+ */
597
+ export async function translateWithFallback(stringKeys, sourceFlat, pairConfig, pairKey, options) {
598
+ const {
599
+ skipPrimary = false, budget = null, noSendPrimary = new Set(), noSendFallback = new Set(), ...pipeline
600
+ } = options;
601
+ const fb = pairConfig.fallback || null;
602
+ if (!fb) {
603
+ const result = await translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, { ...pipeline, noSendKeys: noSendPrimary });
604
+ const primaryKey = tmMethodKey(pairConfig);
605
+ const refusedBy = {};
606
+ for (const k of result.refusedKeys || []) refusedBy[k] = [primaryKey];
607
+ return { ...result, fallback: null, refusedBy };
608
+ }
609
+
610
+ const { tm, targetCode } = pipeline;
611
+ const sharedOutputs = pipeline.sharedOutputs || null;
612
+ const isString = (k) => typeof sourceFlat[k] === 'string';
613
+ const textOf = (k) => tmSourceText(k, sourceFlat[k], pipeline.pluralForms?.[k] || null);
614
+ const primaryKey = tmMethodKey(pairConfig);
615
+ const fallbackKey = tmMethodKey(fb);
616
+ const report = {
617
+ method: fb.method,
618
+ model: fb.model || null,
619
+ attempted: 0,
620
+ accepted: 0,
621
+ cached: 0,
622
+ primaryFailedWholesale: skipPrimary,
623
+ skipped: null,
624
+ // The primary's own accepted answers this run, and why the keys sent to
625
+ // the fallback were not its (lib/fallback.js warnFallbackMajority).
626
+ primaryAccepted: 0,
627
+ primaryReasons: {},
196
628
  };
629
+
630
+ // A cache read outside translateAndValidate: entries for `keys` under
631
+ // `tmKey`, gate-checked against `cfg` like any TM hit (a short Latin value
632
+ // already settled as a name stays accepted); a failing entry is evicted.
633
+ const servedBy = {};
634
+ const serveCached = (keys, cfg, tmKey) => {
635
+ const candidates = {};
636
+ for (const k of keys) {
637
+ const cached = lookupTM(tm, textOf(k), targetCode, tmKey);
638
+ if (cached !== null) candidates[k] = cached;
639
+ }
640
+ const served = {};
641
+ for (const k of Object.keys(candidates)) servedBy[k] = servingMethodKey(tm, textOf(k), targetCode, tmKey) || tmKey;
642
+ if (Object.keys(candidates).length === 0) return served;
643
+ const { validated, failures } = validateTranslations(candidates, sourceFlat, cfg);
644
+ Object.assign(served, validated);
645
+ const evictor = createTMEvictor(tm);
646
+ for (const f of failures) {
647
+ if (f.nameOrLabel || f.pluralGap) { served[f.key] = candidates[f.key]; continue; }
648
+ evictor.evictProducing(textOf(f.key), targetCode, candidates[f.key], [tmKey]);
649
+ }
650
+ // A cache read is held to the shared-output rule too: entries another
651
+ // tool cached one text at a time (the MCP translate tool, an earlier
652
+ // run) used to be written straight from this cache, three different
653
+ // clinical prompts as one sentence (Round 5, hospital persona). A
654
+ // suspect entry is evicted, so the key goes on to the gated methods.
655
+ if (sharedOutputs) {
656
+ const items = Object.entries(candidates).flatMap(([k, v]) => sharedOutputItems(k, sourceFlat[k], v));
657
+ const suspects = sharedOutputs.suspects(items);
658
+ for (const k of suspects.keys()) {
659
+ if (!(k in served)) continue;
660
+ evictor.evictProducing(textOf(k), targetCode, served[k], [tmKey]);
661
+ delete served[k];
662
+ }
663
+ sharedOutputs.add(Object.entries(served).flatMap(([k, v]) => sharedOutputItems(k, sourceFlat[k], v)));
664
+ }
665
+ return served;
666
+ };
667
+
668
+ // Step 2 of the ladder: the fallback's cache, for keys the pair's own
669
+ // cache does not hold. (With the primary known to be down, its own cache
670
+ // is still good: those hits are served, and only the rest go on.)
671
+ const fromCache = {};
672
+ let primaryCacheHits = {};
673
+ if (skipPrimary) {
674
+ primaryCacheHits = serveCached(stringKeys.filter(isString), pairConfig, primaryKey);
675
+ } else {
676
+ const notInOwnCache = stringKeys.filter(k => isString(k) && lookupTM(tm, textOf(k), targetCode, primaryKey) === null);
677
+ // (A key the fallback refused before is still served from its cache
678
+ // here when it holds a valid entry — holding back stops calls, not reads.)
679
+ Object.assign(fromCache, serveCached(notInOwnCache, fb, fallbackKey));
680
+ report.cached = Object.keys(fromCache).length;
681
+ if (report.cached > 0) output.info(`[TM] ${report.cached} key(s) served from the fallback's cache (${fb.method})`);
682
+ }
683
+
684
+ // Step 3: the pair's own method, for everything else.
685
+ const primaryKeys = skipPrimary ? [] : stringKeys.filter(k => !(k in fromCache));
686
+ let primary = {
687
+ translated: null,
688
+ producedBy: {},
689
+ tmHitCount: Object.keys(primaryCacheHits).length,
690
+ failures: [],
691
+ apiCalled: false,
692
+ apiReturnedNull: false,
693
+ sentCount: 0,
694
+ retriedCount: 0,
695
+ pluralGaps: {},
696
+ heldKeys: [],
697
+ refusedKeys: [],
698
+ };
699
+ if (primaryKeys.length > 0) {
700
+ primary = await translateAndValidate(primaryKeys, sourceFlat, pairConfig, pairKey, { ...pipeline, noSendKeys: noSendPrimary });
701
+ }
702
+ const translated = { ...primaryCacheHits, ...fromCache, ...(primary.translated || {}) };
703
+ const notTranslated = (skipPrimary ? stringKeys : primaryKeys).filter(k => isString(k) && !(k in translated));
704
+ // Refused before by the fallback too: neither method is asked.
705
+ const heldBoth = notTranslated.filter(k => noSendFallback.has(k) && (skipPrimary || noSendPrimary.has(k)));
706
+ const toFallback = notTranslated.filter(k => !heldBoth.includes(k));
707
+ if (!skipPrimary && primary.apiReturnedNull && !primary.translated) report.primaryFailedWholesale = true;
708
+
709
+ const done = (fallbackResult = null) => {
710
+ const merged = { ...translated, ...(fallbackResult?.translated || {}) };
711
+ const fbFailures = (fallbackResult?.failures || []).filter(f => !(f.key in merged));
712
+ const fbFailed = new Set(fbFailures.map(f => f.key));
713
+ const failures = [
714
+ ...primary.failures.filter(f => !(f.key in merged) && !fbFailed.has(f.key)),
715
+ ...fbFailures,
716
+ ];
717
+ // Who refused what this run (the lock remembers it per key).
718
+ const refusedBy = {};
719
+ for (const k of primary.refusedKeys || []) if (!(k in merged)) refusedBy[k] = [primaryKey];
720
+ for (const k of fallbackResult?.refusedKeys || []) {
721
+ if (!(k in merged)) refusedBy[k] = [...(refusedBy[k] || []), fallbackKey];
722
+ }
723
+ // Not sent anywhere: refused before by every method that could be asked.
724
+ const heldKeys = [...new Set([...heldBoth, ...(fallbackResult?.heldKeys || [])])].filter(k => !(k in merged));
725
+ // Which method key produced each value (the cache entry, or the method asked).
726
+ const producedBy = {};
727
+ for (const k of Object.keys(merged)) {
728
+ producedBy[k] = fallbackResult?.producedBy?.[k] || primary.producedBy?.[k] || servedBy[k] || primaryKey;
729
+ }
730
+ // The method's own answers this run (the fallback's replace the primary's).
731
+ const fbAnswered = new Set(fallbackResult?.answeredKeys || []);
732
+ const fbTranslated = fallbackResult?.translated || {};
733
+ // What the fallback produced (its answers and its cache): named in the
734
+ // [FALLBACK] line (lib/fallback.js printFallbackReport).
735
+ report.produced = Object.keys(merged).filter(k => producedBy[k] === fallbackKey);
736
+ // The pair's own method's answers that were kept (not its cache hits).
737
+ report.primaryAccepted = (primary.answeredKeys || []).filter(k => k in merged && !(k in fbTranslated)).length;
738
+ const answeredKeys = [
739
+ ...(primary.answeredKeys || []).filter(k => k in merged && !(k in fbTranslated)),
740
+ ...[...fbAnswered].filter(k => k in merged),
741
+ ];
742
+ return {
743
+ producedBy,
744
+ answeredKeys,
745
+ translated: Object.keys(merged).length > 0 ? merged : null,
746
+ tmHitCount: primary.tmHitCount + report.cached + (fallbackResult?.tmHitCount || 0),
747
+ failures,
748
+ apiCalled: primary.apiCalled || !!fallbackResult?.apiCalled,
749
+ apiReturnedNull: skipPrimary || primary.apiReturnedNull,
750
+ sentCount: (primary.sentCount || 0) + (fallbackResult?.sentCount || 0),
751
+ retriedCount: (primary.retriedCount || 0) + (fallbackResult?.retriedCount || 0),
752
+ pluralGaps: pluralGapsOf(merged, sourceFlat, targetCode, pairConfig.pluralSlots || null),
753
+ fallbackReturnedNull: !!fallbackResult?.apiReturnedNull && !fallbackResult?.translated,
754
+ fallback: report,
755
+ sentKeys: [...(primary.sentKeys || []), ...(fallbackResult?.sentKeys || [])],
756
+ heldKeys,
757
+ refusedKeys: Object.keys(refusedBy),
758
+ refusedBy,
759
+ };
760
+ };
761
+
762
+ if (toFallback.length === 0) return done();
763
+
764
+ if (report.primaryFailedWholesale) {
765
+ output.warn(
766
+ `${pairKey}: the primary method (${pairConfig.method}) returned no results`
767
+ + `${skipPrimary ? ' for an earlier file of this locale' : ''} — sending ${toFallback.length} key(s) `
768
+ + `to the fallback (${fb.method}).`
769
+ );
770
+ }
771
+
772
+ // --max-cost: price what the fallback will actually send (its cache misses).
773
+ if (budget) {
774
+ const texts = {};
775
+ for (const k of toFallback) texts[k] = textOf(k);
776
+ const { misses } = partitionByTM(tm, texts, toFallback, targetCode, fallbackKey);
777
+ const verdict = await budget.approve(misses.length, fb);
778
+ if (!verdict.ok) {
779
+ report.skipped = { reason: verdict.reason, items: [...toFallback] };
780
+ return done();
781
+ }
782
+ }
783
+
784
+ report.attempted = toFallback.length;
785
+ // Why each key goes to the fallback, counted: the gate's refusal of the
786
+ // primary's answer, no answer, or a refusal on an earlier sync.
787
+ const primaryFailure = new Map();
788
+ for (const f of primary.failures || []) if (!primaryFailure.has(f.key)) primaryFailure.set(f.key, f);
789
+ for (const k of toFallback) {
790
+ const f = primaryFailure.get(k);
791
+ notePrimaryReason(report, f
792
+ ? refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, memorized: !!f.memorized })
793
+ : refusalCategory(null, { heldBefore: !skipPrimary && noSendPrimary.has(k), noAnswer: true }));
794
+ }
795
+ const fallbackResult = await translateAndValidate(
796
+ toFallback, sourceFlat, fb, `${pairKey} (fallback: ${fb.method})`,
797
+ { ...pipeline, onProgress: null, noSendKeys: noSendFallback },
798
+ );
799
+ const result = done(fallbackResult);
800
+ report.accepted = toFallback.filter(k => result.translated && k in result.translated).length;
801
+ return result;
197
802
  }