// Faithful port of scanfuncs.py's ScansionMachine: ParseLine, both iambic // algorithms (DoAlgorithm), the real ChooseAlgorithm arbiter (tries every // stress-ambiguity resolution x both algorithms, keeps the lowest-complexity // winner), PromotePyrrhics, HowWeDoing, and the anapestic engine // (scanAnapestics, GetBestAnapLexes, AnapPromoteSlack, AnapCleanUpAndReport). // // This machine operates ONLY on a raw line string, its own Positioner grid, // its own dictionary, and its own syllabizer -- it never reads a Calliope // stress grade, dependency parse, or phonological hierarchy. Deliberate, // disclosed deviations from the 2005 source (all narrow, all documented at // point of use): // - ChooseAlgorithm/GetBestAnapLexes break complexity ties by taking the // FIRST-encountered best candidate (stable order) instead of Python's // `random.choice` -- a batch/display tool that answered differently on // every run for the same line would be worse, not more "faithful". // - A handful of the original's own incidental quirks (a stale // currlen/normlen reused after headless-line truncation in DoAlgorithm // algorithm 1; a loop variable in DoAlgorithm algorithm 2 that shadows and // corrupts the outer `longest`; PromotePyrrhics capturing the // pre-reassignment foot name for its syllable-index bookkeeping) are // reproduced deliberately, not fixed -- see the inline notes. import { STRESS, SLACK, PROMOTED, footDict, AnapSubs } from './constants.js'; import { footFinder, invertFootDict, longestMatch, uniquePermutations, altLineLenCalc } from './utilities.js'; import { Positioner } from './positioner.js'; import { dictLookup } from './dictionary.js'; import { syllabize } from './syllabizer.js'; const footDictInverse = invertFootDict(footDict); const anapSubsInverse = invertFootDict(AnapSubs); const VOWEL_RE = /[aeiouyAEIOUY]/; const WORD_BOUNDS_RE = /([-.,;:?!()"\s]+)/; const POSS_IAMB_RE = /(x[x/])+/; function stripPunct(word: string): string { let w = word; let start = 0; while (start < w.length && !/[A-Za-z]/.test(w[start])) start++; let end = w.length; while (end > start && !/[A-Za-z]/.test(w[end - 1])) end--; return w.slice(start, end); } /** Python's str.rfind(sub, start, end): a negative `end` wraps to * len(s)+end (may still land on a normal, non-degenerate range) -- JS's * lastIndexOf has no such convention (it clamps a negative fromIndex to 0), * so `scansion.lastIndexOf(sub, someIndex - 1)` silently diverges from * Python whenever someIndex can be 0 or negative (e.g. no prior '/' found). */ function pyRfind(s: string, sub: string, start: number, end: number): number { const e = end < 0 ? Math.max(0, s.length + end) : Math.min(end, s.length); if (e < start) return -1; const idx = s.slice(0, e).lastIndexOf(sub); return idx < start ? -1 : idx; } export interface LineDataState { lfeet: number; lfeetset: boolean; footlist: string[]; lastfoot: string; hremain: [number, number]; midremain: [number, number]; } export interface FailableFeet { footlist: string[]; boundstest: boolean[]; ok: boolean; } export class ScansionMachine { LD: LineDataState = { lfeet: 5, lfeetset: false, footlist: [], lastfoot: '', hremain: [0, 0], midremain: [0, 0] }; P = new Positioner(); SetLineFeet(num: number, setflag: boolean): void { this.LD.lfeet = num; this.LD.lfeetset = setflag; } // ─── ParseLine (scanfuncs.py ParseLine + _dictLookup + _stripPunct) ───── ParseLine(line: string): void { if (line.length < 1) return; this.LD.footlist = []; this.LD.lastfoot = ''; this.P.NewLine(line.length); const words = line.split(WORD_BOUNDS_RE); let lineindex = 0; for (const wORD of words) { if (!wORD) continue; if (!VOWEL_RE.test(wORD)) { lineindex += wORD.length; continue; } if (!stripPunct(wORD)) { lineindex = this.P.AddPunct(wORD, lineindex); continue; } const w = wORD.toLowerCase(); let syls = dictLookup(w); if (!syls) syls = syllabize(w); lineindex = this.P.AddWord(syls, lineindex); } this.P.LocateFootDivPositions(); } // ─── Iambic Algorithm 1 & 2 (DoAlgorithm) ─────────────────────────────── /** Run the whole iambic scansion (either algorithm) silently against one * candidate marks string. Mutates `this.LD.lfeet` when line length is not * fixed, exactly as the original (and that mutation then persists for * every later candidate tried in the same ChooseAlgorithm search). */ DoAlgorithm(whichAlgorithm: 1 | 2, scansionIn: string): FailableFeet { const FAIL: FailableFeet = { footlist: [], boundstest: [], ok: false }; let linefeet: number; if (!this.LD.lfeetset) { if (Math.floor(scansionIn.length / 2) >= 2) { linefeet = Math.floor(scansionIn.length / 2); this.LD.lfeet = linefeet; } else return FAIL; } else { linefeet = this.LD.lfeet; } const footlist: string[] = []; let scansion = scansionIn; if (whichAlgorithm === 1) { let normlen = linefeet * 2; let currlen = scansion.length; let lastfoot = ''; if (currlen > normlen + 1 && ['x/xx', 'xx/x'].includes(scansion.slice(-4))) { lastfoot = footDict[scansion.slice(-4)]; linefeet -= 1; scansion = scansion.slice(0, -4); } else if (currlen >= normlen && ['x/x', '//x'].includes(scansion.slice(-3))) { lastfoot = footDict[scansion.slice(-3)]; linefeet -= 1; scansion = scansion.slice(0, -3); } normlen = linefeet * 2; currlen = scansion.length; if (currlen <= normlen && ['/x/x', '/xxx'].includes(scansion.slice(0, 4))) { footlist.push('defective'); linefeet -= 1; scansion = scansion.slice(1); } // currlen/normlen deliberately NOT recomputed here -- the original // itself reuses the pre-truncation values for the branch below. if (currlen === normlen) { const r = footFinder(footDict, scansion, 2, 0, scansion.length); if (!r.ok) return FAIL; footlist.push(...r.feet.map(f => f.foot)); } else if (currlen < normlen) { const candidate = scansion.indexOf('x//'); if (candidate % 2 !== 0) return FAIL; // also true for candidate===-1 (JS -1%2===-1) const r1 = footFinder(footDict, scansion, 2, 0, candidate); if (!r1.ok) return FAIL; footlist.push(...r1.feet.map(f => f.foot)); footlist.push('defective'); const r2 = footFinder(footDict, scansion, 2, candidate + 1, scansion.length); if (!r2.ok) return FAIL; footlist.push(...r2.feet.map(f => f.foot)); } else { const need = currlen - normlen; const candidates: number[] = []; for (let p = 0; p <= scansion.length - 4; p++) if (scansion.slice(p, p + 4) === '/xx/') candidates.push(p + 1); if (candidates.length < need) { for (let p = 0; p <= scansion.length - 3; p++) if (scansion.slice(p, p + 3) === 'xx/') candidates.push(p); } let i = 0; while (i < currlen) { if (candidates.includes(i)) { footlist.push('anapest'); i += 3; } else { const chunk = scansion.slice(i, i + 2); if (!(chunk in footDict)) return FAIL; footlist.push(footDict[chunk]); i += 2; } } } if (lastfoot) footlist.push(lastfoot); } else { // Algorithm 2: Maximize the Normal const match = longestMatch(POSS_IAMB_RE, scansion); if (!match) return FAIL; let { start: startoflongest, length: longest } = match; if (startoflongest % 2 === 0) { const r = footFinder(footDict, scansion, 2, 0, startoflongest); if (!r.ok) return FAIL; footlist.push(...r.feet.map(f => f.foot)); } else if (scansion.slice(0, 2) === '/x') { footlist.push('defective'); const r = footFinder(footDict, scansion, 2, 1, startoflongest); if (!r.ok) return FAIL; footlist.push(...r.feet.map(f => f.foot)); } else { const anap = scansion.slice(0, startoflongest).indexOf('xx/'); if (anap === -1) return FAIL; const rHead = footFinder(footDict, scansion, 2, 0, anap); if (!rHead.ok) return FAIL; footlist.push(...rHead.feet.map(f => f.foot)); // Faithful replication of the source's own variable-shadowing quirk: // Hartman's loop variable here is literally named `longest`, silently // overwriting the true longestMatch() length with this footfinder // call's final index -- which the two statements below then inherit. longest = rHead.feet.length ? rHead.feet[rHead.feet.length - 1].index : anap; footlist.push('anapest'); const rTail = footFinder(footDict, scansion, 2, anap + 3, startoflongest); if (!rTail.ok) return FAIL; footlist.push(...rTail.feet.map(f => f.foot)); } const rMain = footFinder(footDict, scansion, 2, startoflongest, startoflongest + longest); if (!rMain.ok) return FAIL; footlist.push(...rMain.feet.map(f => f.foot)); let tailScansion = scansion.slice(startoflongest + longest); if (tailScansion.length > 0) { let lastfoot = ''; if (tailScansion[tailScansion.length - 1] === 'x' && tailScansion.length > 2) { const tail3 = tailScansion.slice(-3); if (tail3 in footDict) { lastfoot = footDict[tail3]; tailScansion = tailScansion.slice(0, -3); } } const rTailFeet = footFinder(footDict, tailScansion, 2, 0, tailScansion.length); if (!rTailFeet.ok) return FAIL; footlist.push(...rTailFeet.feet.map(f => f.foot)); if (lastfoot) footlist.push(lastfoot); } } // NOTE: the original's trailing "for inx,f: if f=='pyrrhic': ... f = // '(iamb)'" loop reassigns only the local loop variable, never // footlist[inx] -- a documented no-op in the source, correctly omitted. const boundstest = this.P.FeetAtPunctBounds(footlist); return { footlist, boundstest, ok: true }; } private measureComplexity(footlist: string[], boundstest: boolean[]): number { if (footlist.length !== this.LD.lfeet) return 100; let points = 0; let prevIsTrochee = false; for (let inx = 0; inx < footlist.length; inx++) { let f = footlist[inx]; if (f.startsWith('(')) f = f.slice(1, -1); if (['spondee', 'pyrrhic', 'trochee'].includes(f)) points += 2; if (['anapest', 'defective', '3rd paeon', 'amphibrach', 'palimbacchius', '2nd paeon'].includes(f)) points += 4; if (['dactyl', 'cretic', 'bacchius'].includes(f)) points += 10; if (f === 'trochee') { if (inx === footlist.length - 1) points += 6; if (prevIsTrochee) points += 8; prevIsTrochee = true; } else { prevIsTrochee = false; } if ((f === 'trochee' || f === 'defective') && !boundstest[inx]) points += 4; } return points; } private computeIambicCandidates(): Array<{ marks: string; algorithm: 1 | 2; footlist: string[]; complexity: number }> { const possScansions = this.P.GetAmbiguities(); const out: Array<{ marks: string; algorithm: 1 | 2; footlist: string[]; complexity: number }> = []; for (const marks of possScansions) { for (const algorithm of [1, 2] as const) { const { footlist, boundstest } = this.DoAlgorithm(algorithm, marks); out.push({ marks, algorithm, footlist, complexity: this.measureComplexity(footlist, boundstest) }); } } return out; } // ─── Step-by-step derivation of the REAL displayed footlist ───────────── // // DoAlgorithm's own footlist (above) is Hartman's "quick, silent" version, // used ONLY to score candidates for ChooseAlgorithm's arbitration -- it is // NOT what a Scandroid user actually sees. The real displayed result comes // from a SEPARATE step-by-step derivation (WeirdEnds+TestLengthAndDice for // algorithm 1, TryREs+CleanUpRE for algorithm 2), which turns out to differ // from DoAlgorithm's own result in several real cases (verified empirically // against the adapted 2005 source: DoAlgorithm's defective-foot branch uses // a stale `candidate` where TestLengthAndDice adds +2; several disyllable- // pattern edge cases DoAlgorithm rejects that the step-by-step path // accepts). So: DoAlgorithm scores candidates; THIS re-derives the winner // for real, exactly as Hartman's own GUI flow does after ChooseAlgorithm. /** Algorithm 1's step-by-step pass (WeirdEnds then TestLengthAndDice). */ private weirdEnds(): void { const endfeet = ['x/xx', 'xx/x', 'x/x', '//x']; const marks = this.P.GetMarks(); const normlen = this.LD.lfeet * 2; const currlen = marks.length; let lastfootstring = ''; if (currlen > normlen + 1 && endfeet.includes(marks.slice(-4))) lastfootstring = marks.slice(-4); else if (currlen >= normlen && endfeet.includes(marks.slice(-3))) lastfootstring = marks.slice(-3); if (lastfootstring) { this.LD.lastfoot = footDict[lastfootstring]; this.P.AddFootDivMark(marks.length - lastfootstring.length); } else { this.LD.lastfoot = ''; } if (currlen - lastfootstring.length <= normlen - 2 && (marks.startsWith('/x/x') || marks.startsWith('/xxx'))) { this.LD.footlist.push('defective'); this.P.AddFootDivMark(1); this.LD.midremain = [1, currlen - lastfootstring.length]; } else { this.LD.midremain = [0, currlen - lastfootstring.length]; } } private testLengthAndDice(): boolean { let normlen = (this.LD.lfeet - this.LD.footlist.length) * 2; if (this.LD.lastfoot) normlen -= 2; const start = this.LD.midremain[0]; const end = this.LD.midremain[1]; const currlen = end - start; const marks = this.P.GetMarks(); if (currlen === normlen) { const r = footFinder(footDict, marks, 2, start, end); if (!r.ok) return false; for (const { foot, index } of r.feet) { this.LD.footlist.push(foot); if (index < end) this.P.AddFootDivMark(index + this.LD.midremain[0]); } } else if (currlen < normlen) { const rel = marks.slice(start, end).indexOf('x//'); const candidateRaw = rel === -1 ? -1 : rel + start; if (candidateRaw % 2 !== 0) return false; const candidate = candidateRaw + 2; // "point directly at the defective foot" const r1 = footFinder(footDict, marks, 2, start, candidate); if (!r1.ok) return false; for (const { foot, index } of r1.feet) { this.LD.footlist.push(foot); this.P.AddFootDivMark(index); } this.LD.footlist.push('defective'); this.P.AddFootDivMark(candidate + 1); const r2 = footFinder(footDict, marks, 2, candidate + 1, end); if (!r2.ok) return false; for (const { foot, index } of r2.feet) { this.LD.footlist.push(foot); if (index < end) this.P.AddFootDivMark(index); } } else { let need = currlen - normlen; const candidates: number[] = []; for (let p = 0; p <= marks.length - 4; p++) if (marks.slice(p, p + 4) === '/xx/') candidates.push(p + 1); if (candidates.length < need) { for (let p = 0; p <= marks.length - 3; p++) if (marks.slice(p, p + 3) === 'xx/') candidates.push(p); } let pos = start; while (pos < end) { if (need && candidates.includes(pos)) { const foot = marks.slice(pos, pos + 3); if (!(foot in footDict)) return false; this.LD.footlist.push(footDict[foot]); pos += 3; need -= 1; } else { const foot = marks.slice(pos, pos + 2); if (!(foot in footDict)) return false; this.LD.footlist.push(footDict[foot]); pos += 2; } if (pos < end) this.P.AddFootDivMark(pos); } } if (this.LD.lastfoot) this.LD.footlist.push(this.LD.lastfoot); return true; } /** Algorithm 2's step-by-step pass (TryREs then CleanUpRE). */ private tryREs(): boolean { const marks = this.P.GetMarks(); const match = longestMatch(POSS_IAMB_RE, marks); if (!match) return false; const { start: startlongest, length: longest } = match; const runend = startlongest + longest; this.P.AddFootDivMark(startlongest); this.P.AddFootDivMark(runend); this.LD.hremain = [0, startlongest]; this.LD.midremain = [runend, marks.length]; const r = footFinder(footDict, marks, 2, startlongest, runend); if (!r.ok) return false; for (const { foot, index } of r.feet) { this.LD.footlist.push(foot); if (index < marks.length) this.P.AddFootDivMark(index); } return true; } private cleanUpRE(): boolean { const marks = this.P.GetMarks(); this.P.RemoveEndFootMarks(); const head = this.LD.hremain[1]; // hremain[0] is always 0 const tail = marks.length - this.LD.midremain[0]; let insertpoint = 0; if (head && head % 2 === 0) { const r = footFinder(footDict, marks, 2, 0, head); if (!r.ok) return false; for (const { foot, index } of r.feet) { this.LD.footlist.splice(insertpoint, 0, foot); this.P.AddFootDivMark(index); insertpoint += 1; } } else if (head) { if (marks.slice(0, 2) === '/x') { this.LD.footlist.splice(insertpoint, 0, 'defective'); insertpoint += 1; const r = footFinder(footDict, marks, 2, 1, head); if (!r.ok) return false; for (const { foot, index } of r.feet) { this.LD.footlist.splice(insertpoint, 0, foot); this.P.AddFootDivMark(index); insertpoint += 2; // faithful: the source increments by 2 here, not 1 } } else { const anap = marks.slice(0, head).indexOf('xx/'); if (anap === -1) return false; const r1 = footFinder(footDict, marks, 2, 0, head); if (!r1.ok) return false; for (const { foot, index } of r1.feet) { this.LD.footlist.splice(insertpoint, 0, foot); this.P.AddFootDivMark(index); insertpoint += 1; } this.LD.footlist.push('anapest'); // faithful: appendFoot (tacked at the END), a source quirk -- not insertFoot const r2 = footFinder(footDict, marks, 2, anap + 3, head); if (!r2.ok) return false; for (const { foot, index } of r2.feet) { this.LD.footlist.splice(insertpoint, 0, foot); this.P.AddFootDivMark(index); insertpoint += 1; } } } if (tail) { let startlastfoot: number; if (marks[marks.length - 1] === 'x' && tail % 2 !== 0) { startlastfoot = marks.length - 3; const tail3 = marks.slice(-3); if (tail3 in footDict) { this.LD.lastfoot = footDict[tail3]; this.P.AddFootDivMark(startlastfoot); } else return false; } else { startlastfoot = marks.length; this.LD.lastfoot = ''; } const r3 = footFinder(footDict, marks, 2, this.LD.midremain[0], startlastfoot); if (!r3.ok) return false; for (const { foot, index } of r3.feet) { this.LD.footlist.push(foot); if (index < startlastfoot) this.P.AddFootDivMark(index); } } if (this.LD.lastfoot) this.LD.footlist.push(this.LD.lastfoot); return true; } /** Reset LD to a clean slate and re-derive the REAL footlist for one * (algorithm, marks) candidate via the step-by-step path. */ runIambicStepByStep(algorithm: 1 | 2, marks: string): boolean { this.LD.footlist = []; this.LD.lastfoot = ''; this.LD.hremain = [0, 0]; this.LD.midremain = [0, 0]; this.P.AdjustMarks(marks); if (algorithm === 1) { this.weirdEnds(); return this.testLengthAndDice(); } if (!this.tryREs()) return false; return this.cleanUpRE(); } /** The full "Corral the Weird" vs "Maximize the Normal" arbiter: DoAlgorithm * scores every stress-ambiguity resolution under both algorithms, then the * lowest-complexity winner is re-derived for real via the step-by-step path. */ ChooseAlgorithm(): { verdictAlgorithm: 1 | 2; verdictMarks: string; verdictOk: boolean; alg1Best: { marks: string; complexity: number } | null; alg2Best: { marks: string; complexity: number } | null; } | null { const candidates = this.computeIambicCandidates(); if (candidates.length === 0) return null; const lowest = Math.min(...candidates.map(c => c.complexity)); const winner = candidates.find(c => c.complexity === lowest)!; // first-best, deterministic const alg1 = candidates.filter(c => c.algorithm === 1); const alg2 = candidates.filter(c => c.algorithm === 2); const bestOf = (arr: typeof candidates) => { if (arr.length === 0) return null; const lo = Math.min(...arr.map(c => c.complexity)); const w = arr.find(c => c.complexity === lo)!; return w.complexity < 100 ? { marks: w.marks, complexity: w.complexity } : null; }; const verdictOk = this.runIambicStepByStep(winner.algorithm, winner.marks); return { verdictAlgorithm: winner.algorithm, verdictMarks: winner.marks, verdictOk, alg1Best: bestOf(alg1), alg2Best: bestOf(alg2), }; } /** Lightweight equivalent of ChooseAlgorithm(deducingParams=True): lowest * complexity score and its foot count, no mutation. */ ChooseAlgorithmComplexityOnly(): { score: number; length: number } { const candidates = this.computeIambicCandidates(); if (candidates.length === 0) return { score: 100, length: 0 }; const lowest = Math.min(...candidates.map(c => c.complexity)); const winner = candidates.find(c => c.complexity === lowest)!; return { score: lowest, length: winner.footlist.length }; } /** Identify and mark promoted stress in an iambic footlist (mutates * this.LD.footlist and this.P's charlist/foot-division marks). */ PromotePyrrhics(): { ok: boolean; promotions: number[] } { const fl = this.LD.footlist; if (this.LD.lfeetset && fl.length !== this.LD.lfeet) return { ok: false, promotions: [] }; const promotions: number[] = []; let sylinx = 0; for (let inx = 0; inx < fl.length; inx++) { const f = fl[inx]; // captured ONCE per iteration -- matches Python's `for inx, f in enumerate(fl)` if (f === 'pyrrhic') { if (inx < fl.length - 1 && (fl[inx + 1] === 'anapest' || fl[inx + 1] === '3rd paeon')) { fl[inx] = '(anapest)'; if (fl[inx + 1] === 'anapest') fl[inx + 1] = 'iamb'; else fl[inx + 1] = 'amphibrach'; this.P.AddScanMark(PROMOTED, sylinx + 2); this.P.EraseFootDivMark(sylinx + 2); this.P.AddFootDivMark(sylinx + 3); promotions.push(sylinx + 2); } else if (inx < fl.length - 1 && fl[inx + 1] === 'trochee') { return { ok: false, promotions }; // "bad pyrrhic (word wrongly stressed?)" } else if (inx === fl.length - 1 || !['spondee', 'palimbacchius'].includes(fl[inx + 1])) { fl[inx] = '(iamb)'; this.P.AddScanMark(PROMOTED, sylinx + 1); promotions.push(sylinx + 1); } } sylinx += footDictInverse[f].length; // uses the ORIGINAL (pre-reassignment) foot name, like Python's `f` } return { ok: true, promotions }; } HowWeDoing(): { ok: boolean; substitutions: number } { let substitutions = 0; for (const f of this.LD.footlist) if (f !== 'iamb' && f !== '(iamb)') substitutions += 1; if (this.LD.lfeetset && this.LD.footlist.length !== this.LD.lfeet) return { ok: false, substitutions }; return { ok: true, substitutions }; } // ─── Anapestic engine ──────────────────────────────────────────────── /** Run the whole anapestic scansion silently against one candidate marks * string. Returns [] on failure (an anapestic line always has >=1 foot, * so an empty footlist is an unambiguous failure sentinel, exactly as the * original's own `return []`). */ scanAnapestics(scansionIn: string): string[] { let scansion = scansionIn; let numsyls = scansion.length; let needfeet: number; if (this.LD.lfeetset) { needfeet = this.LD.lfeet; } else { let excess: number; needfeet = Math.floor(numsyls / 3); excess = numsyls % 3; if (scansion && scansion[scansion.length - 1] === SLACK) excess -= 1; if (excess > 0) needfeet += 1; const altlen = altLineLenCalc(scansion); needfeet = Math.max(needfeet, altlen); this.LD.lfeet = needfeet; } if (scansion.slice(-2) === 'xx') { scansion = scansion.slice(0, -1) + PROMOTED; this.P.AddScanMark(PROMOTED, scansion.length - 1); } let lastfoot = ''; if (scansion && scansion[scansion.length - 1] === SLACK) { let tailstart = scansion.lastIndexOf(STRESS); tailstart = pyRfind(scansion, STRESS, 0, tailstart); let tail = numsyls - tailstart - 1; // tailstart===-1 is a VALID case here, not a failure (Python does no such check) if (AnapSubs[scansion.slice(-tail)] !== undefined) { lastfoot = AnapSubs[scansion.slice(-tail)]; } else { tail += 1; if (AnapSubs[scansion.slice(-tail)] !== undefined) { lastfoot = AnapSubs[scansion.slice(-tail)]; } else return []; } needfeet -= 1; numsyls -= tail; scansion = scansion.slice(0, scansion.length - tail); } if (numsyls > needfeet * 3) return []; let footlist: string[] = []; if (numsyls === needfeet * 3) { scansion = this.anapPromoteSlack(scansion, false); const r = footFinder(AnapSubs, scansion, 3, 0, numsyls); if (!r.ok) return []; footlist = r.feet.map(f => f.foot); } else { const needDisyls = needfeet * 3 - numsyls; if (needDisyls > needfeet) return []; scansion = this.anapPromoteSlack(scansion, false); const numlist = '2'.repeat(needDisyls) + '3'.repeat(needfeet - needDisyls); const listoflists = uniquePermutations(numlist); let pat = ''; let thislldo = false; for (const candidate of listoflists) { thislldo = true; let index = 0; for (const foot of candidate) { index += parseInt(foot, 10); if (!'/%'.includes(scansion[index - 1])) { thislldo = false; break; } } pat = candidate; if (thislldo) break; } if (!thislldo) return []; let f = 0; for (const digit of pat) { const stride = parseInt(digit, 10); const endf = f + stride >= scansion.length ? scansion.length : f + stride; const chunk = scansion.slice(f, endf); if (AnapSubs[chunk] !== undefined) { footlist.push(AnapSubs[chunk]); f += stride; } else return []; } } if (lastfoot) footlist.push(lastfoot); return footlist; } private anapComplexity(footlist: string[]): number { if (footlist.length === 0) return 100; let points = 0; for (const f of footlist) { if (f === 'bacchius') points += 2; else if (f === '(anapest)') points += 1; else if (f === 'iamb' || f === '(iamb)') points += 2; else if (f === 'cretic') points += 4; else if (f === 'spondee' || f === 'pyrrhic') points += 4; else if (f === 'amphibrach' || f === '3rd paeon') points += 4; else if (f === '2nd paeon' || f === 'molossus' || f === 'palimbacchius') points += 5; } return points; } private computeAnapCandidates(): Array<{ marks: string; footlist: string[]; complexity: number }> { const possScansions = this.P.GetAmbiguities(); return possScansions.map(marks => { const footlist = this.scanAnapestics(marks); return { marks, footlist, complexity: this.anapComplexity(footlist) }; }); } GetBestAnapLexes(): { marks: string; footlist: string[] } | null { const candidates = this.computeAnapCandidates(); if (candidates.length === 0) return null; const lowest = Math.min(...candidates.map(c => c.complexity)); const winner = candidates.find(c => c.complexity === lowest)!; if (winner.footlist.length === 0) return null; this.P.AdjustMarks(winner.marks); this.anapPromoteSlack(winner.marks, true); // now WITH the real char-grid mark, for display this.LD.footlist = winner.footlist; return { marks: winner.marks, footlist: winner.footlist }; } GetBestAnapLexesComplexityOnly(): { score: number; length: number } { const candidates = this.computeAnapCandidates(); if (candidates.length === 0) return { score: 100, length: 0 }; const lowest = Math.min(...candidates.map(c => c.complexity)); const winner = candidates.find(c => c.complexity === lowest)!; return { score: lowest, length: winner.footlist.length }; } private anapPromoteSlack(scansion: string, insertMark: boolean): string { const slackrun = scansion.indexOf('xxxx'); if (slackrun === -1) return scansion; const out = scansion.slice(0, slackrun + 2) + PROMOTED + scansion.slice(slackrun + 3); if (insertMark) this.P.AddScanMark(PROMOTED, slackrun + 2); return out; } /** Final-condition check + cosmetic iamb+cretic -> bacchius+iamb pass over * the WINNING anapestic footlist (this.LD.footlist). */ AnapCleanUpAndReport(): { ok: boolean; substitutions: number; footAdjust: boolean; fail?: string } { const fl = this.LD.footlist; let substitutions = 0; let sylinx = 0; let footAdjust = false; // Deliberately `fl.length - 1`: the original's own range(len(fl)-1) // excludes the LAST foot from every check here, including the // substitution count -- reproduced exactly, not "fixed". for (let finx = 0; finx < fl.length - 1; finx++) { if (fl[finx] === 'amphibrach') { return { ok: false, substitutions, footAdjust, fail: 'amphibrach within anapestic line' }; } if (fl[finx] === 'iamb' && fl[finx + 1] === 'cretic') { fl[finx] = 'bacchius'; fl[finx + 1] = 'iamb'; this.P.EraseFootDivMark(sylinx + 2); this.P.AddFootDivMark(sylinx + 3); footAdjust = true; } if (fl[finx] !== 'anapest' && fl[finx] !== '(anapest)') substitutions += 1; sylinx += anapSubsInverse[fl[finx]].length; // re-reads fl[finx] AFTER any reassignment above, per source } if (this.LD.lfeetset && fl.length !== this.LD.lfeet) return { ok: false, substitutions, footAdjust }; return { ok: true, substitutions, footAdjust }; } }