Spaces:
Running
Running
| // engine.ts — Russian poetry scansion engine. | |
| // Faithful port of the Russian Poetry Scansion Tool's core algorithm: | |
| // 1. Parse with UDPipe (Russian SynTagRus) | |
| // 2. Build PoetryWord objects with accentuations from dictionary + neural MLP | |
| // 3. Generate stress variants per word (stressed, unstressed, shifted) | |
| // 4. Map each line's stress variants against meter signatures | |
| // 5. Find the best (meter, stress-assignment) combination across all lines | |
| // 6. Detect rhymes from the aligned clausulas | |
| // 7. Compute technicality score from penalties | |
| import { parseRussianText, isRuPunctuation } from './parser.js'; | |
| import { splitWord, countVowels } from './syllabifier.js'; | |
| import { getAccent, getAccents, yoficate, getAccentsData, loadJson } from './accentuator.js'; | |
| import { extractClausula, pronounce } from './rhyme.js'; | |
| import { buildFabbHalleGrid } from '../fabbhalle.js'; | |
| import { getCompoundSecondaryStress, applyVerbPrefixDerivation } from './compounds.js'; | |
| import { checkCollocation } from './collocations.js'; | |
| import type { | |
| RuToken, RuWord, RuSyllable, RuLine, RuStanza, RuDep, | |
| RuScansionResult, RuMeterResult, RuRhymeEntry, | |
| } from './types.js'; | |
| import { CONTENT_UPOS, PUNCT_UPOS, RU_VOWELS, METER_NAMES_RU } from './types.js'; | |
| // ── Constants from the original ────────────────────────────────────── | |
| const COEFF: Record<string, number> = { | |
| '@68': 0.95, '@68_2': 0.98, '@71': 1.0, '@75': 0.98, | |
| '@77': 1.0, '@77_2': 1.0, '@79': 1.0, '@126': 0.98, | |
| '@225': 0.95, '@143': 0.9, | |
| }; | |
| const METERS: [string, number[]][] = [ | |
| ['ямб', [0, 1]], | |
| ['хорей', [1, 0]], | |
| ['дактиль', [1, 0, 0]], | |
| ['амфибрахий', [0, 1, 0]], | |
| ['анапест', [0, 0, 1]], | |
| ]; | |
| const METER_CANONIC: Record<string, string> = { | |
| 'ямб': 'iambic', 'хорей': 'trochaic', 'дактиль': 'dactylic', | |
| 'амфибрахий': 'amphibrachic', 'анапест': 'anapestic', | |
| 'dolnik': 'dolnik', 'free': 'free', | |
| }; | |
| const EARLY_STOPPING = 0.7; | |
| const MAX_WORDS_PER_LINE = 14; | |
| // ── Word-level types ───────────────────────────────────────────────── | |
| interface WordAccentuation { | |
| stressPos: number; // 1-based vowel position | |
| secondaryStress: number[] | null; | |
| } | |
| interface PoetryWord { | |
| lemma: string; | |
| form: string; | |
| upos: string; | |
| tags: string[]; // e.g. ['Case=Nom', 'Gender=Masc'] | |
| feats: Record<string, string>; | |
| deprel: string; | |
| head: number; // head token id within the sentence (0 = root) | |
| id: number; // token id within its sentence | |
| sent: number; // sentence index within the line's parse | |
| accentuations: WordAccentuation[]; | |
| stressPos: number; // primary stress (first accentuation) | |
| isRhymingWord: boolean; | |
| nVowels: number; | |
| leadingConsonants: number; | |
| trailingConsonants: number; | |
| } | |
| interface WordStressVariant { | |
| poetryWord: PoetryWord; | |
| newStressPos: number; // -1 = unstressed | |
| score: number; | |
| stressSignature: number[]; // per-vowel: 0=unstressed, 1=primary, 2=secondary | |
| stressedForm: string; | |
| isCyrillic: boolean; | |
| } | |
| // ── Word construction ──────────────────────────────────────────────── | |
| function buildPoetryWord(token: RuToken): PoetryWord { | |
| // Apply ёфикация first — this may change 'е' to 'ё' which affects dictionary lookup | |
| const form = yoficate(token.form, token.feats, token.upos); | |
| const lower = form.toLowerCase(); | |
| const upos = token.upos; | |
| const tags = Object.entries(token.feats).map(([k, v]) => `${k}=${v}`); | |
| // Get accentuations | |
| const accentuations: WordAccentuation[] = []; | |
| const secondaryStress = getCompoundSecondaryStress(lower); | |
| // Check with getAccents (which now handles ё, single-vowel, dict, verb prefix, and multiple forms) | |
| const accents = getAccents(lower, token.feats); | |
| for (const acc of accents) { | |
| if (acc.stressPos > 0 || acc.stressPos === -1) { | |
| accentuations.push({ stressPos: acc.stressPos, secondaryStress }); | |
| } | |
| } | |
| // Count vowels and consonants | |
| let nVowels = 0, leadingConsonants = 0, trailingConsonants = 0; | |
| for (const c of form) { | |
| const cl = c.toLowerCase(); | |
| if ('уеыаоэёяию'.includes(cl)) { | |
| trailingConsonants = 0; | |
| nVowels++; | |
| } else if ('бвгджзклмнпрстфхцчшщ'.includes(cl)) { | |
| if (nVowels === 0) leadingConsonants++; | |
| else trailingConsonants++; | |
| } | |
| } | |
| return { | |
| lemma: token.lemma, | |
| form, | |
| upos, | |
| tags, | |
| feats: token.feats, | |
| deprel: token.deprel, | |
| head: token.head, | |
| id: token.id, | |
| sent: token.sent, | |
| accentuations, | |
| stressPos: accentuations[0]?.stressPos ?? -1, | |
| isRhymingWord: false, | |
| nVowels, | |
| leadingConsonants, | |
| trailingConsonants, | |
| }; | |
| } | |
| function locateStressPos(form: string): number { | |
| let nVowels = 0; | |
| for (const c of form) { | |
| if ('уеыаоэёяию'.includes(c.toLowerCase())) nVowels++; | |
| if ('АЕЁИОУЫЭЮЯ'.includes(c)) return nVowels; | |
| } | |
| return -1; | |
| } | |
| // ── Stress variant generation ──────────────────────────────────────── | |
| function makeSet(s: string): Record<string, true> { | |
| const out: Record<string, true> = {}; | |
| for (const c of s) out[c] = true; | |
| return out; | |
| } | |
| const CYRILLIC_SET = makeSet('абвгдеёжзийклмнопрстфхцчшщъыьэюя'); | |
| function buildStressVariant(pw: PoetryWord, newStressPos: number, score: number): WordStressVariant { | |
| const secondaryStress = pw.accentuations[0]?.secondaryStress ?? null; | |
| const stressSignature: number[] = []; | |
| const output: string[] = []; | |
| let nVowels = 0; | |
| for (const c of pw.form) { | |
| output.push(c); | |
| if ('уеыаоэёяию'.includes(c.toLowerCase())) { | |
| nVowels++; | |
| if (nVowels === newStressPos) { | |
| output.push('\u0301'); | |
| stressSignature.push(1); | |
| } else if (secondaryStress && secondaryStress[nVowels - 1] === 2) { | |
| output.push('\u0300'); | |
| stressSignature.push(2); | |
| } else { | |
| stressSignature.push(0); | |
| } | |
| } | |
| } | |
| return { | |
| poetryWord: pw, | |
| newStressPos, | |
| score, | |
| stressSignature, | |
| stressedForm: output.join(''), | |
| isCyrillic: !!CYRILLIC_SET[pw.form[0]?.toLowerCase()], | |
| }; | |
| } | |
| /** Get all stress variants for a word — the core of the alignment algorithm. */ | |
| function getStressVariants(pw: PoetryWord, allowStressShift: boolean, allowUnstress12: boolean): WordStressVariant[] { | |
| const variants: WordStressVariant[] = []; | |
| const nvowels = pw.nVowels; | |
| const uform = pw.form.toLowerCase(); | |
| const data = getAccentsData(); | |
| if (nvowels === 0) { | |
| // No vowels — always unstressed | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| return variants; | |
| } | |
| // Function words: ADP, CCONJ, SCONJ, PART, INTJ, AUX | |
| if (pw.upos === 'ADP' || pw.upos === 'CCONJ' || pw.upos === 'SCONJ' || pw.upos === 'PART' || pw.upos === 'INTJ' || pw.upos === 'AUX') { | |
| if (uform === 'не') { | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| if (pw.isRhymingWord) variants.push(buildStressVariant(pw, pw.stressPos, 0.95)); | |
| else variants.push(buildStressVariant(pw, pw.stressPos, 0.20)); | |
| } else if (['бы', 'ли', 'же', 'ни', 'ка'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| if (pw.isRhymingWord) variants.push(buildStressVariant(pw, pw.stressPos, COEFF['@68'])); | |
| } else if (['а', 'и', 'или', 'но'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| if (pw.isRhymingWord) variants.push(buildStressVariant(pw, pw.stressPos, 0.70)); | |
| else variants.push(buildStressVariant(pw, pw.stressPos, 0.20)); | |
| } else if (['о', 'у', 'из', 'от', 'под', 'подо', 'за', 'при', 'до', 'про', 'для', 'ко', 'со', 'во', 'на', 'по', 'об', 'обо', 'без', 'над', 'пред'].includes(uform) && pw.upos === 'ADP') { | |
| // These prepositions are never stressed | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| if (pw.isRhymingWord) variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| else variants.push(buildStressVariant(pw, pw.stressPos, 0.5)); | |
| } else if (['нибудь'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| if (pw.isRhymingWord) variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| else variants.push(buildStressVariant(pw, pw.stressPos, 0.5)); | |
| } else if (['нет'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, COEFF['@68_2'])); | |
| variants.push(buildStressVariant(pw, -1, COEFF['@71'])); | |
| } else if (countVowels(uform) < 3) { | |
| // Short function words — prefer unstressed | |
| variants.push(buildStressVariant(pw, -1, COEFF['@71'])); | |
| if (['лишь', 'вроде', 'если', 'чтобы', 'когда', 'просто', 'мимо', 'даже', 'всё', 'хотя', 'едва', 'нет', 'пока'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, COEFF['@68_2'])); | |
| } else if (['был', 'будь', 'будем'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| } else { | |
| variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| } | |
| } else { | |
| // Long function words (3+ vowels) — usually stressed | |
| if (pw.stressPos > 0) variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| else variants.push(buildStressVariant(pw, -1, 1.0)); | |
| } | |
| } else if (pw.upos === 'PRON' || pw.upos === 'ADV' || pw.upos === 'DET') { | |
| if (uform !== 'что') { | |
| if (nvowels === 1) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| } else { | |
| if (pw.stressPos > 0) variants.push(buildStressVariant(pw, pw.stressPos, COEFF['@79'])); | |
| const unstressable = ['эти', 'эта', 'этот', 'эту', 'это', 'мои', 'твои', 'моих', 'твоих', 'моим', 'твоим', 'моей', 'твоей', 'мою', 'твою', 'его', 'ему', 'нему', 'ее', 'её', 'себе', 'меня', 'тебя', 'свою', 'свои', 'своим', 'они', 'она', 'уже', 'этом', 'тебе']; | |
| if (unstressable.includes(uform)) { | |
| variants.push(buildStressVariant(pw, -1, COEFF['@77_2'])); | |
| } else if (nvowels === 2 && allowUnstress12) { | |
| variants.push(buildStressVariant(pw, -1, 0.8)); | |
| } | |
| } | |
| } else { | |
| // "что" — treated as content word, allows unstress with allow_unstress12 | |
| if (pw.stressPos > 0) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| if (nvowels === 1 && allowUnstress12) { | |
| variants.push(buildStressVariant(pw, -1, 0.8)); | |
| } else if (nvowels === 2 && allowUnstress12) { | |
| variants.push(buildStressVariant(pw, -1, 0.8)); | |
| } | |
| } else { | |
| variants.push(buildStressVariant(pw, -1, 1.0)); | |
| } | |
| } | |
| } else { | |
| // Content words (NOUN, VERB, ADJ, etc.) | |
| if (pw.accentuations.length > 1) { | |
| // Multiple accentuations — try all | |
| for (const acc of pw.accentuations) { | |
| variants.push(buildStressVariant(pw, acc.stressPos, 1.0)); | |
| } | |
| } else if (pw.accentuations.length === 1) { | |
| variants.push(buildStressVariant(pw, pw.accentuations[0].stressPos, 1.0)); | |
| // Add unstressed variant for short words | |
| if (['есть', 'раз', 'быть', 'будь', 'был'].includes(uform)) { | |
| variants.push(buildStressVariant(pw, -1, COEFF['@143'])); | |
| } else if (nvowels === 1 && (pw.upos === 'NOUN' || pw.upos === 'NUM' || pw.upos === 'ADJ' || pw.upos === 'VERB') && allowUnstress12) { | |
| variants.push(buildStressVariant(pw, -1, 0.7)); | |
| } else if (nvowels === 2 && allowUnstress12) { | |
| variants.push(buildStressVariant(pw, -1, 0.8)); | |
| } | |
| } else { | |
| // No accentuations — OOV | |
| if (pw.stressPos > 0) { | |
| variants.push(buildStressVariant(pw, pw.stressPos, 1.0)); | |
| } else { | |
| // Try neural prediction | |
| const accent = getAccent(uform, pw.feats); | |
| if (accent.stressPos > 0) { | |
| variants.push(buildStressVariant(pw, accent.stressPos, 1.0)); | |
| } | |
| // For OOV with multiple vowels, try all positions | |
| if (nvowels > 1) { | |
| let vc = 0; | |
| for (let i = 0; i < uform.length; i++) { | |
| if ('уеыаоэёяию'.includes(uform[i])) { | |
| vc++; | |
| let proba = 0.90; | |
| if (countVowels(uform.slice(0, i)) > 2 || countVowels(uform.slice(i + 1)) > 2) proba *= 0.5; | |
| variants.push(buildStressVariant(pw, vc, proba)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Stress shift: try alternative stress positions from ambiguous_accents | |
| if (allowStressShift && countVowels(uform) > 1 && data.ambiguousAccents[uform] && !data.ambiguousAccents2[uform]) { | |
| for (const stressedForm of Object.keys(data.ambiguousAccents[uform])) { | |
| const sp = locateStressPos(stressedForm); | |
| if (sp > 0 && !variants.some(v => v.newStressPos === sp)) { | |
| variants.push(buildStressVariant(pw, sp, 0.99)); | |
| } | |
| } | |
| } | |
| return variants; | |
| } | |
| // ── Meter mapping (the recursive tree search) ──────────────────────── | |
| interface WordMappingResult { | |
| word: WordStressVariant; | |
| TP: number; FP: number; TN: number; FN: number; | |
| syllabicMapping: string[]; | |
| stressShift: boolean; | |
| metreScore: number; | |
| totalScore: number; | |
| } | |
| interface MetreMappingResult { | |
| prefix: number; | |
| metreSignature: number[]; | |
| score: number; | |
| wordMappings: WordMappingResult[]; | |
| stressShiftCount: number; | |
| cursor: number; | |
| } | |
| function buildWordMapping(word: WordStressVariant, metreSigns: number[], startCursor: number, prevLastStressed: boolean, prefix: number): { mapping: WordMappingResult; newCursor: number } { | |
| let TP = 0, FP = 0, TN = 0, FN = 0; | |
| const syllabicMapping: string[] = []; | |
| let cursor = startCursor; | |
| const sigLen = metreSigns.length; | |
| for (const wordSign of word.stressSignature) { | |
| let metreSign = 0; | |
| if (prefix > 0) { | |
| if (cursor < prefix) metreSign = 0; | |
| else metreSign = metreSigns[(cursor - prefix) % sigLen]; | |
| } else { | |
| metreSign = metreSigns[cursor % sigLen]; | |
| } | |
| if (metreSign === 1) { | |
| if (wordSign === 1) { TP++; syllabicMapping.push('TP'); } | |
| else if (wordSign === 2) { TP += 0.5; syllabicMapping.push('TP'); } | |
| else { FN++; syllabicMapping.push('FN'); } | |
| } else { | |
| if (wordSign === 1) { FP++; syllabicMapping.push('FP'); } | |
| else if (wordSign === 2) { TN++; syllabicMapping.push('TN'); } | |
| else { TN++; syllabicMapping.push('TN'); } | |
| } | |
| cursor++; | |
| } | |
| // Penalty for two stressed syllables in a row | |
| let additionalScoreFactor = 1.0; | |
| if (word.stressSignature.length > 0 && prevLastStressed && word.stressSignature[0] === 1) { | |
| additionalScoreFactor = 0.1; | |
| } | |
| const metreScore = Math.pow(0.1, FP) * Math.pow(0.95, FN) * additionalScoreFactor; | |
| const totalScore = metreScore * word.score; | |
| return { | |
| mapping: { word, TP, FP, TN, FN, syllabicMapping, stressShift: false, metreScore, totalScore }, | |
| newCursor: cursor, | |
| }; | |
| } | |
| function finalizeMapping(mm: MetreMappingResult): void { | |
| // Penalize chains of 4+ unstressed syllables | |
| const sig: number[] = []; | |
| for (const wm of mm.wordMappings) { | |
| for (const s of wm.word.stressSignature) sig.push(s); | |
| } | |
| const s = sig.map(String).join(''); | |
| const re = /0{4,}/g; | |
| let m: RegExpExecArray | null; | |
| while ((m = re.exec(s)) !== null) { | |
| const len = m[0].length; | |
| const idx = m.index; | |
| let factor = 0.1; | |
| if (len <= 5 && s.slice(0, idx).includes('1') && s.slice(idx + len).includes('1')) { | |
| factor = len === 4 ? 0.80 : 0.50; | |
| } else { | |
| factor = len === 4 ? 0.30 : 0.20; | |
| } | |
| mm.score *= factor; | |
| } | |
| } | |
| function getMappingScore(mm: MetreMappingResult): number { | |
| const shiftFactor = mm.stressShiftCount < 2 ? 1.0 : Math.pow(0.5, mm.stressShiftCount); | |
| return mm.score * shiftFactor; | |
| } | |
| /** Recursively map stress variants against a meter signature. */ | |
| function mapChain( | |
| wordIndex: number, | |
| pwords: PoetryWord[], | |
| prevResults: MetreMappingResult[], | |
| metreSignature: number[], | |
| prefix: number, | |
| allResults: MetreMappingResult[], | |
| ): void { | |
| if (wordIndex >= pwords.length) { | |
| for (const r of prevResults) { | |
| finalizeMapping(r); | |
| allResults.push(r); | |
| } | |
| return; | |
| } | |
| const variants = getStressVariants(pwords[wordIndex], true, true); | |
| const newResults: MetreMappingResult[] = []; | |
| for (const prevResult of prevResults) { | |
| const prevLastStressed = prevResult.wordMappings.length > 0 && | |
| prevResult.wordMappings[prevResult.wordMappings.length - 1].word.stressSignature.length > 0 && | |
| prevResult.wordMappings[prevResult.wordMappings.length - 1].word.stressSignature[ | |
| prevResult.wordMappings[prevResult.wordMappings.length - 1].word.stressSignature.length - 1 | |
| ] === 1; | |
| for (const variant of variants) { | |
| const { mapping, newCursor } = buildWordMapping( | |
| variant, metreSignature, prevResult.cursor, prevLastStressed, prefix | |
| ); | |
| // Prevent long chains of unstressed syllables | |
| if (mapping.word.newStressPos === -1) { | |
| const n = countPrevUnstressed(prevResult); | |
| if (mapping.word.poetryWord.nVowels + n >= 6) continue; | |
| } | |
| const newMapping: MetreMappingResult = { | |
| prefix, | |
| metreSignature, | |
| score: prevResult.score * mapping.totalScore, | |
| wordMappings: [...prevResult.wordMappings, mapping], | |
| stressShiftCount: prevResult.stressShiftCount + (mapping.stressShift ? 1 : 0), | |
| cursor: newCursor, | |
| }; | |
| newResults.push(newMapping); | |
| } | |
| } | |
| // Sort by score descending and keep top N to prevent combinatorial explosion | |
| newResults.sort((a, b) => getMappingScore(b) - getMappingScore(a)); | |
| const MAX_RESULTS = 30; | |
| const topResults = newResults.slice(0, MAX_RESULTS); | |
| mapChain(wordIndex + 1, pwords, topResults, metreSignature, prefix, allResults); | |
| } | |
| function countPrevUnstressed(mm: MetreMappingResult): number { | |
| let n = 0; | |
| for (let i = mm.wordMappings.length - 1; i >= 0; i--) { | |
| if (mm.wordMappings[i].word.newStressPos === -1) { | |
| n += mm.wordMappings[i].word.stressSignature.length; | |
| } else { | |
| break; | |
| } | |
| } | |
| return n; | |
| } | |
| /** Map all stress variants for a line against a meter signature + prefix. */ | |
| function mapLine(pwords: PoetryWord[], metreSignature: number[], prefix: number): MetreMappingResult[] { | |
| const allResults: MetreMappingResult[] = []; | |
| const startResult: MetreMappingResult = { | |
| prefix, | |
| metreSignature, | |
| score: 1.0, | |
| wordMappings: [], | |
| stressShiftCount: 0, | |
| cursor: 0, | |
| }; | |
| mapChain(0, pwords, [startResult], metreSignature, prefix, allResults); | |
| allResults.sort((a, b) => getMappingScore(b) - getMappingScore(a)); | |
| return allResults; | |
| } | |
| // ── Rhyming tail ───────────────────────────────────────────────────── | |
| interface RhymingTail { | |
| stressedWord: WordStressVariant | null; | |
| unstressedTail: string; | |
| prefix: string; | |
| ok: boolean; | |
| } | |
| function getRhymingTail(stressedWords: WordStressVariant[]): RhymingTail { | |
| let stressedWord: WordStressVariant | null = null; | |
| let unstressedPrefix = ''; | |
| const postfixWords: WordStressVariant[] = []; | |
| for (let i = stressedWords.length - 1; i >= 0; i--) { | |
| const sw = stressedWords[i]; | |
| if (sw.newStressPos !== -1) { | |
| stressedWord = sw; | |
| // Special case: single-vowel word | |
| if (/^[аеёиоуыэюя]$/i.test(sw.poetryWord.form) && i > 0) { | |
| const prev = stressedWords[i - 1].poetryWord.form; | |
| const m = prev.match(/([абвгдеёжзийклмнопрстфхцчшщыэюя][ьъ]?)$/i); | |
| if (m) unstressedPrefix = m[1].toLowerCase(); | |
| } | |
| for (let i2 = i + 1; i2 < stressedWords.length; i2++) { | |
| if (stressedWords[i2].poetryWord.upos !== 'PUNCT') { | |
| postfixWords.push(stressedWords[i2]); | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| const unstressedTail = postfixWords.map(w => w.poetryWord.form).join(''); | |
| let ok = false; | |
| if (stressedWord && stressedWord.newStressPos !== -1) { | |
| ok = true; | |
| if (stressedWord.stressSignature.length - stressedWord.newStressPos >= 3) { | |
| ok = false; | |
| } | |
| } | |
| return { stressedWord, unstressedTail, prefix: unstressedPrefix, ok }; | |
| } | |
| // ── Rhyme detection ────────────────────────────────────────────────── | |
| function checkRhyming(tail1: RhymingTail, tail2: RhymingTail): boolean { | |
| if (!tail1.ok || !tail2.ok) return false; | |
| if (!tail1.stressedWord || !tail2.stressedWord) return false; | |
| const data = getAccentsData(); | |
| const form1 = tail1.stressedWord.poetryWord.form.toLowerCase(); | |
| const form2 = tail2.stressedWord.poetryWord.form.toLowerCase(); | |
| // Check explicit rhymed words | |
| if (data.rhymedWords.has([form1, form2] as [string, string]) || | |
| data.rhymedWords.has([form2, form1] as [string, string])) { | |
| return true; | |
| } | |
| // Compare clausulas | |
| const stress1 = tail1.stressedWord.newStressPos; | |
| const stress2 = tail2.stressedWord.newStressPos; | |
| const vowels1 = countVowels(form1) + countVowels(tail1.unstressedTail); | |
| const vowels2 = countVowels(form2) + countVowels(tail2.unstressedTail); | |
| const pos1 = vowels1 - stress1 + countVowels(tail1.unstressedTail); | |
| const pos2 = vowels2 - stress2 + countVowels(tail2.unstressedTail); | |
| if (pos1 !== pos2) return false; | |
| // Compare spelling endings | |
| const ending1 = getSpellingEnding(form1, stress1, tail1.unstressedTail); | |
| const ending2 = getSpellingEnding(form2, stress2, tail2.unstressedTail); | |
| if (ending1 === ending2) return true; | |
| // Compare phonetic clausulas | |
| const claus1 = extractClausula(form1, stress1, tail1.unstressedTail); | |
| const claus2 = extractClausula(form2, stress2, tail2.unstressedTail); | |
| if (arePhoneticallyEqual(claus1, claus2)) return true; | |
| // Allow phonetic fuzzy rhymes like -ать / -а and -ять / -я (умирать / номера) | |
| if ((claus1 === 'ать' && claus2 === 'а') || (claus1 === 'а' && claus2 === 'ать')) return true; | |
| if ((claus1 === 'ять' && claus2 === 'я') || (claus1 === 'я' && claus2 === 'ять')) return true; | |
| if (claus1.endsWith('ть') && claus2.endsWith('т') && claus1.slice(0, -2) === claus2.slice(0, -1)) return true; | |
| if (claus2.endsWith('ть') && claus1.endsWith('т') && claus2.slice(0, -2) === claus1.slice(0, -1)) return true; | |
| // Check fuzzy rhyming dictionary | |
| const lemma1 = tail1.stressedWord.poetryWord.lemma.toLowerCase(); | |
| const lemma2 = tail2.stressedWord.poetryWord.lemma.toLowerCase(); | |
| if (data.rhymingDict[lemma1]?.includes(lemma2) || data.rhymingDict[lemma2]?.includes(lemma1)) { | |
| return true; | |
| } | |
| // 04-08-2022: Депрессяшки - однобуквенная разница или сдвиг | |
| // Actually, we can add more robust fuzzy matching here later, | |
| // but let's check basic substring matching for verbs with different postfixes | |
| if (tail1.stressedWord.poetryWord.upos === 'VERB' && tail2.stressedWord.poetryWord.upos === 'VERB') { | |
| if (form1.endsWith(form2) || form2.endsWith(form1)) return true; | |
| } | |
| return false; | |
| } | |
| function getSpellingEnding(word: string, stressPos: number, tail: string): string { | |
| let vc = 0; | |
| for (let i = 0; i < word.length; i++) { | |
| if ('уеыаоэёяию'.includes(word[i].toLowerCase())) { | |
| vc++; | |
| if (vc === stressPos) return word.slice(i) + tail.toLowerCase(); | |
| } | |
| } | |
| return word + tail.toLowerCase(); | |
| } | |
| function arePhoneticallyEqual(s1: string, s2: string): boolean { | |
| if (s1 === s2) return true; | |
| if (Math.abs(s1.length - s2.length) > 1) return false; | |
| const minLen = Math.min(s1.length, s2.length); | |
| let mismatches = 0; | |
| for (let i = 0; i < minLen; i++) { | |
| if (s1[i] !== s2[i]) { | |
| mismatches++; | |
| if (RU_VOWELS.includes(s1[i]) && RU_VOWELS.includes(s2[i])) continue; | |
| const DEVOICING: Record<string, string> = { 'б': 'п', 'в': 'ф', 'г': 'к', 'д': 'т', 'ж': 'ш', 'з': 'с' }; | |
| if (DEVOICING[s1[i]] === s2[i] || DEVOICING[s2[i]] === s1[i]) continue; | |
| if ((s1[i] === 'ь' || s1[i] === 'ъ') && (s2[i] === 'ь' || s2[i] === 'ъ')) continue; | |
| } | |
| } | |
| return mismatches <= 1; | |
| } | |
| /** Detect rhyme scheme for 4 lines. */ | |
| function detectRhyming(tails: RhymingTail[]): { scheme: string; score: number; graph: (number | null)[] } { | |
| const n = tails.length; | |
| const rx: Record<string, boolean> = {}; | |
| const graph: (number | null)[] = []; | |
| for (let i = 0; i < n - 1; i++) { | |
| let iRhyming: number | null = null; | |
| for (let j = i + 1; j < n; j++) { | |
| const r = checkRhyming(tails[i], tails[j]); | |
| rx[`${i},${j}`] = r; | |
| if (r && iRhyming === null) { | |
| iRhyming = j - i; | |
| } | |
| } | |
| graph.push(iRhyming); | |
| } | |
| graph.push(null); | |
| let scheme = '-'.repeat(n); | |
| const graphStr = graph.map(g => g === null ? 0 : g).join(' '); | |
| if (n === 4) { | |
| if (graphStr === '2 0 1 0') scheme = 'A-AA'; | |
| else if (graphStr === '0 1 1 0') scheme = '-AAA'; | |
| else { | |
| const r01 = rx['0,1'], r02 = rx['0,2'], r03 = rx['0,3']; | |
| const r12 = rx['1,2'], r13 = rx['1,3'], r23 = rx['2,3']; | |
| if (r01 && r12 && r23) scheme = 'AAAA'; | |
| else if (r02 && r13) scheme = 'ABAB'; | |
| else if (r03 && r12) scheme = 'ABBA'; | |
| else if (r01 && r23) scheme = 'AABB'; | |
| else if (r01 && r03 && !r02) scheme = 'AABA'; | |
| else if (r01 && r02 && r12 && !r13) scheme = 'AAAB'; | |
| else if (r02 && !r13) scheme = 'A-A-'; | |
| else if (!r02 && r13) scheme = '-A-A'; | |
| else if (r12 && !r01 && !r23) scheme = '-AA-'; | |
| else if (r03 && !r12) scheme = 'A--A'; | |
| else if (!r01 && r23) scheme = '--AA'; | |
| else if (r01 && !r23) scheme = 'AA--'; | |
| } | |
| } else if (n === 2) { | |
| if (rx['0,1']) { scheme = 'AA'; return { scheme, score: 1.0, graph: [1, null] }; } | |
| else { scheme = '--'; return { scheme, score: 0.75, graph: [null, null] }; } | |
| } else if (n === 1) { | |
| scheme = '-'; | |
| } else { | |
| // For n != 4, use the generic rhyme graph → scheme conversion | |
| scheme = convertRhymeGraphToScheme(graph); | |
| } | |
| // Calculate rhyme score matching original Python logic | |
| const edges = graph.map(g => g === null ? 0 : g); | |
| for (let i = 0; i < edges.length; i++) { | |
| const e = edges[i]; | |
| if (e > 0 && edges[i + e] === 0) { | |
| edges[i + e] = -e; | |
| } | |
| } | |
| const numRhymedLines = edges.filter(e => e !== 0).length; | |
| let rhymeScore: number; | |
| if (n === 3) { | |
| if (numRhymedLines < 2) rhymeScore = 1.0 - 1.0 / 6.0; | |
| else rhymeScore = 1.0; | |
| } else { | |
| const rhymePenalty1 = 1.0 / (2 * n); | |
| const rhymePenalty = edges.reduce((sum, e) => sum + (e === 0 ? rhymePenalty1 : 0.0), 0); | |
| rhymeScore = 1.0 - rhymePenalty; | |
| } | |
| return { scheme, score: rhymeScore, graph }; | |
| } | |
| function convertRhymeGraphToScheme(graph: (number | null)[]): string { | |
| const n = graph.length; | |
| const letters: string[] = new Array(n).fill('-'); | |
| let curChar = 65; // 'A' | |
| for (let i = 0; i < n; i++) { | |
| const offset = graph[i]; | |
| if (offset !== null && offset > 0) { | |
| const j = i + offset; | |
| if (letters[i] === '-') { | |
| if (curChar > 90) break; | |
| letters[i] = String.fromCharCode(curChar); | |
| letters[j] = String.fromCharCode(curChar); | |
| curChar++; | |
| } else { | |
| letters[j] = letters[i]; | |
| } | |
| } | |
| } | |
| return letters.join(''); | |
| } | |
| // ── Line-level scoring ─────────────────────────────────────────────── | |
| interface LineStressVariant { | |
| stressedWords: WordStressVariant[]; | |
| stressSignature: number[]; | |
| stressSignatureStr: string; | |
| rhymingTail: RhymingTail; | |
| totalScore: number; | |
| penalties: string[]; | |
| } | |
| function buildLineStressVariant(stressedWords: WordStressVariant[]): LineStressVariant { | |
| const stressSignature: number[] = []; | |
| for (const sw of stressedWords) { | |
| stressSignature.push(...sw.stressSignature); | |
| } | |
| const stressSignatureStr = stressSignature.map(String).join(''); | |
| const rhymingTail = getRhymingTail(stressedWords); | |
| // Calculate total score | |
| let totalScore = 1.0; | |
| for (const sw of stressedWords) totalScore *= sw.score; | |
| const penalties: string[] = []; | |
| // Penalty: unstressed pronoun after unstressed preposition with vowel | |
| if (stressedWords.length >= 2) { | |
| const last = stressedWords[stressedWords.length - 1]; | |
| const prev = stressedWords[stressedWords.length - 2]; | |
| if (last.newStressPos === -1 && prev.newStressPos === -1 && | |
| prev.poetryWord.upos === 'ADP' && countVowels(prev.poetryWord.form) > 0 && | |
| last.poetryWord.upos === 'PRON') { | |
| totalScore *= 0.5; | |
| penalties.push('@545'); | |
| } | |
| } | |
| // Penalty: bad clausula | |
| if (!rhymingTail.ok) { | |
| totalScore *= 0.1; | |
| penalties.push('@550'); | |
| } | |
| // Penalty: no stresses at all | |
| if (stressedWords.every(w => w.newStressPos === -1)) { | |
| totalScore *= 0.01; | |
| penalties.push('@555'); | |
| } | |
| // Penalty: stressed "и" at line start | |
| if (stressedWords[0]?.newStressPos === 1 && stressedWords[0]?.poetryWord.form.toLowerCase() === 'и') { | |
| totalScore *= 0.1; | |
| penalties.push('@573'); | |
| } | |
| // Penalty: consonant clusters across word boundaries | |
| for (let i = 0; i < stressedWords.length - 1; i++) { | |
| const w1 = stressedWords[i], w2 = stressedWords[i + 1]; | |
| const nAdj = w1.poetryWord.trailingConsonants + w2.poetryWord.leadingConsonants; | |
| if (nAdj > 5) { | |
| totalScore *= 0.5; | |
| penalties.push('@309'); | |
| } | |
| // Penalty: stressed preposition before stressed noun | |
| if (w1.poetryWord.upos === 'ADP' && w1.newStressPos > 0 && | |
| (w2.poetryWord.upos === 'NOUN' || w2.poetryWord.upos === 'PROPN') && w2.newStressPos > 0) { | |
| totalScore *= 0.5; | |
| penalties.push('@317'); | |
| } | |
| } | |
| // Penalty: only one stressed syllable in a long line | |
| const stressSum = stressSignature.reduce((a, b) => a + b, 0); | |
| if (stressSum === 1 && stressedWords.length > 2) { | |
| const totalVowels = stressedWords.reduce((s, w) => s + w.poetryWord.nVowels, 0); | |
| if (totalVowels > 2) { | |
| totalScore *= 0.1; | |
| penalties.push('@335'); | |
| } | |
| } | |
| // Penalty: three unstressed syllables at end | |
| if (stressSignatureStr.endsWith('000')) { | |
| totalScore *= 0.1; | |
| penalties.push('@626'); | |
| } | |
| return { stressedWords, stressSignature, stressSignatureStr, rhymingTail, totalScore, penalties }; | |
| } | |
| function detectPoorPoetry(tails: RhymingTail[], rhymeScheme: string): boolean { | |
| const lastWords = tails.map(t => t.stressedWord?.poetryWord.form.toLowerCase() || ''); | |
| // Check trivial repetition | |
| for (let i1 = 0; i1 < lastWords.length - 1; i1++) { | |
| const word1 = lastWords[i1]; | |
| for (let i2 = i1 + 1; i2 < Math.min(lastWords.length, i1 + 6); i2++) { | |
| const word2 = lastWords[i2]; | |
| if (word1 && word2) { | |
| const form1 = word1.replace(/ё/g, 'е'); | |
| const form2 = word2.replace(/ё/g, 'е'); | |
| if (form1 === form2) return true; | |
| if ('не' + form1 === form2 || 'не' + form2 === form1) return true; | |
| } | |
| } | |
| } | |
| // Check bad rhymes based on scheme | |
| const rhymePairs: [RhymingTail, RhymingTail][] = []; | |
| if (rhymeScheme === 'ABAB' || rhymeScheme === 'A-A-' || rhymeScheme === '-A-A') { | |
| if (tails[0] && tails[2]) rhymePairs.push([tails[0], tails[2]]); | |
| if (tails[1] && tails[3]) rhymePairs.push([tails[1], tails[3]]); | |
| } else if (rhymeScheme === 'ABBA') { | |
| if (tails[0] && tails[3]) rhymePairs.push([tails[0], tails[3]]); | |
| if (tails[1] && tails[2]) rhymePairs.push([tails[1], tails[2]]); | |
| } else if (rhymeScheme === 'AABA') { | |
| if (tails[0] && tails[1]) rhymePairs.push([tails[0], tails[1]]); | |
| if (tails[0] && tails[3]) rhymePairs.push([tails[0], tails[3]]); | |
| } else if (rhymeScheme === 'AABB') { | |
| if (tails[0] && tails[1]) rhymePairs.push([tails[0], tails[1]]); | |
| if (tails[2] && tails[3]) rhymePairs.push([tails[2], tails[3]]); | |
| } else if (rhymeScheme === 'AAAA' || rhymeScheme === '----') { | |
| if (tails[0] && tails[1]) rhymePairs.push([tails[0], tails[1]]); | |
| if (tails[1] && tails[2]) rhymePairs.push([tails[1], tails[2]]); | |
| if (tails[2] && tails[3]) rhymePairs.push([tails[2], tails[3]]); | |
| } | |
| const badGroups = [ | |
| ['твой', 'мой', 'свой'], | |
| ['тебе', 'мне', 'себе'], | |
| ['него', 'его'], | |
| ['твои', 'свои'], | |
| ['наши', 'ваши'], | |
| ['меня', 'тебя', 'себя'], | |
| ['мной', 'тобой', 'собой'], | |
| ['мною', 'тобою', 'собою'], | |
| ['нее', 'ее', 'неё', 'её'], | |
| ['шел', 'шёл'], // simplified | |
| ['твоем', 'твоём', 'своем', 'своём', 'моем', 'моём'], | |
| ['когда', 'никогда', 'навсегда', 'кое-когда'], | |
| ['кто', 'никто', 'кое-кто'], | |
| ['где', 'нигде', 'везде'], | |
| ['каких', 'никаких', 'таких', 'сяких'], | |
| ['какого', 'никакого', 'такого', 'сякого'] | |
| ]; | |
| for (const [tail1, tail2] of rhymePairs) { | |
| if (!tail1.ok || !tail2.ok || !tail1.stressedWord || !tail2.stressedWord) continue; | |
| const word1 = tail1.stressedWord.poetryWord; | |
| const word2 = tail2.stressedWord.poetryWord; | |
| const form1 = word1.form.toLowerCase(); | |
| const form2 = word2.form.toLowerCase(); | |
| if (word1.upos === 'VERB' && word2.upos === 'VERB') { | |
| const data = getAccentsData(); | |
| if (data.rhymedWords.has([form1, form2] as [string, string]) || data.rhymedWords.has([form2, form1] as [string, string])) continue; | |
| const badVerbsEndings = ['ли', 'ла', 'ло', 'л', 'тся', 'те', 'лись', 'лась', 'лось', 'лся', 'тся', 'ться', 'шись']; | |
| if (badVerbsEndings.some(e => form1.endsWith(e) && form2.endsWith(e))) return true; | |
| if (form1.endsWith(form2) || form2.endsWith(form1)) return true; | |
| if (word1.lemma === word2.lemma) return true; | |
| } | |
| if ((word1.upos === 'NOUN' || word1.upos === 'PROPN' || word1.upos === 'ADJ') && word1.upos === word2.upos) { | |
| if (word1.lemma === word2.lemma) return true; | |
| } | |
| for (const group of badGroups) { | |
| const g = new Set(group); | |
| if (g.has(form1.replace(/ё/g, 'е')) && g.has(form2.replace(/ё/g, 'е'))) return true; | |
| } | |
| if (word1.upos === 'ADJ' && word2.upos === 'ADJ') { | |
| if (form1.endsWith('ому') && form2.endsWith('ому')) return true; | |
| if (form1.endsWith('ему') && form2.endsWith('ему')) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // ── The main alignment function ────────────────────────────────────── | |
| interface AlignmentResult { | |
| stressedLines: LineStressVariant[]; | |
| metreMappings: MetreMappingResult[]; | |
| score: number; | |
| meter: string; | |
| rhymeScheme: string; | |
| rhymeGraph: (number | null)[]; | |
| } | |
| function getPrefixesForMeter(metreSignature: number[]): number[] { | |
| return metreSignature.length === 2 ? [0] : [0, 1]; | |
| } | |
| function getCanonicMeter(metreName: string, prefix: number): string { | |
| if (metreName === 'ямб') return prefix === 0 ? 'ямб' : 'хорей'; | |
| if (metreName === 'хорей') return prefix === 0 ? 'хорей' : 'ямб'; | |
| // For ternary meters, prefix=1 shifts the meter signature right by 1 | |
| if (metreName === 'дактиль' || metreName === 'амфибрахий' || metreName === 'анапест') { | |
| let m: [number, number, number]; | |
| if (metreName === 'дактиль') m = [1, 0, 0]; | |
| else if (metreName === 'амфибрахий') m = [0, 1, 0]; | |
| else m = [0, 0, 1]; | |
| if (prefix === 1) { | |
| m = [m[2], m[0], m[1]]; | |
| } | |
| if (m[0] === 1) return 'дактиль'; | |
| if (m[1] === 1) return 'амфибрахий'; | |
| if (m[2] === 1) return 'анапест'; | |
| } | |
| if (metreName === 'dolnik') return 'dolnik'; | |
| return metreName; | |
| } | |
| function alignLines(lines: string[]): AlignmentResult { | |
| // Parse all lines | |
| const pwordsPerLine: PoetryWord[][] = []; | |
| for (const line of lines) { | |
| const sentences = parseRussianText(line); | |
| const tokens = sentences.flat(); | |
| const pwords: PoetryWord[] = []; | |
| for (const token of tokens) { | |
| if (isRuPunctuation(token.upos)) continue; | |
| pwords.push(buildPoetryWord(token)); // ёфикация happens inside | |
| } | |
| // Mark last non-punct word as rhyming word | |
| for (let i = pwords.length - 1; i >= 0; i--) { | |
| if (!PUNCT_UPOS.has(pwords[i].upos)) { | |
| pwords[i].isRhymingWord = true; | |
| break; | |
| } | |
| } | |
| pwordsPerLine.push(pwords); | |
| } | |
| // Check for too-long lines | |
| for (const pwords of pwordsPerLine) { | |
| if (pwords.filter(w => w.nVowels >= 1).length >= MAX_WORDS_PER_LINE) { | |
| // Fall back to first-variant only | |
| const stressedLines = pwordsPerLine.map(pws => | |
| buildLineStressVariant(pws.map(pw => getStressVariants(pw, false, false)[0]).filter(v => v)) | |
| ); | |
| return { | |
| stressedLines, | |
| metreMappings: [], | |
| score: 0.0, | |
| meter: 'free', | |
| rhymeScheme: '-'.repeat(lines.length), | |
| rhymeGraph: lines.map(() => null), | |
| }; | |
| } | |
| } | |
| let bestScore = 0.0; | |
| let bestMeter = ''; | |
| let bestRhymeScheme = '----'; | |
| let bestVariant: [MetreMappingResult, LineStressVariant][] | null = null; | |
| let bestRhymeGraph: (number | null)[] = lines.map(() => null); | |
| const rhymeCache: Record<string, [string, number, (number | null)[]]> = {}; | |
| // Try each meter | |
| for (const [metreName, metreSignature] of METERS) { | |
| if (bestScore > EARLY_STOPPING) break; | |
| const prefixes = getPrefixesForMeter(metreSignature); | |
| // For each line, find best stress variants for each prefix | |
| const bestPerLine: [MetreMappingResult, LineStressVariant][][] = []; | |
| for (let iLine = 0; iLine < pwordsPerLine.length; iLine++) { | |
| const pwords = pwordsPerLine[iLine]; | |
| const lineBests: Record<string, [MetreMappingResult, LineStressVariant]> = {}; | |
| for (const prefix of prefixes) { | |
| const mappings = mapLine(pwords, metreSignature, prefix); | |
| // Take top results | |
| for (const mm of mappings.slice(0, 5)) { | |
| const stressedWords = mm.wordMappings.map(wm => wm.word); | |
| const lsv = buildLineStressVariant(stressedWords); | |
| if (lsv.rhymingTail.ok) { | |
| const tailStr = lsv.rhymingTail.stressedWord?.stressedForm ?? ''; | |
| const score = getMappingScore(mm); | |
| if (!lineBests[tailStr] || score > getMappingScore(lineBests[tailStr][0])) { | |
| lineBests[tailStr] = [mm, lsv]; | |
| } | |
| } | |
| } | |
| } | |
| bestPerLine.push(Object.values(lineBests)); | |
| } | |
| // Check for empty lines | |
| if (bestPerLine.some(b => b.length === 0)) continue; | |
| // Try combinations (limit to prevent explosion) | |
| const MAX_COMBOS = 500; | |
| let combos: [MetreMappingResult, LineStressVariant][][] = [[]]; | |
| for (const lineOptions of bestPerLine) { | |
| const newCombos: [MetreMappingResult, LineStressVariant][][] = []; | |
| for (const combo of combos) { | |
| for (const opt of lineOptions.slice(0, 3)) { | |
| newCombos.push([...combo, opt]); | |
| } | |
| } | |
| combos = newCombos.slice(0, MAX_COMBOS); | |
| } | |
| for (const plinev of combos) { | |
| // Meter defects scoring | |
| let metreDefectsScore = 1.0; | |
| const prefixCounts: Record<number, number> = {}; | |
| for (const [mm] of plinev) { | |
| prefixCounts[mm.prefix] = (prefixCounts[mm.prefix] ?? 0) + 1; | |
| } | |
| // Only penalize if exactly one line has a different prefix | |
| const prefixValues = Object.values(prefixCounts); | |
| if (prefixValues.includes(1) && plinev.length > 2) { | |
| metreDefectsScore *= 0.1; | |
| } | |
| // Syllable count variation penalty | |
| const sylCounts = plinev.map(([, lsv]) => lsv.stressSignature.length); | |
| const uniqueSyls = new Set(sylCounts); | |
| if (uniqueSyls.size > 2) { | |
| metreDefectsScore *= 0.1; | |
| } | |
| // Detect rhyming | |
| const tails = plinev.map(([, lsv]) => lsv.rhymingTail); | |
| const cacheKey = tails.map(t => t.stressedWord?.stressedForm ?? '').join('|'); | |
| let rhymeResult: [string, number, (number | null)[]]; | |
| if (rhymeCache[cacheKey]) { | |
| rhymeResult = rhymeCache[cacheKey]; | |
| } else { | |
| const r = detectRhyming(tails); | |
| rhymeResult = [r.scheme, r.score, r.graph]; | |
| rhymeCache[cacheKey] = rhymeResult; | |
| } | |
| const [rhymeScheme, rhymeScore, rhymeGraph] = rhymeResult; | |
| // Recalculate line scores with rhyme graph | |
| const lineTScores = plinev.map(([mm]) => getMappingScore(mm)); | |
| for (let i1 = 0; i1 < rhymeGraph.length; i1++) { | |
| const edge = rhymeGraph[i1]; | |
| if (edge !== null) { | |
| const i2 = i1 + edge; | |
| if (plinev[i1][1].stressSignatureStr === plinev[i2][1].stressSignatureStr) { | |
| for (const j of [i1, i2]) { | |
| lineTScores[j] += (1.0 - lineTScores[j]) * 0.10; | |
| } | |
| } | |
| } | |
| } | |
| let totalScore = metreDefectsScore * rhymeScore * lineTScores.reduce((a, b) => a * b, 1); | |
| if (detectPoorPoetry(tails, rhymeScheme)) { | |
| totalScore *= 0.1; | |
| } | |
| if (totalScore > bestScore) { | |
| bestScore = totalScore; | |
| bestMeter = getCanonicMeter(metreName, plinev[0][0].prefix); | |
| bestRhymeScheme = rhymeScheme; | |
| bestVariant = plinev; | |
| bestRhymeGraph = rhymeGraph; | |
| } | |
| } | |
| } | |
| // Try dolnik if no good match found | |
| if (bestScore <= EARLY_STOPPING) { | |
| const numSyllables = pwordsPerLine.map(pws => pws.reduce((s, pw) => s + pw.nVowels, 0)); | |
| const dolnikPatterns = getDolnikPatterns(numSyllables); | |
| for (const pattern of dolnikPatterns) { | |
| const newStressLines: [MetreMappingResult, LineStressVariant][] = []; | |
| let ok = true; | |
| for (let iLine = 0; iLine < pwordsPerLine.length; iLine++) { | |
| const pwords = pwordsPerLine[iLine]; | |
| const sig = pattern[iLine % pattern.length]; | |
| const mappings = mapLine(pwords, sig, 0); | |
| let bestMapping: MetreMappingResult | null = null; | |
| let bestLSV: LineStressVariant | null = null; | |
| let maxScore = 0.0; | |
| for (const mm of mappings) { | |
| if (getMappingScore(mm) > maxScore) { | |
| const stressedWords = mm.wordMappings.map(wm => wm.word); | |
| const lsv = buildLineStressVariant(stressedWords); | |
| if (lsv.rhymingTail.ok) { | |
| maxScore = getMappingScore(mm); | |
| bestMapping = mm; | |
| bestLSV = lsv; | |
| } | |
| } | |
| } | |
| if (bestMapping && bestLSV) { | |
| newStressLines.push([bestMapping, bestLSV]); | |
| } else { | |
| ok = false; | |
| break; | |
| } | |
| } | |
| if (ok) { | |
| const tails = newStressLines.map(([, lsv]) => lsv.rhymingTail); | |
| const r = detectRhyming(tails); | |
| const lineTScores = newStressLines.map(([mm]) => getMappingScore(mm)); | |
| let totalScore = r.score * lineTScores.reduce((a, b) => a * b, 1); | |
| if (detectPoorPoetry(tails, r.scheme)) { | |
| totalScore *= 0.1; | |
| } | |
| if (totalScore > bestScore) { | |
| bestScore = totalScore; | |
| bestMeter = 'dolnik'; | |
| bestRhymeScheme = r.scheme; | |
| bestVariant = newStressLines; | |
| bestRhymeGraph = r.graph; | |
| } | |
| } | |
| } | |
| } | |
| if (!bestVariant) { | |
| // No good alignment found — return default | |
| const stressedLines = pwordsPerLine.map(pws => { | |
| const sws = pws.map(pw => getStressVariants(pw, false, false)[0]).filter(v => v); | |
| return buildLineStressVariant(sws); | |
| }); | |
| return { | |
| stressedLines, | |
| metreMappings: [], | |
| score: 0.0, | |
| meter: 'free', | |
| rhymeScheme: '-'.repeat(lines.length), | |
| rhymeGraph: lines.map(() => null), | |
| }; | |
| } | |
| return { | |
| stressedLines: bestVariant.map(([, lsv]) => lsv), | |
| metreMappings: bestVariant.map(([mm]) => mm), | |
| score: bestScore, | |
| meter: bestMeter, | |
| rhymeScheme: bestRhymeScheme, | |
| rhymeGraph: bestRhymeGraph, | |
| }; | |
| } | |
| function getDolnikPatterns(numSyllables: number[]): number[][][] { | |
| const maxSyl = Math.max(...numSyllables); | |
| const minSyl = Math.min(...numSyllables); | |
| const patterns: number[][][] = []; | |
| // Typical Dolniks (3-ictus and 4-ictus) | |
| if (minSyl >= 8 && maxSyl <= 12) { | |
| // 3-ictus dolnik (e.g. 0 0 1 0 0 1 0 1 0) | |
| patterns.push([ | |
| [0, 0, 1, 0, 0, 1, 0, 1, 0], | |
| [0, 0, 1, 0, 0, 1, 0, 1] | |
| ]); | |
| patterns.push([ | |
| [0, 1, 0, 0, 1, 0, 0, 1, 0], | |
| [0, 1, 0, 0, 1, 0, 0, 1] | |
| ]); | |
| patterns.push([ | |
| [1, 0, 0, 1, 0, 0, 1, 0], | |
| [1, 0, 0, 1, 0, 0, 1] | |
| ]); | |
| patterns.push([ | |
| [1, 0, 1, 0, 0, 1, 0, 1, 0], | |
| [1, 0, 1, 0, 0, 1, 0, 1] | |
| ]); | |
| patterns.push([ | |
| [1, 0, 1, 0, 1, 0, 0, 1, 0], | |
| [1, 0, 1, 0, 1, 0, 0, 1] | |
| ]); | |
| patterns.push([[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0]]); | |
| } | |
| if (minSyl >= 12 && maxSyl <= 16) { | |
| patterns.push([[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0]]); | |
| patterns.push([ | |
| [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], | |
| [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1] | |
| ]); | |
| } | |
| if (minSyl >= 5 && maxSyl <= 8) { | |
| patterns.push([[0, 0, 1, 0, 0, 1, 0, 0]]); | |
| } | |
| return patterns; | |
| } | |
| // ── Public API ─────────────────────────────────────────────────────── | |
| /** Analyze a Russian poem and return the full scansion result. */ | |
| export function analyzeRussianPoem(text: string): RuScansionResult { | |
| // Split into stanzas (blank lines separate stanzas) | |
| const stanzaTexts = text.split(/\n\s*\n/).filter(s => s.trim()); | |
| const stanzas: RuStanza[] = []; | |
| const allResults: AlignmentResult[] = []; | |
| let totalScore = 1.0; | |
| for (const stanzaText of stanzaTexts) { | |
| const lines = stanzaText.split('\n').filter(l => l.trim()); | |
| // Auto-segment long unstructured text into 4-line blocks | |
| let blocks: string[][]; | |
| if (lines.length > 7) { | |
| blocks = []; | |
| for (let i = 0; i < lines.length; i += 4) { | |
| blocks.push(lines.slice(i, Math.min(i + 4, lines.length))); | |
| } | |
| } else { | |
| // Leave blocks of <= 7 lines intact, just like the original Python | |
| blocks = [lines]; | |
| } | |
| const stanzaLines: RuLine[] = []; | |
| let stanzaScore = 1.0; | |
| for (const block of blocks) { | |
| const result = alignLines(block); | |
| allResults.push(result); | |
| stanzaScore = Math.min(stanzaScore, result.score); | |
| // Build RuLine from result | |
| for (let i = 0; i < block.length; i++) { | |
| const lsv = result.stressedLines[i]; | |
| const raw = block[i]; | |
| const words: RuWord[] = []; | |
| for (const sw of lsv.stressedWords) { | |
| const pw = sw.poetryWord; | |
| const sylTexts = splitWord(pw.form); | |
| const syllables: RuSyllable[] = []; | |
| let vIdx = 0; | |
| for (const sylText of sylTexts) { | |
| vIdx++; | |
| const stressed = sw.newStressPos === vIdx; | |
| const secondaryStressed = sw.stressSignature[vIdx - 1] === 2; | |
| let vowel = ''; | |
| for (const c of sylText) { | |
| if (RU_VOWELS.includes(c.toLowerCase())) { vowel = c; break; } | |
| } | |
| syllables.push({ text: sylText, vowel, vowelIndex: vIdx, stressed, secondaryStressed }); | |
| } | |
| words.push({ | |
| form: pw.form, | |
| lemma: pw.lemma, | |
| upos: pw.upos, | |
| feats: pw.feats, | |
| deprel: pw.deprel, | |
| head: pw.head, | |
| isContent: CONTENT_UPOS.has(pw.upos), | |
| syllables, | |
| stressPos: sw.newStressPos, | |
| lexStressPos: pw.stressPos, | |
| secondaryStress: sw.stressSignature.map(s => s === 2 ? 2 : 0), | |
| }); | |
| } | |
| const syllableCount = words.reduce((s, w) => s + w.syllables.filter(syl => syl.vowel).length, 0); | |
| const stressPattern = lsv.stressSignature.map(s => s >= 1 ? 'S' : 'U').join(''); | |
| // Dependency edges between the line's words (UDPipe heads remapped | |
| // from per-sentence token ids to indices into `words`; edges whose | |
| // governor is punctuation — not present in `words` — are dropped). | |
| const tokenIndex = new Map<string, number>(); | |
| lsv.stressedWords.forEach((sw2, wi) => | |
| tokenIndex.set(`${sw2.poetryWord.sent}:${sw2.poetryWord.id}`, wi)); | |
| const deps: RuDep[] = []; | |
| lsv.stressedWords.forEach((sw2, wi) => { | |
| const pw2 = sw2.poetryWord; | |
| if (!pw2.deprel) return; | |
| if (pw2.head === 0 || pw2.deprel === 'root') { | |
| deps.push({ from: wi, to: -1, rel: 'root' }); | |
| return; | |
| } | |
| const to = tokenIndex.get(`${pw2.sent}:${pw2.head}`); | |
| if (to !== undefined && to !== wi) deps.push({ from: wi, to, rel: pw2.deprel }); | |
| }); | |
| stanzaLines.push({ raw, words, syllableCount, stressPattern, deps }); | |
| } | |
| } | |
| stanzas.push({ lines: stanzaLines }); | |
| totalScore = Math.min(totalScore, stanzaScore); | |
| } | |
| // Determine overall meter | |
| const mainResult = allResults[0]; | |
| const meter: RuMeterResult = { | |
| meter: METER_CANONIC[mainResult?.meter ?? 'free'] || 'free', | |
| meterRu: mainResult?.meter ?? 'вольный', | |
| score: totalScore, | |
| footCount: 0, | |
| scansion: '', | |
| }; | |
| // Calculate foot count | |
| if (mainResult && stanzas[0]?.lines[0]) { | |
| const avgSyls = stanzas[0].lines.reduce((s, l) => s + l.syllableCount, 0) / stanzas[0].lines.length; | |
| const footLens: Record<string, number> = { | |
| 'iambic': 2, 'trochaic': 2, 'dactylic': 3, 'amphibrachic': 3, 'anapestic': 3, | |
| 'dolnik': 3, | |
| }; | |
| meter.footCount = Math.round(avgSyls / (footLens[meter.meter] || 2)); | |
| meter.scansion = stanzas[0].lines.map(l => l.stressPattern).join('|'); | |
| } | |
| // Build rhyme entries | |
| const allRhymes: RuRhymeEntry[] = []; | |
| let rhymeSchemeStr = ''; | |
| for (const result of allResults) { | |
| rhymeSchemeStr += result.rhymeScheme + ' '; | |
| for (let i = 0; i < result.rhymeGraph.length; i++) { | |
| const lsv = result.stressedLines[i]; | |
| if (!lsv) continue; | |
| const tail = lsv.rhymingTail; | |
| allRhymes.push({ | |
| endWord: tail.stressedWord?.poetryWord.form ?? '', | |
| letter: result.rhymeScheme[i] ?? '-', | |
| rhymeType: null, | |
| matchedLine: result.rhymeGraph[i] !== null ? i + (result.rhymeGraph[i] as number) : null, | |
| }); | |
| } | |
| } | |
| rhymeSchemeStr = rhymeSchemeStr.trim(); | |
| // Generate stressed lines | |
| const stressedLines: string[] = []; | |
| for (const result of allResults) { | |
| for (const lsv of result.stressedLines) { | |
| const line = lsv.stressedWords.map(sw => { | |
| if (sw.newStressPos === -1) { | |
| return sw.poetryWord.form; | |
| } | |
| // Render with stress mark | |
| const output: string[] = []; | |
| let vc = 0; | |
| for (const c of sw.poetryWord.form) { | |
| output.push(c); | |
| if ('уеыаоэёяию'.includes(c.toLowerCase())) { | |
| vc++; | |
| if (vc === sw.newStressPos) output.push('\u0301'); | |
| else if (sw.stressSignature[vc - 1] === 2) output.push('\u0300'); | |
| } | |
| } | |
| return output.join(''); | |
| }).join(' '); | |
| stressedLines.push(line); | |
| } | |
| stressedLines.push(''); | |
| } | |
| // Fabb–Halle bracketed grid — a second opinion relative to the STIPULATED | |
| // meter (the theory generates lines under a given rule set; it does not | |
| // discover meters), exactly as the English side passes its final meter | |
| // name. Two independence requirements: | |
| // (1) the syllables' `lex` comes from the DICTIONARY accentuation | |
| // (lexStressPos), never from the alignment's chosen stress — the | |
| // alignment may unstress words to fit the meter, and feeding that | |
| // back in would make the "second opinion" circular; | |
| // (2) monosyllabic function words are clitics with no lexical stress. | |
| // Maxima use the flanking-free Russian definition (Taranovsky's law): | |
| // any polysyllable primary stress must land on an ictus. | |
| { | |
| const fhMeter = METER_CANONIC[mainResult?.meter ?? 'free'] || 'free'; | |
| const footLen = fhMeter === 'iambic' || fhMeter === 'trochaic' ? 2 | |
| : fhMeter === 'dactylic' || fhMeter === 'amphibrachic' || fhMeter === 'anapestic' ? 3 | |
| : 0; | |
| const CLITIC_UPOS = new Set(['ADP', 'PART', 'CCONJ', 'SCONJ', 'AUX', 'INTJ']); | |
| for (const st of stanzas) { | |
| for (const line of st.lines) { | |
| if (fhMeter === 'free') { line.fabbHalle = null; continue; } | |
| const fhSyls = []; | |
| for (const w of line.words) { | |
| const vowelSyls = w.syllables.filter(s => s.vowel); | |
| const poly = vowelSyls.length > 1; | |
| const cliticMono = !poly && CLITIC_UPOS.has(w.upos); | |
| let vi = 0; | |
| for (const syl of w.syllables) { | |
| if (!syl.vowel) continue; // consonant-only chunks project nothing | |
| vi++; | |
| let lex = 0; | |
| if (!cliticMono) { | |
| if (w.lexStressPos > 0 && vi === w.lexStressPos) lex = 2; | |
| else if (w.secondaryStress && w.secondaryStress[vi - 1] === 2) lex = 1; | |
| } | |
| fhSyls.push({ text: syl.text, lex, poly }); | |
| } | |
| } | |
| const footCount = footLen > 0 ? Math.floor(fhSyls.length / footLen) : undefined; | |
| line.fabbHalle = buildFabbHalleGrid(fhSyls, fhMeter, footCount, { maximaMode: 'polysyllabic' }); | |
| } | |
| } | |
| } | |
| return { | |
| stanzas, | |
| meter, | |
| rhymeScheme: rhymeSchemeStr, | |
| rhymes: allRhymes, | |
| score: totalScore, | |
| stressedLines, | |
| }; | |
| } | |
| /** Quick check: is this text Russian? */ | |
| export function isRussian(text: string): boolean { | |
| const cyrillic = (text.match(/[А-Яа-яЁё]/g) || []).length; | |
| const latin = (text.match(/[A-Za-z]/g) || []).length; | |
| return cyrillic > latin && cyrillic > 0; | |
| } | |