champollion 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/validate.js CHANGED
@@ -451,8 +451,11 @@ function isProtectedTermValue(value, protectedTerms = []) {
451
451
  * until someone names it for a redo. Bump it whenever a check is loosened.
452
452
  * 2 — 2026-10-05: names with citations/anchors, reference entries, tables,
453
453
  * short titles and source-shown fullwidth letters stopped being refused.
454
+ * 3 — 2026-10-06: inline code is not prose (an ICU sample in backticks no
455
+ * longer makes a translated paragraph "ASCII-only"); a phrase said
456
+ * twice is not a loop; "keep it" twice is accepted give or take a stop.
454
457
  */
455
- const GATE_VERSION = 2;
458
+ const GATE_VERSION = 3;
456
459
 
457
460
  /**
458
461
  * Refusals that may be the model keeping text that is correct as written —
@@ -519,7 +522,7 @@ function isBibliographicEntry(text) {
519
522
  const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
520
523
  return lines.length > 1 && lines.every(l => !l.includes('\n') && isBibliographicEntry(l));
521
524
  }
522
- return /^(?:\[?[A-Z]?\d{1,3}\]?[.)]?|[-*+])\s+\S/.test(t)
525
+ return /^(?:\[?[A-Z]?\d{1,3}[a-z]?\]?[.)]?|[-*+])\s+\S/.test(t)
523
526
  && (/\(\d{4}[a-z]?\)/.test(t) || /\]\(https?:\/\//.test(t))
524
527
  && (/"[^"]{8,}"|“[^”]{8,}”/.test(t) || /\*[^*]{8,}\*/.test(t));
525
528
  }
@@ -570,6 +573,43 @@ function contentGateFault(source, value, pairConfig = {}) {
570
573
  return failures.length > 0 ? failures[0].reason : null;
571
574
  }
572
575
 
576
+ /**
577
+ * Does `translated` show the shape of a degeneration loop, beyond a high
578
+ * repeated-n-gram rate? A phrase a language says twice where English
579
+ * elides it is not a loop: "From most to least trustworthy:" is correctly
580
+ * "Từ đáng tin cậy nhất đến ít đáng tin cậy nhất:" in Vietnamese, and was
581
+ * refused as a repetition hallucination (dogfood 2026-10-06). A loop does
582
+ * one of three things: repeats some span three times or more, repeats a whole
583
+ * line, or hands the source back verbatim beside a translation of it (the
584
+ * doubled Arabic headings, "### METEOR (…)\n### METEOR (…)").
585
+ *
586
+ * @param {string} translated
587
+ * @param {string} source
588
+ * @returns {boolean}
589
+ */
590
+ function hasLoopEvidence(translated, source) {
591
+ const t = String(translated);
592
+ const grams = new Map();
593
+ for (let i = 0; i + REPETITION_LONG_N <= t.length; i++) {
594
+ const g = t.slice(i, i + REPETITION_LONG_N);
595
+ const n = (grams.get(g) || 0) + 1;
596
+ if (n >= 3 && g.trim().length >= 4) return true;
597
+ grams.set(g, n);
598
+ }
599
+ // A word said three times or more ("Qo' Qo' Qo' Qo'" — too short for an
600
+ // 8-character span to recur three times).
601
+ const words = new Map();
602
+ for (const w of t.toLowerCase().split(/\s+/u).filter(x => /\p{L}/u.test(x))) {
603
+ const n = (words.get(w) || 0) + 1;
604
+ if (n >= 3) return true;
605
+ words.set(w, n);
606
+ }
607
+ const lines = t.split('\n').map(l => l.trim()).filter(l => l.length >= 8);
608
+ if (new Set(lines).size < lines.length) return true;
609
+ const src = String(source).trim();
610
+ return src.length >= 8 && t.trim() !== src && t.includes(src);
611
+ }
612
+
573
613
  /**
574
614
  * Validate a batch of translations and return only passing keys.
575
615
  *
@@ -775,7 +815,7 @@ function validateTranslations(translations, sourceFlat, pairConfig, options = {}
775
815
  longGramRate: measureRepetition(seg, REPETITION_LONG_N),
776
816
  }))
777
817
  .find(m => m.trigramRate > trigramCap && m.longGramRate > longGramCap);
778
- if (degenerateSegment) {
818
+ if (degenerateSegment && hasLoopEvidence(translated, source)) {
779
819
  failures.push({
780
820
  key,
781
821
  reason: `repetition hallucination (${(degenerateSegment.trigramRate * 100).toFixed(0)}% repeated trigrams, ${(degenerateSegment.longGramRate * 100).toFixed(0)}% repeated ${REPETITION_LONG_N}-grams)`,
@@ -1300,12 +1340,30 @@ const FULLWIDTH_LATIN = /[A-Za-z]/u;
1300
1340
  * URL value is a wrong-script answer unless the key is declared
1301
1341
  * no-translate — which never reaches this check.)
1302
1342
  */
1343
+ /** `text` with every balanced {…} group removed (nested groups included). */
1344
+ function withoutBraceGroups(text) {
1345
+ let out = '';
1346
+ let depth = 0;
1347
+ for (const ch of text) {
1348
+ if (ch === '{') { depth++; continue; }
1349
+ if (ch === '}' && depth > 0) { depth--; continue; }
1350
+ if (depth === 0) out += ch;
1351
+ }
1352
+ return out;
1353
+ }
1354
+
1303
1355
  function proseOf(text) {
1356
+ // Inline code is never prose: a Markdown paragraph quoting an ICU message
1357
+ // in backticks (`{n, plural, one {One file} other {…}}`) was read as that
1358
+ // message, its English branches were taken for the whole value, and 470
1359
+ // characters of correct Japanese were refused as "ASCII-only" — twice, in
1360
+ // five languages (dogfood 2026-10-06, docs/getting-started/configuration.md).
1361
+ const str = String(text).replace(/(`+)[^`]*?\1/g, ' ');
1304
1362
  // A plural/select message: its prose is the text of its branches (the
1305
- // keywords and selectors — "plural", "one", "other" — are code).
1306
- const str = String(text);
1363
+ // keywords and selectors — "plural", "one", "other" — are code) PLUS the
1364
+ // text around it ("You have {count, plural, …}" — "You have" is prose).
1307
1365
  const branches = str.includes('{') ? pluralBranchTexts(str) : null;
1308
- return (branches ? branches.join(' ') : str)
1366
+ return (branches ? `${branches.join(' ')} ${withoutBraceGroups(str)}` : str)
1309
1367
  // Simple placeholders only: {name}, {count, number}. Branch text such as
1310
1368
  // "{Один файл}" is prose, never stripped.
1311
1369
  .replace(/\{\s*[\w.$-]+\s*(?:,[^{}]*)?\}/g, ' ')
@@ -1467,6 +1525,7 @@ export {
1467
1525
  nameCore,
1468
1526
  GATE_VERSION,
1469
1527
  isKeepAsWrittenFault,
1528
+ hasLoopEvidence,
1470
1529
  isBibliographicEntry,
1471
1530
  tableProse,
1472
1531
  SHORT_SOURCE_LENGTH_SLACK,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "champollion",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Research-grade translation engine for i18n projects. Pluggable methods, per-pair quality tiers, and deterministic script converters. Supports JSON (next-intl, i18next), TOML, and YAML (Hugo).",
5
5
  "type": "module",
6
6
  "bin": {