{"version":3,"file":"alignBlocks.mjs","names":[],"sources":["../../../src/docReview/alignBlocks.ts"],"sourcesContent":["import { computeJaccardSimilarity } from './computeSimilarity';\nimport type { AlignmentPair, FingerprintedBlock } from './types';\n\n/** Cost of leaving a block unaligned (an insertion or a deletion). */\nconst GAP_PENALTY = -2;\n\n/**\n * Score of a pair that structural evidence rules out.\n *\n * Strictly below the cost of two gaps (`2 * GAP_PENALTY`) so the aligner always\n * prefers reporting an insertion plus a deletion over such a pair. Every other\n * score is positive, which means that without an explicit veto the aligner would\n * rather pair two unrelated blocks than leave them unaligned — and a bogus pair\n * is planned as `reuse`, which keeps the stale translation verbatim *and*\n * inserts the freshly translated base block next to it, producing the duplicated\n * headings this penalty exists to prevent.\n */\nconst STRUCTURAL_MISMATCH_PENALTY = GAP_PENALTY * 2 - 1;\n\n/** Reward for two blocks opened by a heading of the very same depth. */\nconst HEADING_DEPTH_MATCH_BONUS = 3;\n\n/** Minimum length ratio for the (small) \"comparable size\" reward. */\nconst COMPARABLE_LENGTH_RATIO = 0.75;\n\n/**\n * Body length from which a section is considered to carry content of its own.\n *\n * Kept above a single sentence so a translator's short lead-in paragraph is not\n * mistaken for a whole section body.\n */\nconst SUBSTANTIAL_BODY_LENGTH = 80;\n\n/**\n * Length of the body a block carries below its opening heading.\n *\n * A section that only holds its heading (because subsections carry all of its\n * content) is structurally different from one that holds a full body, and that\n * difference survives translation.\n *\n * @param block - The block to measure.\n * @returns The trimmed length of everything below the opening heading line.\n */\nconst measureBodyLength = (block: FingerprintedBlock): number => {\n  if (block.headingDepth === null) return block.content.trim().length;\n\n  const [, ...bodyLines] = block.content.split('\\n');\n\n  return bodyLines.join('\\n').trim().length;\n};\n\n/**\n * Align the blocks of a base document with the blocks of its translation using a\n * Needleman–Wunsch global alignment over heading depth, anchor similarity and\n * block type.\n *\n * Because prose differs across languages, the score is weighted toward the\n * structural signals — heading depth first, then the anchor (digits and symbols)\n * — rather than the words themselves.\n *\n * @param baseBlocks - Blocks of the base (source) document.\n * @param targetBlocks - Blocks of the target (translated) document.\n * @returns The ordered list of alignment pairs, including insertions and deletions.\n */\nexport const alignBaseAndTargetBlocks = (\n  baseBlocks: FingerprintedBlock[],\n  targetBlocks: FingerprintedBlock[]\n): AlignmentPair[] => {\n  const baseLength = baseBlocks.length;\n  const targetLength = targetBlocks.length;\n\n  const scoreMatrix: number[][] = Array.from({ length: baseLength + 1 }, () =>\n    Array.from({ length: targetLength + 1 }, () => 0)\n  );\n  const traceMatrix: ('diagonal' | 'up' | 'left')[][] = Array.from(\n    { length: baseLength + 1 },\n    () => Array.from({ length: targetLength + 1 }, () => 'diagonal')\n  );\n\n  const computeMatchScore = (\n    baseIndex: number,\n    targetIndex: number\n  ): number => {\n    const baseBlock = baseBlocks[baseIndex]!;\n    const targetBlock = targetBlocks[targetIndex]!;\n\n    // Translating a document never changes its heading depths, so two headings\n    // of different depths cannot be counterparts, however similar their content.\n    const hasComparableHeadingDepths =\n      baseBlock.headingDepth !== null && targetBlock.headingDepth !== null;\n\n    if (\n      hasComparableHeadingDepths &&\n      baseBlock.headingDepth !== targetBlock.headingDepth\n    ) {\n      return STRUCTURAL_MISMATCH_PENALTY;\n    }\n\n    // A section reduced to its bare heading (its content living in subsections)\n    // is not the translation of a section holding a full body. Pairing them\n    // reuses that whole body while the base heading is translated again right\n    // next to it — which is exactly how a duplicated heading appears.\n    const baseBodyLength = measureBodyLength(baseBlock);\n    const targetBodyLength = measureBodyLength(targetBlock);\n    const isBodyPresenceMismatched =\n      (baseBodyLength === 0 && targetBodyLength >= SUBSTANTIAL_BODY_LENGTH) ||\n      (targetBodyLength === 0 && baseBodyLength >= SUBSTANTIAL_BODY_LENGTH);\n\n    if (isBodyPresenceMismatched) return STRUCTURAL_MISMATCH_PENALTY;\n\n    const lengthRatio =\n      Math.min(baseBlock.content.length, targetBlock.content.length) /\n      Math.max(baseBlock.content.length, targetBlock.content.length);\n\n    const headingDepthBonus = hasComparableHeadingDepths\n      ? HEADING_DEPTH_MATCH_BONUS\n      : 0;\n    const typeBonus = baseBlock.type === targetBlock.type ? 2 : 0;\n    const anchorSimilarity = computeJaccardSimilarity(\n      baseBlock.anchorText,\n      targetBlock.anchorText,\n      3\n    );\n    const lengthBonus = lengthRatio > COMPARABLE_LENGTH_RATIO ? 1 : 0;\n\n    // weighted toward the structural signals (heading depth, then anchor)\n    return headingDepthBonus + typeBonus + lengthBonus + anchorSimilarity * 8;\n  };\n\n  // initialize first row and column\n  for (let i = 1; i <= baseLength; i += 1) {\n    scoreMatrix[i][0] = scoreMatrix[i - 1][0] + GAP_PENALTY;\n    traceMatrix[i][0] = 'up';\n  }\n  for (let j = 1; j <= targetLength; j += 1) {\n    scoreMatrix[0][j] = scoreMatrix[0][j - 1] + GAP_PENALTY;\n    traceMatrix[0][j] = 'left';\n  }\n\n  // fill\n  for (let i = 1; i <= baseLength; i += 1) {\n    for (let j = 1; j <= targetLength; j += 1) {\n      const match = scoreMatrix[i - 1][j - 1] + computeMatchScore(i - 1, j - 1);\n      const deleteGap = scoreMatrix[i - 1][j] + GAP_PENALTY;\n      const insertGap = scoreMatrix[i][j - 1] + GAP_PENALTY;\n\n      const best = Math.max(match, deleteGap, insertGap);\n      scoreMatrix[i][j] = best;\n      traceMatrix[i][j] =\n        best === match ? 'diagonal' : best === deleteGap ? 'up' : 'left';\n    }\n  }\n\n  // traceback\n  const result: AlignmentPair[] = [];\n  let i = baseLength;\n  let j = targetLength;\n  while (i > 0 || j > 0) {\n    if (i > 0 && j > 0 && traceMatrix[i][j] === 'diagonal') {\n      const baseIndex = i - 1;\n      const targetIndex = j - 1;\n      const similarityScore = computeJaccardSimilarity(\n        baseBlocks[baseIndex].anchorText,\n        targetBlocks[targetIndex].anchorText,\n        3\n      );\n      result.unshift({ baseIndex, targetIndex, similarityScore });\n      i -= 1;\n      j -= 1;\n    } else if (i > 0 && (j === 0 || traceMatrix[i][j] === 'up')) {\n      result.unshift({\n        baseIndex: i - 1,\n        targetIndex: null,\n        similarityScore: 0,\n      });\n      i -= 1;\n    } else if (j > 0 && (i === 0 || traceMatrix[i][j] === 'left')) {\n      // target block has no corresponding base block (deleted)\n      result.unshift({\n        baseIndex: -1,\n        targetIndex: j - 1,\n        similarityScore: 0,\n      });\n      j -= 1;\n    }\n  }\n  return result;\n};\n"],"mappings":"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"}