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
@@ -0,0 +1,964 @@
1
+ /**
2
+ * fallback.js — the per-pair fallback method: shared machinery for every lane.
3
+ *
4
+ * A pair may name a second method (`"fallback": { "method": … }`, resolved by
5
+ * lib/pairs.js resolveFallbackForPair into a full pair config). The pair's own
6
+ * method runs first; what it could not translate safely — keys the quality
7
+ * gate refused or that came back empty, Markdown blocks it dropped or damaged,
8
+ * front-matter fields it hollowed — goes to the fallback ONCE, through the
9
+ * same gate. What the fallback translates is cached under its own TM key, so
10
+ * the cache always says which method produced a value. What both fail stays
11
+ * failed exactly as without a fallback ('[EN] ' remains the content lanes'
12
+ * last resort).
13
+ *
14
+ * The TM ladder, everywhere: the pair's cache → the fallback's cache → the
15
+ * pair's method → the fallback's method. Text the fallback already paid for
16
+ * is reused, not re-sent to the primary on every re-run (re-ask the primary
17
+ * with --fresh or --retranslate).
18
+ *
19
+ * This module holds what the key-value, Hugo-content and Docusaurus lanes
20
+ * share: the --max-cost guard for fallback batches, the per-pair tally and its
21
+ * [FALLBACK] report line, and the content-lane helpers (front-matter fields,
22
+ * Markdown blocks, whole pages). The key-value pipeline itself is
23
+ * translateWithFallback in lib/translate-pair.js.
24
+ */
25
+
26
+ import { estimateCost } from './pairs.js';
27
+ import { lookupTM, lookupTMValidated, tmMethodKey } from './tm.js';
28
+ import { createTMEvictor } from './tm-evict.js';
29
+ import { translatableBlockSources } from './content-estimate.js';
30
+ import { checkContentPreservation, contentGateFault, sharedOutputReason } from './validate.js';
31
+ import { restoreBlocks, hasOrphanedPlaceholders, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX } from './content.js';
32
+ import { EST_CHARS_PER_KEY } from './config.js';
33
+ import { output } from './output.js';
34
+ import { refusalCategory, notePrimaryReason } from './refusal-category.js';
35
+
36
+ export { refusalCategory, notePrimaryReason };
37
+
38
+ // ── TM keys ──────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * The TM method keys whose entries a pair's output may have come from: its
42
+ * own, then its fallback's. Echo confirmation (lib/diff.js) and verify must
43
+ * consult both — a value the fallback produced is cached under the fallback.
44
+ *
45
+ * @param {object} pairConfig
46
+ * @returns {string[]}
47
+ */
48
+ export function tmKeysForPair(pairConfig) {
49
+ const keys = [tmMethodKey(pairConfig)];
50
+ if (pairConfig?.fallback) keys.push(tmMethodKey(pairConfig.fallback));
51
+ return keys;
52
+ }
53
+
54
+ /**
55
+ * True when the TM holds `value` for `text` under any of `tmKeys` — the
56
+ * "the pipeline produced this" proof the echo checks need.
57
+ *
58
+ * @param {object} tm
59
+ * @param {string} text - Source text as cached (tmSourceText)
60
+ * @param {string} locale
61
+ * @param {string[]} tmKeys - From tmKeysForPair
62
+ * @param {string} value
63
+ * @returns {boolean}
64
+ */
65
+ export function tmHoldsValue(tm, text, locale, tmKeys, value) {
66
+ return tmKeys.some(k => lookupTM(tm, text, locale, k) === value);
67
+ }
68
+
69
+ // ── Cost guard ───────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * The --max-cost guard for fallback batches.
73
+ *
74
+ * The pre-run estimate cannot know which keys or blocks the primary will
75
+ * fail, so fallback spend is not in it. Each fallback batch is priced just
76
+ * before it runs, with the same estimator the run's estimate used
77
+ * (pairs.js estimateCost → the method's own estimateCost). Under a cap, a
78
+ * batch runs only if the spend already committed — the pre-run estimate the
79
+ * cap admitted, plus every fallback batch approved so far — plus this one
80
+ * stays within the cap. An unpriced fallback is refused: unknown is not
81
+ * free, the same rule the pre-run gate applies to the primary.
82
+ *
83
+ * Concurrency: approvals from parallel locales await the estimate, then
84
+ * check-and-commit synchronously, so two batches cannot both slip under the
85
+ * cap.
86
+ *
87
+ * @param {{ maxCost?: number|null, committed?: number }} [opts]
88
+ * @returns {{ maxCost: number|null, readonly committed: number,
89
+ * approve: (units: number, fallbackConfig: object) => Promise<{ ok: boolean, estimate: number|null, reason?: string }> }}
90
+ */
91
+ export function createFallbackBudget({ maxCost = null, committed = 0, cwd = null } = {}) {
92
+ let spent = Number.isFinite(committed) ? committed : 0;
93
+ return {
94
+ maxCost,
95
+ get committed() { return spent; },
96
+ async approve(units, fallbackConfig) {
97
+ if (maxCost === null || units <= 0) return { ok: true, estimate: null };
98
+ // cwd: the project, where a local fallback's endpoint may be set (.env).
99
+ const estimate = await estimateCost(units, fallbackConfig, cwd ? { cwd } : {});
100
+ const cost = estimate?.estimatedCost ?? null;
101
+ if (cost === null) {
102
+ return {
103
+ ok: false,
104
+ estimate: null,
105
+ reason: `its cost cannot be estimated (${estimate?.source || 'unknown pricing'}) and --max-cost is set — unknown is not free`,
106
+ };
107
+ }
108
+ if (spent + cost > maxCost) {
109
+ return {
110
+ ok: false,
111
+ estimate: cost,
112
+ reason: `~$${cost.toFixed(4)} on top of the ~$${spent.toFixed(4)} already committed would pass --max-cost $${maxCost.toFixed(4)}`,
113
+ };
114
+ }
115
+ spent += cost;
116
+ return { ok: true, estimate: cost };
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Key-equivalents for a run of source characters — the unit the content
123
+ * lanes' estimates price (lib/cost-report.js priceContentChars).
124
+ *
125
+ * @param {number} chars
126
+ * @returns {number}
127
+ */
128
+ export function charsToKeyUnits(chars) {
129
+ return Math.ceil(chars / EST_CHARS_PER_KEY);
130
+ }
131
+
132
+ // ── Reporting ────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * A per-call fallback report (one file, one batch). Callers add these into
136
+ * a per-pair tally (addToTally) and print one [FALLBACK] line per pair.
137
+ *
138
+ * @param {object} fallbackConfig
139
+ * @returns {FallbackReport}
140
+ *
141
+ * @typedef {object} FallbackReport
142
+ * @property {string} method - The fallback's method
143
+ * @property {string|null} model
144
+ * @property {number} attempted - Units the primary failed and the fallback was asked to translate
145
+ * @property {number} accepted - Of those, how many the fallback translated and the gate accepted
146
+ * @property {number} cached - Units served from the fallback's cache (the primary was not re-asked)
147
+ * @property {boolean} primaryFailedWholesale - The primary returned nothing at all
148
+ * @property {{ reason: string, items: string[] }|null} skipped - Set when the
149
+ * fallback did not run (--max-cost); items are the keys/segments left failing
150
+ * @property {number} primaryAccepted - Units the pair's OWN method answered
151
+ * this run and the gate accepted (cache hits not counted) — with
152
+ * `accepted`, the run's fresh translations for the pair
153
+ * @property {Object<string, number>} primaryReasons - Why the units sent to
154
+ * the fallback were not the primary's: a refusal in a few words
155
+ * (refusalCategory) → how many
156
+ */
157
+ export function newFallbackReport(fallbackConfig) {
158
+ return {
159
+ method: fallbackConfig.method,
160
+ model: fallbackConfig.model || null,
161
+ attempted: 0,
162
+ accepted: 0,
163
+ cached: 0,
164
+ primaryFailedWholesale: false,
165
+ skipped: null,
166
+ // What the fallback produced (keys, or pages for the content lanes) —
167
+ // named in the [FALLBACK] line, so its output is findable in the files.
168
+ produced: [],
169
+ primaryAccepted: 0,
170
+ primaryReasons: {},
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Above this share of a run's fresh translations for a pair, the fallback
176
+ * wrote most of it, and sync says so in one warning (warnFallbackMajority) —
177
+ * and `status` beside the share of the files it holds. A fallback is the
178
+ * second opinion for what the pair's method cannot translate safely; when it
179
+ * writes most of the output, what ships is the fallback's work (Round 11,
180
+ * school persona: every app string and 5 of 7 newsletter segments came from
181
+ * the weakest method measured, and nothing said so in context).
182
+ */
183
+ export const FALLBACK_MAJORITY_SHARE = 0.5;
184
+
185
+
186
+ /**
187
+ * Fold one call's report into a pair's running tally (mutates `tally`).
188
+ *
189
+ * @param {FallbackReport} tally
190
+ * @param {FallbackReport|null} report
191
+ * @returns {FallbackReport}
192
+ */
193
+ export function addToTally(tally, report) {
194
+ if (!report) return tally;
195
+ tally.attempted += report.attempted;
196
+ tally.accepted += report.accepted;
197
+ tally.cached += report.cached;
198
+ tally.primaryAccepted = (tally.primaryAccepted || 0) + (report.primaryAccepted || 0);
199
+ for (const [why, n] of Object.entries(report.primaryReasons || {})) {
200
+ tally.primaryReasons = tally.primaryReasons || {};
201
+ tally.primaryReasons[why] = (tally.primaryReasons[why] || 0) + n;
202
+ }
203
+ if (Array.isArray(report.produced) && report.produced.length > 0) {
204
+ tally.produced = tally.produced || [];
205
+ for (const k of report.produced) if (!tally.produced.includes(k)) tally.produced.push(k);
206
+ }
207
+ if (report.primaryFailedWholesale) tally.primaryFailedWholesale = true;
208
+ if (report.skipped) {
209
+ tally.skipped = tally.skipped || { reason: report.skipped.reason, items: [] };
210
+ tally.skipped.items.push(...report.skipped.items);
211
+ }
212
+ return tally;
213
+ }
214
+
215
+ /**
216
+ * The machine-readable form of a tally (the --json summary's per-locale
217
+ * `fallback` object).
218
+ *
219
+ * @param {FallbackReport} tally
220
+ * @returns {object}
221
+ */
222
+ export function fallbackSummary(tally) {
223
+ return {
224
+ method: tally.method,
225
+ model: tally.model,
226
+ attempted: tally.attempted,
227
+ accepted: tally.accepted,
228
+ failed: tally.attempted - tally.accepted,
229
+ cached: tally.cached,
230
+ // The pair's own method's accepted answers this run (with `accepted`:
231
+ // this run's fresh translations), and why the rest went to the fallback.
232
+ primaryAccepted: tally.primaryAccepted || 0,
233
+ ...(tally.primaryReasons && Object.keys(tally.primaryReasons).length > 0 && { primaryReasons: { ...tally.primaryReasons } }),
234
+ primaryFailedWholesale: tally.primaryFailedWholesale,
235
+ // Keys (or pages) whose text the fallback produced this run.
236
+ ...(Array.isArray(tally.produced) && tally.produced.length > 0 && { produced: [...tally.produced] }),
237
+ ...(tally.skipped && { skipped: { reason: tally.skipped.reason, items: [...tally.skipped.items] } }),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Print a pair's [FALLBACK] line(s). Silent when the fallback had nothing
243
+ * to do — a pair whose own method translated everything says nothing here.
244
+ *
245
+ * [FALLBACK] en:crk — 6 key(s) the primary (api) could not translate
246
+ * safely → translated by llm-coached (4 accepted, 2 still failing)
247
+ *
248
+ * @param {string} pairKey
249
+ * @param {string} primaryMethod
250
+ * @param {FallbackReport} tally
251
+ * @param {{ unit?: string }} [opts] - "key(s)" or "content segment(s)"
252
+ */
253
+ export function printFallbackReport(pairKey, primaryMethod, tally, { unit = 'key(s)', producedLabel = 'keys' } = {}) {
254
+ if (!tally) return;
255
+ const still = tally.attempted - tally.accepted;
256
+ // Which ones: the files do not say what the fallback wrote (Round 8 school
257
+ // + hospital personas), so the line names them, and status counts them.
258
+ const produced = Array.isArray(tally.produced) ? tally.produced : [];
259
+ const named = produced.length > 0
260
+ ? ` — ${producedLabel}: ${produced.slice(0, 8).join(', ')}${produced.length > 8 ? `, +${produced.length - 8} more` : ''}`
261
+ + ' (`champollion status` counts what each locale holds from the fallback)'
262
+ : '';
263
+ if (tally.skipped) {
264
+ const items = tally.skipped.items;
265
+ const shown = items.slice(0, 10).join(', ') + (items.length > 10 ? `, +${items.length - 10} more` : '');
266
+ output.warn(
267
+ `[FALLBACK] ${pairKey} — ${items.length} ${unit} the primary (${primaryMethod}) could not translate safely; `
268
+ + `the fallback (${tally.method}) was skipped: ${tally.skipped.reason}. Still untranslated: ${shown}`
269
+ );
270
+ }
271
+ if (tally.attempted > 0) {
272
+ const line = `[FALLBACK] ${pairKey} — ${tally.attempted} ${unit} the primary (${primaryMethod}) could not `
273
+ + `translate safely → translated by ${tally.method} (${tally.accepted} accepted, ${still} still failing)`
274
+ + (tally.cached > 0 ? `; ${tally.cached} more served from its cache` : '')
275
+ + named;
276
+ if (still > 0) output.warn(line);
277
+ else output.info(line);
278
+ } else if (tally.cached > 0) {
279
+ output.info(
280
+ `[FALLBACK] ${pairKey} — ${tally.cached} ${unit} served from the fallback's cache (${tally.method}); `
281
+ + `the primary (${primaryMethod}) was not asked again${named}`
282
+ );
283
+ }
284
+ }
285
+
286
+ /**
287
+ * When the fallback wrote most of a run's fresh translations for a pair
288
+ * (more than FALLBACK_MAJORITY_SHARE of them — the primary's accepted
289
+ * answers plus the fallback's), say it once, plainly: how many of how many,
290
+ * by which method and model, why the primary's answers were not used
291
+ * (counted), and what to consider. Silent otherwise, and for a run that
292
+ * translated nothing fresh (everything from the cache: `status` carries the
293
+ * standing share of the files).
294
+ *
295
+ * @param {string} pairKey
296
+ * @param {string} primaryMethod
297
+ * @param {FallbackReport} tally
298
+ * @param {{ unit?: string }} [opts] - "key(s)" or "content segment(s)"
299
+ * @returns {{ fromFallback: number, translated: number, share: number }|null} what was said (null: nothing)
300
+ */
301
+ export function warnFallbackMajority(pairKey, primaryMethod, tally, { unit = 'key(s)' } = {}) {
302
+ if (!tally) return null;
303
+ const fromFallback = tally.accepted || 0;
304
+ const translated = (tally.primaryAccepted || 0) + fromFallback;
305
+ if (translated === 0 || fromFallback / translated <= FALLBACK_MAJORITY_SHARE) return null;
306
+ const who = `${tally.method}${tally.model ? `, model ${tally.model}` : ''}`;
307
+ const reasons = Object.entries(tally.primaryReasons || {}).sort((a, b) => b[1] - a[1])
308
+ .map(([why, n]) => `${why} (${n})`).join('; ');
309
+ output.warn(
310
+ `[FALLBACK] ${pairKey} — most of this run's translations came from the fallback: ${fromFallback} of ${translated} ${unit} `
311
+ + `were written by ${who}, not by the pair's method (${primaryMethod})`
312
+ + (reasons ? `. Why ${primaryMethod}'s answers were not used, for the ${tally.attempted} ${unit} sent to the fallback: ${reasons}` : '')
313
+ + `. What ships is the fallback's work. Consider: ${primaryMethod} may not suit these strings; check what was written `
314
+ + '(`champollion verify` checks structure — a speaker checks meaning); or a stronger fallback ("fallback" in the pair\'s "pairs" entry).',
315
+ );
316
+ return { fromFallback, translated, share: fromFallback / translated };
317
+ }
318
+
319
+ // ── Content lanes ────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Read-time validation shared by every content TM read (see lookupTMValidated):
323
+ * the gate a fresh answer passes (lib/validate.js contentGateFault), so a
324
+ * cached block the gate refuses today — a heading turned into a sentence,
325
+ * cached before the lane checked length — is evicted, not served.
326
+ */
327
+ const passesGate = (pairConfig) => (src, cached) => !contentGateFault(src, cached, pairConfig);
328
+
329
+ /**
330
+ * TM ladder for one content segment (a block, a whole body): the pair's own
331
+ * cache, then its fallback's. Each read is validated; a failing entry is
332
+ * evicted and reported as a miss.
333
+ *
334
+ * @returns {{ text: string, fromFallback: boolean }|null}
335
+ */
336
+ export function lookupContentTM(tm, source, code, pairConfig, { countCarry = true, gate = true } = {}) {
337
+ // gate false: a look for the repeat check, which names what it refuses —
338
+ // the gate still applies when the entry is served.
339
+ const check = (cfg) => (gate ? passesGate(cfg) : () => true);
340
+ const own = lookupTMValidated(tm, source, code, tmMethodKey(pairConfig), check(pairConfig), { countCarry });
341
+ if (own !== null) return { text: own, fromFallback: false };
342
+ if (!pairConfig.fallback) return null;
343
+ const fb = lookupTMValidated(tm, source, code, tmMethodKey(pairConfig.fallback), check(pairConfig.fallback), { countCarry });
344
+ return fb !== null ? { text: fb, fromFallback: true } : null;
345
+ }
346
+
347
+ // ── Cached content and the repeat check ──────────────────────────────
348
+
349
+ /**
350
+ * Items through the locale's different-inputs-same-output index; a suspect
351
+ * is evicted where it lives — every entry, under the pair's or its
352
+ * fallback's key, that holds that text for that source (lib/tm-evict.js).
353
+ *
354
+ * @param {Array<{ key: string, source: string, value: string }>} items
355
+ * @returns {Set<string>} Keys refused (and evicted)
356
+ */
357
+ function refuseRepeatedCached(items, { sharedOutputs, tm, code, pairConfig }) {
358
+ const refused = new Set();
359
+ if (items.length === 0) return refused;
360
+ const suspects = sharedOutputs.suspects(items);
361
+ if (suspects.size === 0) return refused;
362
+ const evictor = createTMEvictor(tm);
363
+ const keys = tmKeysForPair(pairConfig);
364
+ for (const it of items) {
365
+ if (!suspects.has(it.key)) continue;
366
+ refused.add(it.key);
367
+ evictor.evictProducing(it.source, code, it.value, keys);
368
+ }
369
+ return refused;
370
+ }
371
+
372
+ /**
373
+ * A page's cached content — its front-matter fields, its blocks, its whole
374
+ * body — through the repeat check (lib/validate.js SharedOutputIndex) in ONE
375
+ * batch, before the lane serves any of it: the way a key-value file's cached
376
+ * keys are checked together (lib/translate-pair.js). Cache hits used to skip
377
+ * the check: one memorized sentence cached for three different paragraphs
378
+ * was re-served by every sync, and only `verify` caught it on disk.
379
+ *
380
+ * A repeat is evicted where it lives, so the lane's own cache lookups miss
381
+ * it and it is translated again with the misses — through the same check.
382
+ * A whole-body entry holding a repeated block is evicted too (the body is
383
+ * then translated block by block, where only the repeats are asked again).
384
+ * What passes is recorded in the index, so later answers are compared with
385
+ * it. Keys are named as `verify` and the fresh path name them:
386
+ * "<label> front matter:<field>" and "<label>#<n>" (n = place among the
387
+ * page's translatable blocks). A whole body whose block count differs from
388
+ * its source's cannot be compared block by block (verify skips it too).
389
+ *
390
+ * @param {object} p
391
+ * @param {string} p.label - "content:<file>"
392
+ * @param {Record<string, string>} p.fields - field → source text: the fields the lane looks up
393
+ * @param {string} p.body - Source body ('' = none)
394
+ * @param {Set<number>} [p.skipBlocks] - Block places the lane does not serve from the cache (kept by hand)
395
+ * @param {boolean} [p.wholeBody=true] - false when the lane does not serve a whole-body hit
396
+ * @param {object} p.tm
397
+ * @param {string} p.code - Target locale
398
+ * @param {object} p.pairConfig
399
+ * @param {import('./validate.js').SharedOutputIndex|null} p.sharedOutputs
400
+ * @returns {string[]} What was refused, for the lane's warning ("title", "paragraph 2")
401
+ */
402
+ export function refuseRepeatedCachedPage({
403
+ label, fields = {}, body = '', skipBlocks = new Set(), wholeBody = true, tm, code, pairConfig, sharedOutputs,
404
+ }) {
405
+ if (!sharedOutputs || !tm) return [];
406
+ // A look, not a serve: a carried (other-model) hit is counted when served.
407
+ const peek = (source) => lookupContentTM(tm, source, code, pairConfig, { countCarry: false, gate: false });
408
+ const items = [];
409
+ for (const [field, source] of Object.entries(fields)) {
410
+ if (typeof source !== 'string') continue;
411
+ const hit = peek(source);
412
+ if (hit) items.push({ key: `${label} front matter:${field}`, source, value: hit.text, name: field });
413
+ }
414
+ const sources = typeof body === 'string' && body.trim() ? translatableBlockSources(body) : [];
415
+ const blockKeys = new Set();
416
+ sources.forEach((source, i) => {
417
+ if (skipBlocks.has(i)) return;
418
+ const hit = peek(source);
419
+ if (!hit) return;
420
+ items.push({ key: `${label}#${i + 1}`, source, value: hit.text, name: `paragraph ${i + 1}` });
421
+ blockKeys.add(`${label}#${i + 1}`);
422
+ });
423
+ let whole = null;
424
+ const wholeKeys = [];
425
+ if (wholeBody && sources.length > 0) {
426
+ const hit = peek(body);
427
+ const outs = hit ? translatableBlockSources(hit.text) : [];
428
+ if (hit && outs.length === sources.length) {
429
+ whole = hit;
430
+ sources.forEach((source, i) => {
431
+ const key = `${label}#${i + 1}`;
432
+ wholeKeys.push(key);
433
+ // The block's own entry, when there is one, speaks for it.
434
+ if (!blockKeys.has(key)) items.push({ key, source, value: outs[i], name: `paragraph ${i + 1}` });
435
+ });
436
+ }
437
+ }
438
+ const ctx = { sharedOutputs, tm, code, pairConfig };
439
+ const refused = refuseRepeatedCached(items, ctx);
440
+ if (whole && wholeKeys.some(k => refused.has(k))) {
441
+ createTMEvictor(tm).evictProducing(body, code, whole.text, tmKeysForPair(pairConfig));
442
+ }
443
+ sharedOutputs.add(items.filter(it => !refused.has(it.key)));
444
+ return items.filter(it => refused.has(it.key)).map(it => it.name);
445
+ }
446
+
447
+ /**
448
+ * Serve front-matter fields the pair's own cache missed from its fallback's
449
+ * cache (validated). No-op without a fallback.
450
+ *
451
+ * @param {object} tm
452
+ * @param {Record<string,string>} fields - field → source text
453
+ * @param {string[]} misses - Fields the pair's own cache did not hold
454
+ * @param {string} code
455
+ * @param {object} pairConfig
456
+ * @returns {{ hits: Record<string,string>, misses: string[] }}
457
+ */
458
+ export function serveFieldsFromFallbackCache(tm, fields, misses, code, pairConfig) {
459
+ if (!pairConfig.fallback || misses.length === 0) return { hits: {}, misses };
460
+ const fbKey = tmMethodKey(pairConfig.fallback);
461
+ const hits = {};
462
+ const rest = [];
463
+ for (const field of misses) {
464
+ const cached = lookupTMValidated(tm, fields[field], code, fbKey, passesGate(pairConfig.fallback));
465
+ if (cached !== null) hits[field] = cached;
466
+ else rest.push(field);
467
+ }
468
+ return { hits, misses: rest };
469
+ }
470
+
471
+ /**
472
+ * Translate the front-matter fields the TM did not hold: the pair's method,
473
+ * then — for every field it returned nothing for or hollowed — its fallback.
474
+ *
475
+ * NOTHING IS CACHED HERE. The lanes promise "nothing was written or cached"
476
+ * when a field fails, so the TM stores come back in `stores` and the caller
477
+ * commits them only once no field is left failing.
478
+ *
479
+ * Without a fallback this behaves exactly as the lanes always did: a null
480
+ * response is `noResults`, the first hollowed field is reported, a field
481
+ * missing from the response is left as it was.
482
+ *
483
+ * @param {object} p
484
+ * @param {Record<string,string>} p.fields - field → source text
485
+ * @param {string[]} p.misses - Fields to translate
486
+ * @param {object} p.pairConfig
487
+ * @param {(fields: string[], config: object) => Promise<object|null>} p.translate
488
+ * - The lane's translateBatch call for a given pair config
489
+ * @param {object|null} [p.budget] - createFallbackBudget(); null = no cap
490
+ * @param {Set<string>} [p.fallbackOnly] - Fields the pair's method refused
491
+ * before (lib/content-refusals.js): not sent to it, only to the fallback
492
+ * @returns {Promise<{ translated: Record<string,string>, stores: Array<{text: string, value: string, tmKey: string}>,
493
+ * hollowed: Array<{field: string, reason: string, value: string, fallbackReason?: string}>,
494
+ * noResults: boolean, report: FallbackReport|null, refusedBy: Record<string, string[]> }>}
495
+ * refusedBy: field → the method keys whose answer the gate refused, for the
496
+ * fields left untranslated (the lane remembers them)
497
+ */
498
+ export async function translateFieldsWithFallback({
499
+ fields, misses, pairConfig, translate, budget = null, sharedOutputs = null, label = 'front matter',
500
+ fallbackOnly = new Set(),
501
+ }) {
502
+ const fb = pairConfig.fallback || null;
503
+ const primaryKey = tmMethodKey(pairConfig);
504
+ const translated = {};
505
+ const stores = [];
506
+ /** @type {Map<string, {reason: string|null, value?: string}>} field → why it failed */
507
+ const failed = new Map();
508
+ const fieldKey = (field) => `${label}:${field}`;
509
+ // A field answered with the text the model gave for other source strings
510
+ // (lib/validate.js SharedOutputIndex) is refused like a hollowed one.
511
+ const sharedCheck = (candidates) => {
512
+ if (!sharedOutputs || candidates.length === 0) return new Map();
513
+ const suspects = sharedOutputs.suspects(candidates.map(([field, value]) => ({ key: fieldKey(field), source: fields[field], value })));
514
+ return new Map(candidates.filter(([field]) => suspects.has(fieldKey(field)))
515
+ .map(([field]) => [field, sharedOutputReason(suspects.get(fieldKey(field)))]));
516
+ };
517
+ const remember = (pairs) => {
518
+ if (sharedOutputs) sharedOutputs.add(pairs.map(([field, value]) => ({ key: fieldKey(field), source: fields[field], value })));
519
+ };
520
+
521
+ // Fields the pair's method refused before go to the fallback only.
522
+ const primaryAsked = fb ? misses.filter(f => !fallbackOnly.has(f)) : misses;
523
+ const out = primaryAsked.length > 0 ? await translate(primaryAsked, pairConfig) : {};
524
+ for (const field of misses) if (!primaryAsked.includes(field)) failed.set(field, { reason: null, fallbackOnly: true });
525
+ if (out) {
526
+ const passing = [];
527
+ for (const field of primaryAsked) {
528
+ const value = out[field];
529
+ if (typeof value !== 'string') {
530
+ // Missing from the response: left untranslated, as always — unless
531
+ // there is a fallback to ask.
532
+ if (fb) failed.set(field, { reason: null });
533
+ continue;
534
+ }
535
+ const refused = contentGateFault(fields[field], value, pairConfig);
536
+ if (refused) {
537
+ failed.set(field, { reason: refused, value });
538
+ continue;
539
+ }
540
+ passing.push([field, value]);
541
+ }
542
+ const shared = sharedCheck(passing);
543
+ for (const [field, value] of passing) {
544
+ if (shared.has(field)) { failed.set(field, { reason: shared.get(field), value, sharedOutput: true }); continue; }
545
+ translated[field] = value;
546
+ stores.push({ text: fields[field], value, tmKey: primaryKey });
547
+ }
548
+ remember(passing.filter(([field]) => !shared.has(field)));
549
+ } else if (fb) {
550
+ for (const field of primaryAsked) failed.set(field, { reason: null });
551
+ }
552
+
553
+ // With a fallback, a report even when nothing goes to it: the primary's
554
+ // accepted fields count toward the run's share (warnFallbackMajority).
555
+ let report = fb ? newFallbackReport(fb) : null;
556
+ if (report) report.primaryAccepted = Object.keys(translated).length;
557
+ let fallbackOut = null;
558
+ if (fb && failed.size > 0) {
559
+ report.primaryFailedWholesale = !out;
560
+ const asked = [...failed.keys()];
561
+ for (const field of asked) {
562
+ const f = failed.get(field);
563
+ notePrimaryReason(report, refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, heldBefore: !!f.fallbackOnly, noAnswer: !f.reason }));
564
+ }
565
+ const chars = asked.reduce((n, f) => n + fields[f].length, 0);
566
+ const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
567
+ if (!verdict.ok) {
568
+ report.skipped = { reason: verdict.reason, items: asked.map(f => `front matter "${f}"`) };
569
+ } else {
570
+ report.attempted = asked.length;
571
+ fallbackOut = await translate(asked, fb);
572
+ const fbKey = tmMethodKey(fb);
573
+ const fbPassing = [];
574
+ for (const field of asked) {
575
+ const value = fallbackOut?.[field];
576
+ if (typeof value !== 'string') continue;
577
+ const refused = contentGateFault(fields[field], value, fb);
578
+ if (refused) {
579
+ failed.get(field).fallbackReason = refused;
580
+ if (failed.get(field).fallbackOnly) failed.get(field).value = value;
581
+ continue;
582
+ }
583
+ fbPassing.push([field, value]);
584
+ }
585
+ const fbShared = sharedCheck(fbPassing);
586
+ for (const [field, value] of fbPassing) {
587
+ if (fbShared.has(field)) {
588
+ failed.get(field).fallbackReason = fbShared.get(field);
589
+ if (failed.get(field).fallbackOnly) failed.get(field).value = value;
590
+ continue;
591
+ }
592
+ translated[field] = value;
593
+ stores.push({ text: fields[field], value, tmKey: fbKey });
594
+ failed.delete(field);
595
+ report.accepted++;
596
+ }
597
+ remember(fbPassing.filter(([field]) => !fbShared.has(field)));
598
+ }
599
+ }
600
+
601
+ // A field still hollowed (by the primary, and by the fallback when there
602
+ // is one) fails the file, as always; the caller names the first. A field
603
+ // only the fallback was asked for fails it when the fallback's answer is
604
+ // refused too.
605
+ const hollowed = [];
606
+ const refusedBy = {};
607
+ const fbKey = fb ? tmMethodKey(fb) : null;
608
+ for (const [field, f] of failed) {
609
+ const methods = [...(f.reason ? [primaryKey] : []), ...(f.fallbackReason ? [fbKey] : [])];
610
+ if (methods.length > 0) refusedBy[field] = methods;
611
+ if (f.reason || (f.fallbackOnly && f.fallbackReason)) {
612
+ hollowed.push({
613
+ field, reason: f.reason || `refused before: the gate refused ${pairConfig.method}'s translation of it on an earlier sync`, value: f.value,
614
+ ...(f.sharedOutput && { sharedOutput: true }),
615
+ ...(f.fallbackReason && { fallbackReason: f.fallbackReason }),
616
+ });
617
+ }
618
+ }
619
+
620
+ return {
621
+ translated,
622
+ stores,
623
+ hollowed,
624
+ noResults: !out && Object.keys(translated).length === 0,
625
+ report,
626
+ refusedBy,
627
+ };
628
+ }
629
+
630
+ /** ⟦PROTECTED_N⟧ tokens in a protected text. */
631
+ function protectedTokens(text) {
632
+ const re = new RegExp(`${PLACEHOLDER_PREFIX}\\d+${PLACEHOLDER_SUFFIX}`, 'g');
633
+ return text.match(re) || [];
634
+ }
635
+
636
+ /**
637
+ * Why a translated Markdown block cannot be used, or null. The same gates
638
+ * the lanes apply to a whole body — orphaned placeholders, lost content —
639
+ * applied per block so the failing block (not the whole file) goes to the
640
+ * fallback, plus a check the whole-body gate cannot make: every protected
641
+ * element of the source block (code, link target, shortcode, HTML) must
642
+ * still be there.
643
+ *
644
+ * Used only for pairs with a fallback: without one there is no second
645
+ * method to route a block to, and the whole-body gates decide as before.
646
+ *
647
+ * @param {string} protectedSource - The block as sent (placeholders in)
648
+ * @param {string} protectedOut - The model's block (placeholders in)
649
+ * @param {string} restoredOut - protectedOut with placeholders restored
650
+ * @param {string} restoredSource - The block's source text, restored
651
+ * @returns {string|null}
652
+ */
653
+ export function blockFault(protectedSource, protectedOut, restoredOut, restoredSource, pairConfig = {}) {
654
+ if (hasOrphanedPlaceholders(restoredOut)) return 'a protected element was damaged';
655
+ const dropped = protectedTokens(protectedSource).filter(t => !protectedOut.includes(t));
656
+ if (dropped.length > 0) return `${dropped.length} protected element(s) (code, link, markup) missing`;
657
+ // The key-value gate's checks, per block (lib/validate.js contentGateFault):
658
+ // hollowing, echo, length inflation, truncation, repetition, script.
659
+ return contentGateFault(restoredSource, restoredOut, pairConfig);
660
+ }
661
+
662
+ /**
663
+ * Translate the Markdown blocks the TM ladder did not hold: one batch through
664
+ * the pair's method, then one batch through its fallback for every block the
665
+ * primary dropped or damaged. A block both fail is written as
666
+ * `fallbackPrefix` + its source — the honest '[EN] ' last resort (never
667
+ * cached; the caller does not advance the file's lock).
668
+ *
669
+ * Without a fallback this is exactly the lanes' old block path: the primary's
670
+ * output stands, its dropped blocks carry the prefix, and a structural
671
+ * failure (no response, duplicate markers) throws.
672
+ *
673
+ * With a fallback, a structural primary failure sends every block to the
674
+ * fallback; only when the fallback ALSO produces nothing does the primary's
675
+ * error fail the file. When --max-cost skips the fallback, the primary's
676
+ * output stands exactly as it would without one.
677
+ *
678
+ * @param {object} p
679
+ * @param {Array<{seg: {text: string}, source: string}>} p.missed - Blocks to translate
680
+ * @param {Map<string,string>} p.blocks - protectBlocks() placeholder map
681
+ * @param {object} p.pairConfig
682
+ * @param {(texts: string[], config: object) => Promise<{blocks: string[], fellBack: number[]}>} p.runBatch
683
+ * - The lane's translateBlockBatchResilient call for a given pair config
684
+ * @param {string} p.fallbackPrefix
685
+ * @param {object|null} [p.budget]
686
+ * @param {import('./validate.js').SharedOutputIndex|null} [p.sharedOutputs] - The
687
+ * locale's different-inputs-same-output index: a block answered with the
688
+ * text the model gave for other source strings is refused like a damaged
689
+ * one (the fallback gets it; without one, the honest '[EN] ' last resort)
690
+ * @param {string} [p.label] - Names the blocks in that index ("posts/a.md")
691
+ * @param {Set<number>} [p.fallbackOnly] - Indices (into `missed`) of blocks the
692
+ * pair's method refused before (lib/content-refusals.js): not sent to it,
693
+ * only to the fallback ('[EN] ' when the fallback does not translate them)
694
+ * @returns {Promise<{ outs: string[], stores: Array<{source: string, translation: string, tmKey: string}>,
695
+ * fellBack: number[], fromFallback: number, report: FallbackReport|null, sharedOutput: number[],
696
+ * refused: Array<{ i: number, block: number, reason: string }>, refusedBy: string[][] }>} refused: blocks written as the
697
+ * '[EN] ' last resort because the gate refused them (block = place among the page's blocks, from 1);
698
+ * refusedBy[i]: the method keys whose answer for block i the gate refused, for blocks left as '[EN] '
699
+ * (empty for the rest) — the lane remembers them
700
+ */
701
+ export async function translateBlocksWithFallback({
702
+ missed, blocks, pairConfig, runBatch, fallbackPrefix, budget = null, sharedOutputs = null, label = 'block',
703
+ fallbackOnly = new Set(),
704
+ }) {
705
+ const texts = missed.map(r => r.seg.text);
706
+ const primaryKey = tmMethodKey(pairConfig);
707
+ const fb = pairConfig.fallback || null;
708
+ // Whose answers the gate refused, per block (the primary's, the fallback's).
709
+ const primaryRefused = new Set();
710
+ const fallbackRefused = new Set();
711
+ const refusedByOf = (fellBack) => {
712
+ const fell = new Set(fellBack);
713
+ return texts.map((_, i) => (fell.has(i)
714
+ ? [...(primaryRefused.has(i) ? [primaryKey] : []), ...(fallbackRefused.has(i) ? [tmMethodKey(fb)] : [])]
715
+ : []));
716
+ };
717
+ // Named by the block's place among the page's translatable blocks (as
718
+ // verify and the cached-block check name it), not its place in `missed`.
719
+ const blockKey = (i) => `${label}#${(Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1}`;
720
+ // Blocks whose output repeats what the model answered for other sources.
721
+ const sharedFault = (candidates) => {
722
+ if (!sharedOutputs) return new Set();
723
+ const suspects = sharedOutputs.suspects(candidates.map(({ i, out }) => ({ key: blockKey(i), source: missed[i].source, value: out })));
724
+ return new Set(candidates.filter(({ i }) => suspects.has(blockKey(i))).map(({ i }) => i));
725
+ };
726
+ const remember = (accepted) => {
727
+ if (sharedOutputs) sharedOutputs.add(accepted.map(({ i, out }) => ({ key: blockKey(i), source: missed[i].source, value: out })));
728
+ };
729
+ // Why the gate refused a block (index → reason), for the lane's warning.
730
+ const refusedReason = new Map();
731
+ /** Blocks written as the '[EN] ' last resort because the gate refused them. */
732
+ const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
733
+ .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
734
+
735
+ if (!fb) {
736
+ const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
737
+ const fellSet = new Set(fellBack);
738
+ const outs = translatedBlocks.map(t => restoreBlocks(t, blocks));
739
+ // The key-value gate's checks, per block: a short heading turned into a
740
+ // sentence is refused here as an app key is (Round 7, school persona).
741
+ for (let i = 0; i < outs.length; i++) {
742
+ if (fellSet.has(i)) continue;
743
+ const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
744
+ if (fault) refusedReason.set(i, fault);
745
+ }
746
+ const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
747
+ const stores = [];
748
+ const accepted = [];
749
+ outs.forEach((out, i) => {
750
+ if (shared.has(i) || refusedReason.has(i)) {
751
+ // Refused: the honest last resort, never cached.
752
+ outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
753
+ fellBack.push(i);
754
+ primaryRefused.add(i);
755
+ return;
756
+ }
757
+ if (!fellSet.has(i)) {
758
+ stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
759
+ accepted.push({ i, out });
760
+ }
761
+ });
762
+ remember(accepted);
763
+ return {
764
+ outs, stores, fellBack, fromFallback: 0, report: null, sharedOutput: [...shared], refused: refusedList(fellBack),
765
+ refusedBy: refusedByOf(fellBack),
766
+ };
767
+ }
768
+
769
+ // Blocks the pair's method refused before are not sent to it again.
770
+ const primaryIdx = texts.map((_, i) => i).filter(i => !fallbackOnly.has(i));
771
+ let primary = null;
772
+ let primaryError = null;
773
+ try {
774
+ if (primaryIdx.length === texts.length) {
775
+ primary = await runBatch(texts, pairConfig);
776
+ } else if (primaryIdx.length > 0) {
777
+ // Back to the page's own block indices.
778
+ const res = await runBatch(primaryIdx.map(i => texts[i]), pairConfig);
779
+ const all = new Array(texts.length);
780
+ primaryIdx.forEach((i, j) => { all[i] = res.blocks[j]; });
781
+ primary = { blocks: all, fellBack: res.fellBack.map(j => primaryIdx[j]) };
782
+ } else {
783
+ primary = { blocks: new Array(texts.length), fellBack: [] };
784
+ }
785
+ } catch (err) {
786
+ primaryError = err;
787
+ }
788
+
789
+ const outs = new Array(texts.length);
790
+ const stores = [];
791
+ const failed = [];
792
+ const primaryFell = new Set(primary ? primary.fellBack : []);
793
+ const sharedIdx = new Set();
794
+ if (primary) {
795
+ const good = [];
796
+ for (let i = 0; i < texts.length; i++) {
797
+ if (primaryFell.has(i) || fallbackOnly.has(i)) { failed.push(i); continue; }
798
+ const restored = restoreBlocks(primary.blocks[i], blocks);
799
+ const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
800
+ if (fault) {
801
+ refusedReason.set(i, fault);
802
+ primaryRefused.add(i);
803
+ failed.push(i);
804
+ continue;
805
+ }
806
+ good.push({ i, out: restored });
807
+ }
808
+ const shared = sharedFault(good);
809
+ for (const { i, out } of good) {
810
+ if (shared.has(i)) { failed.push(i); sharedIdx.add(i); primaryRefused.add(i); continue; }
811
+ outs[i] = out;
812
+ stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
813
+ }
814
+ remember(good.filter(({ i }) => !shared.has(i)));
815
+ } else {
816
+ for (let i = 0; i < texts.length; i++) failed.push(i);
817
+ }
818
+
819
+ const report = newFallbackReport(fb);
820
+ report.primaryFailedWholesale = !primary;
821
+ // The primary's accepted blocks, and why the rest go to the fallback
822
+ // (lib/fallback.js warnFallbackMajority).
823
+ report.primaryAccepted = outs.filter(o => o !== undefined).length;
824
+ for (const i of failed) {
825
+ notePrimaryReason(report, refusalCategory(refusedReason.get(i) || null, {
826
+ sharedOutput: sharedIdx.has(i), heldBefore: fallbackOnly.has(i), noAnswer: !refusedReason.has(i) && !sharedIdx.has(i),
827
+ }));
828
+ }
829
+ const fellBack = [];
830
+ failed.sort((a, b) => a - b);
831
+ if (failed.length === 0) return { outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [], refused: [], refusedBy: refusedByOf([]) };
832
+
833
+ const chars = failed.reduce((n, i) => n + missed[i].source.length, 0);
834
+ const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
835
+ if (!verdict.ok) {
836
+ report.skipped = { reason: verdict.reason, items: failed.map(i => `block ${i + 1} of ${texts.length}`) };
837
+ if (primaryError) throw primaryError;
838
+ // The primary's own output stands, exactly as without a fallback —
839
+ // except a block refused as a repeated (memorized) answer, and a block
840
+ // only the fallback was to be asked for (the primary had no answer).
841
+ for (const i of failed) {
842
+ if (sharedIdx.has(i) || primaryFell.has(i) || refusedReason.has(i) || fallbackOnly.has(i)) {
843
+ outs[i] = sharedIdx.has(i) || refusedReason.has(i) || fallbackOnly.has(i)
844
+ ? restoreBlocks(fallbackPrefix + texts[i], blocks) : restoreBlocks(primary.blocks[i], blocks);
845
+ fellBack.push(i);
846
+ continue;
847
+ }
848
+ outs[i] = restoreBlocks(primary.blocks[i], blocks);
849
+ stores.push({ source: missed[i].source, translation: outs[i], tmKey: primaryKey });
850
+ }
851
+ return {
852
+ outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [...sharedIdx], refused: refusedList(fellBack),
853
+ refusedBy: refusedByOf(fellBack),
854
+ };
855
+ }
856
+
857
+ report.attempted = failed.length;
858
+ let second = null;
859
+ try {
860
+ second = await runBatch(failed.map(i => texts[i]), fb);
861
+ } catch (err) {
862
+ if (primaryError) {
863
+ primaryError.message += ` (the fallback, ${fb.method}, failed too: ${err.message})`;
864
+ throw primaryError;
865
+ }
866
+ }
867
+ if (!second && primaryError) throw primaryError;
868
+
869
+ const fbKey = tmMethodKey(fb);
870
+ const secondFell = new Set(second ? second.fellBack : []);
871
+ let fromFallback = 0;
872
+ const fbGood = [];
873
+ failed.forEach((i, j) => {
874
+ if (second && !secondFell.has(j)) {
875
+ const restored = restoreBlocks(second.blocks[j], blocks);
876
+ const fbFault = blockFault(texts[i], second.blocks[j], restored, missed[i].source, fb);
877
+ if (!fbFault) fbGood.push({ i, out: restored });
878
+ else {
879
+ refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
880
+ fallbackRefused.add(i);
881
+ }
882
+ }
883
+ });
884
+ const fbShared = sharedFault(fbGood);
885
+ for (const i of fbShared) fallbackRefused.add(i);
886
+ const fbAccepted = new Map(fbGood.filter(({ i }) => !fbShared.has(i)).map(({ i, out }) => [i, out]));
887
+ remember([...fbAccepted].map(([i, out]) => ({ i, out })));
888
+ for (const i of failed) {
889
+ if (fbAccepted.has(i)) {
890
+ outs[i] = fbAccepted.get(i);
891
+ stores.push({ source: missed[i].source, translation: outs[i], tmKey: fbKey });
892
+ report.accepted++;
893
+ fromFallback++;
894
+ continue;
895
+ }
896
+ // Both methods failed this block: the honest last resort.
897
+ outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
898
+ fellBack.push(i);
899
+ }
900
+ return {
901
+ outs, stores, fellBack, fromFallback, report, sharedOutput: [...sharedIdx].filter(i => !fbAccepted.has(i)),
902
+ refused: refusedList(fellBack),
903
+ refusedBy: refusedByOf(fellBack),
904
+ };
905
+ }
906
+
907
+ /**
908
+ * Whole-page ('page' segmentation) translation with a fallback. The pair's
909
+ * method first; when it returns nothing, damages a placeholder or hollows
910
+ * the body, the fallback translates the whole page once. Returns null for
911
+ * `body` when both fail — the caller then fails the file exactly as it does
912
+ * without a fallback (its whole-body checks name the reason).
913
+ *
914
+ * Only called for pairs WITH a fallback; the lanes keep their own page path
915
+ * otherwise.
916
+ *
917
+ * @param {object} p
918
+ * @param {string} p.body - Source body (restored)
919
+ * @param {Map<string,string>} p.blocks - protectBlocks() map
920
+ * @param {object} p.pairConfig
921
+ * @param {(config: object) => Promise<string|null>} p.runPage - One page call for a config
922
+ * @param {object|null} [p.budget]
923
+ * @param {boolean} [p.fallbackOnly] - The pair's method refused this page
924
+ * before (lib/content-refusals.js): it is not asked, only the fallback is
925
+ * @returns {Promise<{ body: string|null, primaryBody: string|null, tmKey: string|null, report: FallbackReport,
926
+ * refusedBy: string[] }>} refusedBy: the method keys whose page the whole-body checks refused this run
927
+ */
928
+ export async function translatePageWithFallback({ body, blocks, pairConfig, runPage, budget = null, fallbackOnly = false }) {
929
+ const fb = pairConfig.fallback;
930
+ const report = newFallbackReport(fb);
931
+ const fault = (restored) => hasOrphanedPlaceholders(restored) || !!checkContentPreservation(body, restored);
932
+ const refusedBy = [];
933
+
934
+ let primaryBody = null;
935
+ if (!fallbackOnly) {
936
+ const first = await runPage(pairConfig);
937
+ primaryBody = first ? restoreBlocks(first, blocks) : null;
938
+ if (primaryBody !== null && !fault(primaryBody)) {
939
+ report.primaryAccepted = 1;
940
+ return { body: primaryBody, primaryBody, tmKey: tmMethodKey(pairConfig), report, refusedBy };
941
+ }
942
+ if (primaryBody !== null) refusedBy.push(tmMethodKey(pairConfig));
943
+ report.primaryFailedWholesale = first === null;
944
+ }
945
+ // Why the page goes to the fallback (warnFallbackMajority).
946
+ notePrimaryReason(report, fallbackOnly
947
+ ? refusalCategory(null, { heldBefore: true })
948
+ : refusalCategory(primaryBody === null ? null : 'content lost or changed', { noAnswer: primaryBody === null }));
949
+
950
+ const verdict = budget ? await budget.approve(charsToKeyUnits(body.length), fb) : { ok: true };
951
+ if (!verdict.ok) {
952
+ report.skipped = { reason: verdict.reason, items: ['the page body'] };
953
+ return { body: null, primaryBody, tmKey: null, report, refusedBy };
954
+ }
955
+ report.attempted = 1;
956
+ const second = await runPage(fb);
957
+ const fallbackBody = second ? restoreBlocks(second, blocks) : null;
958
+ if (fallbackBody !== null && !fault(fallbackBody)) {
959
+ report.accepted = 1;
960
+ return { body: fallbackBody, primaryBody, tmKey: tmMethodKey(fb), report, refusedBy };
961
+ }
962
+ if (fallbackBody !== null) refusedBy.push(tmMethodKey(fb));
963
+ return { body: null, primaryBody, tmKey: null, report, refusedBy };
964
+ }