cts / src /scandroidNative /machine.ts
AlekseyCalvin's picture
Upload 119 files
59f93ff verified
Raw
History Blame Contribute Delete
30.9 kB
// 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 };
}
}