champollion 0.4.0 → 0.5.1

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.
@@ -74,7 +74,7 @@ import {
74
74
  import { walkFiles } from './locale-layout.js';
75
75
  import {
76
76
  readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
77
- describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME,
77
+ describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
78
78
  } from './content-refusals.js';
79
79
  import { applyNamedKeyRule, reportUnmatchedKeys, unmatchedKeysSummary, reportNamedFromCache } from './named-keys.js';
80
80
 
@@ -248,7 +248,9 @@ function scanDocusaurusContentWork(contentSources, pairEntries, config, manifest
248
248
  action = 'changed';
249
249
  }
250
250
  } else {
251
- // No stored hash — check for [EN] fallback markers. This runs
251
+ // No stored hash — check for legacy [EN] fallback markers (pages
252
+ // written before 2026-10-05; now such a page has a pending:<hash>
253
+ // lock entry instead). This runs
252
254
  // even under --force-content: a target with no lock entry and
253
255
  // no [EN] markers is a genuine hand-translated file, and force
254
256
  // must never overwrite human work with machine output.
@@ -1372,6 +1374,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1372
1374
  // Each field is cached on its own source text (exactly like a
1373
1375
  // key-value sync key): a title edit re-pays only the title.
1374
1376
  const translatedFields = {};
1377
+ // A field left in the source language (refused, or held from an
1378
+ // earlier refusal): the page is written, its lock marked pending.
1379
+ let fieldsLeftInSource = false;
1375
1380
  if (hasFrontMatter && Object.keys(fieldsToTranslate).length > 0) {
1376
1381
  const { hits: fmHits, misses: fmMisses } = partitionByTM(
1377
1382
  tm, fieldsToTranslate, Object.keys(fieldsToTranslate), code, tmKey
@@ -1393,14 +1398,16 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1393
1398
  if (Object.keys(fbCache.hits).length > 0) notePage(pairKey, label);
1394
1399
  const fieldsToSend = fbCache.misses;
1395
1400
  for (const f of Object.keys(translatedFields)) refusal.filled.add(fieldUnit(f));
1396
- // Refused before: a held field fails its page as a refused one
1397
- // does — nothing of it is sent, nothing written, lock not advanced.
1401
+ // Refused before: a held field is not sent again; it keeps its
1402
+ // source text and the page is written (its lock marked pending).
1398
1403
  const heldFields = fieldsToSend.filter(f => holds.field(f, fieldsToTranslate[f]) === 'held');
1399
1404
  const fallbackOnlyFields = new Set(fieldsToSend.filter(f => holds.field(f, fieldsToTranslate[f]) === 'fallback-only'));
1400
1405
  if (heldFields.length > 0) {
1401
- const err = new Error('held');
1402
- err.heldNames = heldFields.map(f => `front matter "${f}"`);
1403
- throw err;
1406
+ fieldsLeftInSource = true;
1407
+ const names = heldFields.map(f => `front matter "${f}"`);
1408
+ refusal.held.push(...names);
1409
+ output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
1410
+ for (const f of heldFields) fieldsToSend.splice(fieldsToSend.indexOf(f), 1);
1404
1411
  }
1405
1412
 
1406
1413
  if (fieldsToSend.length > 0) {
@@ -1420,9 +1427,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1420
1427
  budget: fallbackBudget,
1421
1428
  sharedOutputs: sharedOutputsFor(code),
1422
1429
  label: `${docLabel} front matter`,
1423
- translate: (keys, cfg) => translateBatch(
1430
+ translate: (keys, cfg, extra = {}) => translateBatch(
1424
1431
  keys, fieldsToTranslate, cfg,
1425
- { apiKey, model: cfg.model, batchSize: cfg.batchSize || 30 },
1432
+ { apiKey, model: cfg.model, batchSize: cfg.batchSize || 30, ...extra },
1426
1433
  ),
1427
1434
  fallbackOnly: fallbackOnlyFields,
1428
1435
  });
@@ -1432,18 +1439,16 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1432
1439
  refusal.refused.push({ unit: fieldUnit(field), source: fieldsToTranslate[field], methods });
1433
1440
  }
1434
1441
  if (fm.hollowed.length > 0) {
1435
- const h = fm.hollowed[0];
1436
- const err = new Error(
1437
- `Docusaurus content sync for ${code}: front matter "${h.field}" — ${h.reason}.\n` +
1438
- ` source: ${JSON.stringify(fieldsToTranslate[h.field])}\n` +
1439
- ` got: ${JSON.stringify(h.value)}\n` +
1440
- (h.fallbackReason ? ` the fallback (${pairConfig.fallback.method}) failed it too: ${h.fallbackReason}\n` : '') +
1441
- ' Nothing was written or cached. If this is a low-coverage target\n' +
1442
- ' language, the model has no vocabulary for this string.\n' +
1443
- ` ${describeNewHold({ file: label, pairKey, pairConfig, count: fm.hollowed.length })}`
1444
- );
1445
- err.heldNext = true;
1446
- throw err;
1442
+ // Refused, and refused again when asked with the reason: the
1443
+ // field keeps its source text; the page is still written.
1444
+ fieldsLeftInSource = true;
1445
+ for (const h of fm.hollowed) {
1446
+ output.warn(
1447
+ `${label} → ${code}: front matter "${h.field}" kept in the source language — ${h.reason}`
1448
+ + `${h.fallbackReason ? `; the fallback (${pairConfig.fallback.method}): ${h.fallbackReason}` : ''}.`
1449
+ );
1450
+ }
1451
+ output.warn(describeNewHold({ file: label, pairKey, pairConfig, count: fm.hollowed.length }));
1447
1452
  }
1448
1453
  if (fm.noResults) {
1449
1454
  // Front matter translation failed — loud error
@@ -1461,9 +1466,10 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1461
1466
  // by the pair's method — the page is held, as with no fallback.
1462
1467
  const unfilled = [...fallbackOnlyFields].filter(f => !(f in fm.translated));
1463
1468
  if (unfilled.length > 0) {
1464
- const err = new Error('held');
1465
- err.heldNames = unfilled.map(f => `front matter "${f}"`);
1466
- throw err;
1469
+ fieldsLeftInSource = true;
1470
+ const names = unfilled.map(f => `front matter "${f}"`);
1471
+ refusal.held.push(...names);
1472
+ output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
1467
1473
  }
1468
1474
  Object.assign(translatedFields, fm.translated);
1469
1475
  for (const f of Object.keys(fm.translated)) refusal.filled.add(fieldUnit(f));
@@ -1583,7 +1589,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1583
1589
  }));
1584
1590
 
1585
1591
  const missed = [];
1586
- const heldBlocks = []; // refused before: not sent, '[EN] ' kept
1592
+ const heldBlocks = []; // refused before: not sent, the source text kept
1587
1593
  let position = 0; // place among the translatable blocks
1588
1594
  for (const r of rendered) {
1589
1595
  if (r.seg.type !== 'translatable') {
@@ -1603,8 +1609,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1603
1609
  const hold = holds.block(r.source);
1604
1610
  if (hold === 'held') {
1605
1611
  // Refused before by this method (and its fallback): the
1606
- // honest last resort stays, nothing is sent or billed.
1607
- r.out = restoreBlocks(config.fallbackPrefix + r.seg.text, blocks);
1612
+ // source text stays, nothing is sent or billed.
1613
+ r.out = r.source;
1608
1614
  heldBlocks.push(r);
1609
1615
  continue;
1610
1616
  }
@@ -1615,7 +1621,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1615
1621
  bodyUsedFallback = true;
1616
1622
  const names = heldBlocks.map(r => `paragraph ${r.pos + 1}`);
1617
1623
  refusal.held.push(...names);
1618
- output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names, fallbackPrefix: config.fallbackPrefix }));
1624
+ output.warn(describeHeld({ file: label, code, pairKey, pairConfig, names }));
1619
1625
  }
1620
1626
 
1621
1627
  if (missed.length > 0) {
@@ -1624,14 +1630,13 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1624
1630
  // batch → one missing-segments-only retry → (with a
1625
1631
  // fallback method: one batch through it for every block the
1626
1632
  // primary dropped or damaged — lib/fallback.js) → honest
1627
- // '[EN] '-prefixed source for anything still missing. A
1633
+ // the source text, unmarked, for anything still missing. A
1628
1634
  // duplicate/unknown marker or an empty first response
1629
1635
  // still fails the file whole when no fallback rescues it.
1630
1636
  const outcome = await translateBlocksWithFallback({
1631
1637
  missed,
1632
1638
  blocks,
1633
1639
  pairConfig,
1634
- fallbackPrefix: config.fallbackPrefix,
1635
1640
  budget: fallbackBudget,
1636
1641
  sharedOutputs: sharedOutputsFor(code),
1637
1642
  label: docLabel,
@@ -1639,7 +1644,6 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1639
1644
  texts,
1640
1645
  buildPrompt: (t) => buildBlockBatchPrompt(t, cfg, { ...promptOptions, pageTitle }),
1641
1646
  callModel: (prompt) => translateRawContent(prompt, { apiKey, pairConfig: cfg }),
1642
- fallbackPrefix: config.fallbackPrefix,
1643
1647
  }),
1644
1648
  fallbackOnly: new Set(missed.flatMap((r, i) => (r.fallbackOnly ? [i] : []))),
1645
1649
  });
@@ -1664,7 +1668,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1664
1668
  }
1665
1669
  if ((outcome.refused || []).length > 0) {
1666
1670
  output.warn(
1667
- `Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate — `
1671
+ `Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate, also when asked again with the reason — `
1668
1672
  + outcome.refused.slice(0, 3).map(r => `paragraph ${r.block}: ${r.reason}`).join('; ')
1669
1673
  + `${outcome.refused.length > 3 ? '; …' : ''}.`
1670
1674
  );
@@ -1680,8 +1684,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1680
1684
  : 'missing from the model response after a retry';
1681
1685
  output.warn(
1682
1686
  `Docusaurus body for ${code}: ${fellBack.length} of ${missed.length} ` +
1683
- `block(s) ${why} — written as ` +
1684
- `'${config.fallbackPrefix}'-prefixed source. ` +
1687
+ `block(s) ${why} — left in the source language, unmarked (\`champollion status\` lists them). ` +
1685
1688
  describeFallenBack({
1686
1689
  file: label, pairKey, pairConfig, fellBack: fellBack.length, newlyHeld, refusedBy: outcome.refusedBy,
1687
1690
  })
@@ -1721,7 +1724,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1721
1724
 
1722
1725
  // Content-preservation check on the REASSEMBLED body — same
1723
1726
  // lane, same reasoning as the front matter above. Skipped for a
1724
- // fallback body: it deliberately carries '[EN] '-prefixed
1727
+ // fallback body: it deliberately carries untranslated
1725
1728
  // source text and is neither cached nor lock-advanced already.
1726
1729
  const bodyHollowed = !bodyUsedFallback && checkContentPreservation(body, translatedBody);
1727
1730
  if (bodyHollowed) {
@@ -1736,7 +1739,7 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1736
1739
  // Store per-block AND whole-body entries only after the check
1737
1740
  // passes (whole-body makes reverts/lock-loss re-runs free). A
1738
1741
  // fallback body is NEVER stored whole — it contains
1739
- // untranslated '[EN] ' text.
1742
+ // untranslated source text.
1740
1743
  for (const s of pendingBlockStores) {
1741
1744
  storeTM(tm, s.source, code, s.tmKey, s.translation);
1742
1745
  }
@@ -1759,10 +1762,9 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1759
1762
  // keeps its old entry (or none), so it re-fires next sync — as
1760
1763
  // does a fallback body (its re-fire is TM-cheap: only the
1761
1764
  // fallen-back segment re-bills).
1762
- if (!bodyUsedFallback) {
1763
- updatedDocuManifest[manifestKey] = sourceHash;
1764
- manifestDirty = true;
1765
- }
1765
+ updatedDocuManifest[manifestKey] = !bodyUsedFallback && !fieldsLeftInSource
1766
+ ? sourceHash : pendingLockValue(sourceHash);
1767
+ manifestDirty = true;
1766
1768
 
1767
1769
  } catch (contentErr) {
1768
1770
  if (contentErr.heldNames) {
@@ -1774,7 +1776,6 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1774
1776
  settleRefusals();
1775
1777
  output.warn(describeHeld({
1776
1778
  file: `${dirName}/${relPath}`, code, pairKey, pairConfig, names: contentErr.heldNames, pageHeld: true,
1777
- fallbackPrefix: config.fallbackPrefix,
1778
1779
  }));
1779
1780
  completed++;
1780
1781
  output.raw(` [${completed}/${totalWork}] ${dirName}/${relPath} → ${code} [HELD]`);
package/lib/fallback.js CHANGED
@@ -27,9 +27,9 @@ import { estimateCost } from './pairs.js';
27
27
  import { lookupTM, lookupTMValidated, tmMethodKey } from './tm.js';
28
28
  import { createTMEvictor } from './tm-evict.js';
29
29
  import { translatableBlockSources } from './content-estimate.js';
30
- import { checkContentPreservation, contentGateFault, sharedOutputReason } from './validate.js';
30
+ import { checkContentPreservation, contentGateFault, sharedOutputReason, isKeepAsWrittenFault } from './validate.js';
31
31
  import { restoreBlocks, hasOrphanedPlaceholders, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX } from './content.js';
32
- import { EST_CHARS_PER_KEY } from './config.js';
32
+ import { contentKeyUnits } from './config.js';
33
33
  import { output } from './output.js';
34
34
  import { refusalCategory, notePrimaryReason } from './refusal-category.js';
35
35
 
@@ -125,8 +125,8 @@ export function createFallbackBudget({ maxCost = null, committed = 0, cwd = null
125
125
  * @param {number} chars
126
126
  * @returns {number}
127
127
  */
128
- export function charsToKeyUnits(chars) {
129
- return Math.ceil(chars / EST_CHARS_PER_KEY);
128
+ export function charsToKeyUnits(chars, pairConfig = {}) {
129
+ return contentKeyUnits(chars, pairConfig);
130
130
  }
131
131
 
132
132
  // ── Reporting ────────────────────────────────────────────────────────
@@ -546,6 +546,45 @@ export async function translateFieldsWithFallback({
546
546
  stores.push({ text: fields[field], value, tmKey: primaryKey });
547
547
  }
548
548
  remember(passing.filter(([field]) => !shared.has(field)));
549
+
550
+ // Refused: asked once more, with the reason (the key-value lane's
551
+ // feedback retry, and retryRefusedBlocks for blocks). A keep-as-written
552
+ // refusal answered the same way twice is taken at its word.
553
+ const refusedFields = [...failed].filter(([, f]) => f.reason && !f.fallbackOnly).map(([field]) => field);
554
+ if (refusedFields.length > 0) {
555
+ const descriptions = {};
556
+ for (const field of refusedFields) {
557
+ const f = failed.get(field);
558
+ descriptions[field] = `RETRY: a previous translation ("${String(f.value ?? '').slice(0, 60)}") was refused by an automatic quality check: ${f.reason}. `
559
+ + `Translate this ${field} into ${pairConfig.name || pairConfig.target}. If it is correct exactly as written — a name, title, acronym or code — return it unchanged.`;
560
+ }
561
+ const chars = refusedFields.reduce((n, f) => n + fields[f].length, 0);
562
+ const deaf = pairConfig.acceptsInstructions === false;
563
+ const verdict = deaf ? { ok: false } : budget ? await budget.approve(charsToKeyUnits(chars, pairConfig), pairConfig) : { ok: true };
564
+ // Declared deaf: not asked again; its first answer is judged as a second one.
565
+ let again = deaf ? Object.fromEntries(refusedFields.map(f => [f, failed.get(f).value])) : null;
566
+ if (verdict.ok) {
567
+ try { again = await translate(refusedFields, pairConfig, { descriptions }); } catch { again = null; }
568
+ }
569
+ const againPassing = [];
570
+ for (const field of refusedFields) {
571
+ const value = again?.[field];
572
+ if (typeof value !== 'string') continue;
573
+ const f = failed.get(field);
574
+ const fault = contentGateFault(fields[field], value, pairConfig);
575
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason) && value === f.value;
576
+ if (!fault || insisted) againPassing.push([field, value]);
577
+ else f.reason = `${f.reason}; asked again with that reason: ${fault}`;
578
+ }
579
+ const againShared = sharedCheck(againPassing);
580
+ for (const [field, value] of againPassing) {
581
+ if (againShared.has(field)) continue;
582
+ translated[field] = value;
583
+ stores.push({ text: fields[field], value, tmKey: primaryKey });
584
+ failed.delete(field);
585
+ }
586
+ remember(againPassing.filter(([field]) => !againShared.has(field)));
587
+ }
549
588
  } else if (fb) {
550
589
  for (const field of primaryAsked) failed.set(field, { reason: null });
551
590
  }
@@ -563,7 +602,7 @@ export async function translateFieldsWithFallback({
563
602
  notePrimaryReason(report, refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, heldBefore: !!f.fallbackOnly, noAnswer: !f.reason }));
564
603
  }
565
604
  const chars = asked.reduce((n, f) => n + fields[f].length, 0);
566
- const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
605
+ const verdict = budget ? await budget.approve(charsToKeyUnits(chars, fb), fb) : { ok: true };
567
606
  if (!verdict.ok) {
568
607
  report.skipped = { reason: verdict.reason, items: asked.map(f => `front matter "${f}"`) };
569
608
  } else {
@@ -659,12 +698,73 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
659
698
  return contentGateFault(restoredSource, restoredOut, pairConfig);
660
699
  }
661
700
 
701
+ /**
702
+ * Ask the pair's method once more for the blocks the gate refused, telling it
703
+ * why. WHY: a refusal used to be final for the run, and the block was left in
704
+ * the source language — while a good share of refusals are the model keeping
705
+ * text that is correct as written (a name heading, a citation, a table of
706
+ * codes), which no fixed rule can tell from a missed translation (dogfood
707
+ * 2026-10-05: 0.5% of 40,352 accepted blocks of champollion.dev). Told the
708
+ * reason, the model either translates it or returns it unchanged again — and
709
+ * a keep-as-written refusal (isKeepAsWrittenFault) answered the same way
710
+ * twice is taken at its word, as the key-value lane takes a name. Every other
711
+ * fault must clear the gate outright. One ask, never a loop; under --max-cost
712
+ * it is billed like any other call (budget.approve).
713
+ *
714
+ * @param {object} p
715
+ * @param {number[]} p.idx - Indices (into `missed`) the gate refused
716
+ * @param {string[]} p.texts - The protected block texts
717
+ * @param {Array<{source: string}>} p.missed
718
+ * @param {Map<string,string>} p.blocks - protectBlocks() map
719
+ * @param {object} p.pairConfig
720
+ * @param {Function} p.runBatch
721
+ * @param {Map<number,string>} p.firstOut - The refused (restored) answers
722
+ * @param {Map<number,string>} p.reasons - Why each was refused; a block asked
723
+ * again and refused again gets the second reason appended
724
+ * @param {object|null} [p.budget]
725
+ * @returns {Promise<Map<number,string>>} index → accepted (restored) answer
726
+ */
727
+ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
728
+ const accepted = new Map();
729
+ if (idx.length === 0) return accepted;
730
+ // An endpoint that declares it follows no instructions (a trained NMT
731
+ // model) would answer the same: not asked again — its first answers are
732
+ // judged as second answers are (the key-value lane's rule).
733
+ if (pairConfig.acceptsInstructions === false) {
734
+ for (const i of idx) if (isKeepAsWrittenFault(reasons.get(i))) accepted.set(i, firstOut.get(i));
735
+ return accepted;
736
+ }
737
+ if (budget) {
738
+ const chars = idx.reduce((n, i) => n + missed[i].source.length, 0);
739
+ const verdict = await budget.approve(charsToKeyUnits(chars, pairConfig), pairConfig);
740
+ if (!verdict.ok) return accepted;
741
+ }
742
+ const retryNotes = new Map(idx.map(i => [texts[i], reasons.get(i)]));
743
+ let res = null;
744
+ try {
745
+ res = await runBatch(idx.map(i => texts[i]), { ...pairConfig, retryNotes });
746
+ } catch {
747
+ return accepted;
748
+ }
749
+ const fell = new Set(res?.fellBack || []);
750
+ idx.forEach((i, j) => {
751
+ if (!res || fell.has(j) || typeof res.blocks[j] !== 'string') return;
752
+ const restored = restoreBlocks(res.blocks[j], blocks);
753
+ const fault = blockFault(texts[i], res.blocks[j], restored, missed[i].source, pairConfig);
754
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i)) && restored === firstOut.get(i);
755
+ if (!fault || insisted) accepted.set(i, restored);
756
+ else reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
757
+ });
758
+ return accepted;
759
+ }
760
+
662
761
  /**
663
762
  * Translate the Markdown blocks the TM ladder did not hold: one batch through
664
763
  * the pair's method, then one batch through its fallback for every block the
665
764
  * 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).
765
+ * its source text, unmarked (never cached; the caller records the refusal,
766
+ * does not advance the file's lock, and reports it). A refused block is first
767
+ * asked again once, with the reason (retryRefusedBlocks).
668
768
  *
669
769
  * Without a fallback this is exactly the lanes' old block path: the primary's
670
770
  * output stands, its dropped blocks carry the prefix, and a structural
@@ -686,16 +786,16 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
686
786
  * @param {import('./validate.js').SharedOutputIndex|null} [p.sharedOutputs] - The
687
787
  * locale's different-inputs-same-output index: a block answered with the
688
788
  * 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)
789
+ * one (the fallback gets it; without one, the source text stands)
690
790
  * @param {string} [p.label] - Names the blocks in that index ("posts/a.md")
691
791
  * @param {Set<number>} [p.fallbackOnly] - Indices (into `missed`) of blocks the
692
792
  * 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)
793
+ * only to the fallback (the source text stands when the fallback does not translate them)
694
794
  * @returns {Promise<{ outs: string[], stores: Array<{source: string, translation: string, tmKey: string}>,
695
795
  * fellBack: number[], fromFallback: number, report: FallbackReport|null, sharedOutput: number[],
696
796
  * 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] '
797
+ * source text because the gate refused them (block = place among the page's blocks, from 1);
798
+ * refusedBy[i]: the method keys whose answer for block i the gate refused, for blocks left in the source language
699
799
  * (empty for the rest) — the lane remembers them
700
800
  */
701
801
  export async function translateBlocksWithFallback({
@@ -728,7 +828,7 @@ export async function translateBlocksWithFallback({
728
828
  };
729
829
  // Why the gate refused a block (index → reason), for the lane's warning.
730
830
  const refusedReason = new Map();
731
- /** Blocks written as the '[EN] ' last resort because the gate refused them. */
831
+ /** Blocks left as their source text because the gate refused them. */
732
832
  const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
733
833
  .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
734
834
 
@@ -744,12 +844,27 @@ export async function translateBlocksWithFallback({
744
844
  if (fault) refusedReason.set(i, fault);
745
845
  }
746
846
  const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
847
+ for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
848
+ // Refused: asked once more, with the reason.
849
+ const again = await retryRefusedBlocks({
850
+ idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
851
+ firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
852
+ });
853
+ const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
854
+ for (const [i, out] of again) {
855
+ if (againShared.has(i)) continue;
856
+ outs[i] = out;
857
+ refusedReason.delete(i);
858
+ shared.delete(i);
859
+ }
860
+ // A memorized answer is reported as one (sharedOutput), not as a refusal.
861
+ for (const i of shared) refusedReason.delete(i);
747
862
  const stores = [];
748
863
  const accepted = [];
749
864
  outs.forEach((out, i) => {
750
865
  if (shared.has(i) || refusedReason.has(i)) {
751
- // Refused: the honest last resort, never cached.
752
- outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
866
+ // Refused twice: the source text stands, never cached.
867
+ outs[i] = missed[i].source;
753
868
  fellBack.push(i);
754
869
  primaryRefused.add(i);
755
870
  return;
@@ -812,6 +927,29 @@ export async function translateBlocksWithFallback({
812
927
  stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
813
928
  }
814
929
  remember(good.filter(({ i }) => !shared.has(i)));
930
+ // Refused (not dropped, not held before): asked once more, with the reason.
931
+ const firstOut = new Map();
932
+ for (const i of failed) {
933
+ if (sharedIdx.has(i)) refusedReason.set(i, 'the same text the model gave for other, different source strings');
934
+ if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
935
+ }
936
+ const again = await retryRefusedBlocks({
937
+ idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
938
+ });
939
+ const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
940
+ const recovered = new Set();
941
+ for (const [i, out] of again) {
942
+ if (againShared.has(i)) continue;
943
+ outs[i] = out;
944
+ stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
945
+ refusedReason.delete(i);
946
+ sharedIdx.delete(i);
947
+ primaryRefused.delete(i);
948
+ recovered.add(i);
949
+ }
950
+ remember([...again].filter(([i]) => recovered.has(i)).map(([i, out]) => ({ i, out })));
951
+ if (recovered.size > 0) failed.splice(0, failed.length, ...failed.filter(i => !recovered.has(i)));
952
+ for (const i of sharedIdx) refusedReason.delete(i);
815
953
  } else {
816
954
  for (let i = 0; i < texts.length; i++) failed.push(i);
817
955
  }
@@ -831,7 +969,7 @@ export async function translateBlocksWithFallback({
831
969
  if (failed.length === 0) return { outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [], refused: [], refusedBy: refusedByOf([]) };
832
970
 
833
971
  const chars = failed.reduce((n, i) => n + missed[i].source.length, 0);
834
- const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
972
+ const verdict = budget ? await budget.approve(charsToKeyUnits(chars, fb), fb) : { ok: true };
835
973
  if (!verdict.ok) {
836
974
  report.skipped = { reason: verdict.reason, items: failed.map(i => `block ${i + 1} of ${texts.length}`) };
837
975
  if (primaryError) throw primaryError;
@@ -841,7 +979,7 @@ export async function translateBlocksWithFallback({
841
979
  for (const i of failed) {
842
980
  if (sharedIdx.has(i) || primaryFell.has(i) || refusedReason.has(i) || fallbackOnly.has(i)) {
843
981
  outs[i] = sharedIdx.has(i) || refusedReason.has(i) || fallbackOnly.has(i)
844
- ? restoreBlocks(fallbackPrefix + texts[i], blocks) : restoreBlocks(primary.blocks[i], blocks);
982
+ ? missed[i].source : restoreBlocks(primary.blocks[i], blocks);
845
983
  fellBack.push(i);
846
984
  continue;
847
985
  }
@@ -893,8 +1031,8 @@ export async function translateBlocksWithFallback({
893
1031
  fromFallback++;
894
1032
  continue;
895
1033
  }
896
- // Both methods failed this block: the honest last resort.
897
- outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
1034
+ // Both methods failed this block: the source text stands.
1035
+ outs[i] = missed[i].source;
898
1036
  fellBack.push(i);
899
1037
  }
900
1038
  return {
@@ -947,7 +1085,7 @@ export async function translatePageWithFallback({ body, blocks, pairConfig, runP
947
1085
  ? refusalCategory(null, { heldBefore: true })
948
1086
  : refusalCategory(primaryBody === null ? null : 'content lost or changed', { noAnswer: primaryBody === null }));
949
1087
 
950
- const verdict = budget ? await budget.approve(charsToKeyUnits(body.length), fb) : { ok: true };
1088
+ const verdict = budget ? await budget.approve(charsToKeyUnits(body.length, fb), fb) : { ok: true };
951
1089
  if (!verdict.ok) {
952
1090
  report.skipped = { reason: verdict.reason, items: ['the page body'] };
953
1091
  return { body: null, primaryBody, tmKey: null, report, refusedBy };
@@ -65,6 +65,7 @@ import path from 'node:path';
65
65
  import { hashValue } from './hash.js';
66
66
  import { tmTranslationsOf, tmTranslationSet, tmMethodKey } from './tm.js';
67
67
  import { tmProofTextsFor } from './tm-evict.js';
68
+ import { GATE_VERSION } from './validate.js';
68
69
 
69
70
  /** Where replaced hand edits are recorded: the project root, tracked in git. */
70
71
  export const REPLACED_EDITS_FILENAME = '.champollion-replaced-edits.jsonl';
@@ -269,6 +270,8 @@ export function refusedBy(record, sourceValue, methodKey) {
269
270
  * @returns {'send'|'fallback-only'|'held'}
270
271
  */
271
272
  export function holdState(record, sourceValue, pairConfig) {
273
+ // A refusal by an earlier gate lifts by itself (validate.js GATE_VERSION).
274
+ if (!record || record.gate !== GATE_VERSION) return 'send';
272
275
  if (typeof sourceValue !== 'string' || !refusedBy(record, sourceValue, tmMethodKey(pairConfig))) return 'send';
273
276
  if (pairConfig.fallback && !refusedBy(record, sourceValue, tmMethodKey(pairConfig.fallback))) return 'fallback-only';
274
277
  return 'held';
@@ -294,6 +297,7 @@ export function recordRefusal(localeState, lockKey, sourceValue, methods, { redo
294
297
  source: src,
295
298
  methods: [...new Set([...known, ...methods])],
296
299
  on: new Date().toISOString().slice(0, 10),
300
+ gate: GATE_VERSION,
297
301
  ...(redo && { redo: true }),
298
302
  };
299
303
  }
@@ -27,7 +27,7 @@
27
27
  * set of abstract methods to provide provider-specific HTTP details.
28
28
  *
29
29
  * RUNTIME MODEL VALIDATION:
30
- * An OpenRouter-style id ("openai/gpt-5.5", or an alias for one) becomes
30
+ * An OpenRouter-style id ("openai/gpt-5.5") becomes
31
31
  * the provider's own name, or is refused when the provider has none
32
32
  * (resolveModelId — pairs.js applies it when the pair graph is built).
33
33
  * On first translate() call, the base class fetches the provider's available
@@ -46,7 +46,7 @@ import path from 'node:path';
46
46
  import { LLMMethod, buildSystemMessage, buildUserMessage, promptSettingsFor, isUnsafeKey } from './llm.js';
47
47
  import { captureRequest, isCapturing, PREVIEW_KEY } from './request-capture.js';
48
48
  import { projectGlossary } from './coaching-data.js';
49
- import { resolveModel } from '../models.js';
49
+ import { requireExactModelId } from '../models.js';
50
50
  import { getEnvOrFileVar, findEnvOrFileVar } from '../api-key.js';
51
51
  import {
52
52
  MAX_RETRIES,
@@ -330,8 +330,10 @@ class DirectLLMMethod extends LLMMethod {
330
330
 
331
331
  /**
332
332
  * The model id this provider is sent, for a configured model:
333
- * - an alias from shared/model-aliases.json resolves first
334
- * ("gpt" → "openai/gpt-5.5");
333
+ * - a retired alias ("gpt") or a floating id ("…-latest", "~vendor/…")
334
+ * is REFUSED first, on every transport (gateway and local included),
335
+ * naming the exact slug to write — founder ruling 2026-10-05: exact
336
+ * slugs only, no aliasing (lib/models.js requireExactModelId);
335
337
  * - an OpenRouter-style id of THIS provider's vendor becomes its own
336
338
  * name ("openai/gpt-5.5" → "gpt-5.5", "anthropic/claude-haiku-4.5" →
337
339
  * "claude-haiku-4-5") — mirroring the harness's direct providers;
@@ -345,12 +347,15 @@ class DirectLLMMethod extends LLMMethod {
345
347
  * ("from --model", "from the top-level \"model\""); cwd: the project
346
348
  * directory (its .env may point the method at a gateway)
347
349
  * @returns {string|null}
348
- * @throws {Error} code CHAMPOLLION_MODEL_ROUTE when there is no such model here
350
+ * @throws {Error} code CHAMPOLLION_MODEL_ROUTE when there is no such model here,
351
+ * code CHAMPOLLION_MODEL_ID for a retired alias or a floating id
349
352
  */
350
353
  resolveModelId(model, { from = null, cwd = null } = {}) {
351
- if (!model || typeof model !== 'string' || !this._callsOwnApi(cwd)) return model;
354
+ if (!model || typeof model !== 'string') return model;
355
+ requireExactModelId(model, { from });
356
+ if (!this._callsOwnApi(cwd)) return model;
352
357
  const vendor = this._getModelVendor();
353
- const resolved = resolveModel(model);
358
+ const resolved = model;
354
359
  const slash = resolved.indexOf('/');
355
360
  if (slash < 0) return this._nativeModelName(resolved);
356
361
  const idVendor = resolved.slice(0, slash);
@@ -358,7 +363,7 @@ class DirectLLMMethod extends LLMMethod {
358
363
  const plainName = /^[A-Za-z0-9._-]+$/.test(name);
359
364
  if (idVendor === vendor && plainName) return this._nativeModelName(name);
360
365
 
361
- const notes = [resolved === model ? null : `alias of ${resolved}`, from].filter(Boolean);
366
+ const notes = [from].filter(Boolean);
362
367
  const what = `"${model}"${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`;
363
368
  const servedBy = Object.entries(DIRECT_MODEL_VENDORS).find(([, v]) => v === idVendor)?.[0] || null;
364
369
  const label = this._getProviderLabel();
@@ -18,7 +18,7 @@ import { estimateLlmCost } from './provider-pricing.js';
18
18
 
19
19
  // The default model, named ONCE — it was previously written twice (here and
20
20
  // as an inline fallback in estimateCost()) and the two could drift.
21
- const DEFAULT_MODEL = 'gemini-2.5-flash';
21
+ const DEFAULT_MODEL = 'gemini-3.8-flash';
22
22
 
23
23
  class GeminiMethod extends DirectLLMMethod {
24
24
  constructor(options = {}) {
@@ -66,6 +66,7 @@ import { LLMMethod, inferKeyTypes, isUnsafeKey, buildSystemMessage, buildUserMes
66
66
  import { DEFAULT_COACHING_DIR, loadCoachingData, findDictionaryMatches, projectGlossary } from './coaching-data.js';
67
67
  import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_COACHED_TEMPERATURE, DEFAULT_MAX_RETRIES, DEFAULT_METHOD_CONCURRENCY } from '../config.js';
68
68
  import { pMap } from '../concurrent.js';
69
+ import { requireExactModelId } from '../models.js';
69
70
  import { output } from '../output.js';
70
71
 
71
72
  /**
@@ -256,7 +257,8 @@ class LLMCoachedMethod extends TranslationMethod {
256
257
  output.warn('LLM-Coached translate: no API key provided — skipping batch.');
257
258
  return null;
258
259
  }
259
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
260
+ // Exact slugs only (founder ruling 2026-10-05): refused before anything is sent.
261
+ const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
260
262
  const llm = new LLMMethod();
261
263
  const batchFn = (batch, opts) => this._callCoachedBatch(batch, glossary, opts, pairConfig);
262
264
  return this._runCoachedBatches(llm, keys, sourceFlat, langConfig, {
@@ -44,6 +44,7 @@ import { isUnsafeKey } from '../security.js';
44
44
  import { pMap } from '../concurrent.js';
45
45
  import { output } from '../output.js';
46
46
  import { getEnvOrFileVar } from '../api-key.js';
47
+ import { requireExactModelId } from '../models.js';
47
48
  import { nameRules } from '../name-rules.js';
48
49
  import { isCapturing, PREVIEW_KEY } from './request-capture.js';
49
50
  import { projectGlossary, terminologyBlock } from './coaching-data.js';
@@ -97,7 +98,9 @@ class LLMMethod extends TranslationMethod {
97
98
  // A request preview needs no key: it is shown, never sent.
98
99
  const apiKey = options.apiKey || (isCapturing() ? PREVIEW_KEY : null);
99
100
  const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
100
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
101
+ // Exact slugs only (founder ruling 2026-10-05) — checked here too, since
102
+ // callers outside the pair graph (the MCP translate tool) reach this directly.
103
+ const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
101
104
  const maxRetries = pairConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
102
105
  if (!apiKey) {
103
106
  output.warn('LLM translate: no API key provided — skipping batch.');
@@ -181,7 +184,9 @@ class LLMMethod extends TranslationMethod {
181
184
  */
182
185
  async translateContent(prompt, pairConfig, options) {
183
186
  const { apiKey } = options;
184
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
187
+ // Exact slugs only (founder ruling 2026-10-05) — checked here too, since
188
+ // callers outside the pair graph (the MCP translate tool) reach this directly.
189
+ const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
185
190
  if (!apiKey) {
186
191
  output.warn('LLM translateContent: no API key provided — skipping.');
187
192
  return null;