cts / src /scandroidNative /engine.ts
AlekseyCalvin's picture
Upload 119 files
59f93ff verified
Raw
History Blame Contribute Delete
7.9 kB
// Top-level orchestration for the independent Scandroid engine. Public API:
// `scanTextNatively(rawLines)` takes ONLY the raw verse-line strings of a
// poem (or a single line) and returns one rich, self-contained result per
// line -- Hartman's own verdict, Hartman's own stress map, in Hartman's own
// symbols and foot names. Nothing here reads anything Calliope has already
// worked out about the same lines.
import { footDict, AnapSubs, lineLengthName, FOOTDIV } from './constants.js';
import { invertFootDict } from './utilities.js';
import { ScansionMachine } from './machine.js';
import { deduceParametersForPoem, deduceParametersForSingleLine, type ScandroidParams, type Metron } from './deduceParameters.js';
const footDictInverse = invertFootDict(footDict);
const anapSubsInverse = invertFootDict(AnapSubs);
export interface ScandroidNativeFootResult {
/** 'Corral the Weird' | 'Maximize the Normal' | 'Anapestic' */
algorithm: string;
footlist: string[];
/** Feet joined by FOOTDIV ('|'), each foot's own STRESS/SLACK/PROMOTED marks. */
scanString: string;
/** The same marks with no foot divisions -- one character per syllable. */
marksString: string;
substitutions: number;
/** false = FAIL: wrong foot count against a fixed line length, a pyrrhic
* before a trochee, an amphibrach inside an anapestic line, etc. */
ok: boolean;
failReason?: string;
}
export interface ScandroidNativeLineResult {
metron: Metron;
metronName: string; // 'IAMBIC' | 'ANAPESTIC', Hartman's own status-bar wording
lineFeet: number;
lineFeetSet: boolean;
lineLengthName: string; // e.g. 'PENTAMETER', or 'VARIABLE' when lineFeetSet is false
/** The ChooseAlgorithm / GetBestAnapLexes winner -- Scandroid's own verdict. */
verdict: ScandroidNativeFootResult | null;
/** Iambic lines only: each algorithm's own independent best, for side-by-side display. */
corralTheWeird?: ScandroidNativeFootResult | null;
maximizeTheNormal?: ScandroidNativeFootResult | null;
/** 0-based syllable indices where a pyrrhic/slack-run was promoted to a beat. */
promotions: number[];
}
// Display-only helper (not a port of anything in the 2005 source): walks the
// footlist in pattern-length chunks to insert FOOTDIV between feet. On a
// length-mismatch FAIL the footlist can run past the syllables actually
// available (CleanUpRE/TestLengthAndDice keep assembling feet from whatever
// state a wrong-length line leaves them in) -- stop once marks are exhausted
// rather than emit a trail of empty '||||' segments.
function renderFootDividedString(marks: string, footlist: string[], patternDict: Record<string, string>): string {
const parts: string[] = [];
let pos = 0;
for (const foot of footlist) {
if (pos >= marks.length) break;
const len = patternDict[foot]?.length ?? 0;
parts.push(marks.slice(pos, pos + len));
pos += len;
}
return parts.join(FOOTDIV);
}
function buildFootResult(
algorithm: string,
footlist: string[],
marks: string,
substitutions: number,
ok: boolean,
patternDict: Record<string, string>,
failReason?: string
): ScandroidNativeFootResult {
return {
algorithm,
footlist: footlist.slice(),
scanString: renderFootDividedString(marks, footlist, patternDict),
marksString: marks,
substitutions,
ok,
failReason,
};
}
function runIndividualIambic(
line: string,
params: ScandroidParams,
algorithm: 1 | 2,
candidate: { marks: string; complexity: number },
algLabel: string
): ScandroidNativeFootResult {
const m = new ScansionMachine();
m.SetLineFeet(params.lineFeet, params.lineFeetSet);
m.ParseLine(line);
// Mirror DoAlgorithm's own auto-length side effect (this candidate never ran
// DoAlgorithm on THIS fresh machine, since we're going straight to the
// step-by-step path) -- every candidate shares the same total syllable
// count, so this is exactly what DoAlgorithm would have set LD.lfeet to.
if (!params.lineFeetSet) m.LD.lfeet = Math.floor(candidate.marks.length / 2);
const stepOk = m.runIambicStepByStep(algorithm, candidate.marks);
const promote = m.PromotePyrrhics();
const howwedoing = m.HowWeDoing();
return buildFootResult(algLabel, m.LD.footlist, m.P.GetMarks(), howwedoing.substitutions, stepOk && promote.ok && howwedoing.ok, footDictInverse);
}
function lengthName(feet: number, feetSet: boolean): string {
return feetSet ? lineLengthName[feet] ?? '' : 'VARIABLE';
}
/** Scan one line under already-deduced poem-level (or single-line) parameters. */
export function scanLineNatively(line: string, params: ScandroidParams): ScandroidNativeLineResult {
const metronName = params.metron === 'iambic' ? 'IAMBIC' : 'ANAPESTIC';
if (params.metron === 'iambic') {
const machine = new ScansionMachine();
machine.SetLineFeet(params.lineFeet, params.lineFeetSet);
machine.ParseLine(line);
const chosen = machine.ChooseAlgorithm();
if (!chosen) {
return {
metron: 'iambic', metronName, lineFeet: params.lineFeet, lineFeetSet: params.lineFeetSet,
lineLengthName: lengthName(params.lineFeet, params.lineFeetSet),
verdict: null, corralTheWeird: null, maximizeTheNormal: null, promotions: [],
};
}
const promote = machine.PromotePyrrhics();
const howwedoing = machine.HowWeDoing();
const verdict = buildFootResult(
chosen.verdictAlgorithm === 1 ? 'Corral the Weird' : 'Maximize the Normal',
machine.LD.footlist,
machine.P.GetMarks(),
howwedoing.substitutions,
chosen.verdictOk && promote.ok && howwedoing.ok,
footDictInverse
);
const corral = chosen.alg1Best ? runIndividualIambic(line, params, 1, chosen.alg1Best, 'Corral the Weird') : null;
const maximize = chosen.alg2Best ? runIndividualIambic(line, params, 2, chosen.alg2Best, 'Maximize the Normal') : null;
return {
metron: 'iambic', metronName, lineFeet: machine.LD.lfeet, lineFeetSet: params.lineFeetSet,
lineLengthName: lengthName(machine.LD.lfeet, params.lineFeetSet),
verdict, corralTheWeird: corral, maximizeTheNormal: maximize, promotions: promote.promotions,
};
}
// Anapestic
const machine = new ScansionMachine();
machine.SetLineFeet(params.lineFeet, params.lineFeetSet);
machine.ParseLine(line);
const best = machine.GetBestAnapLexes();
if (!best) {
return {
metron: 'anapestic', metronName, lineFeet: params.lineFeet, lineFeetSet: params.lineFeetSet,
lineLengthName: lengthName(params.lineFeet, params.lineFeetSet),
verdict: null, promotions: [],
};
}
const cleanup = machine.AnapCleanUpAndReport();
const verdict = buildFootResult(
'Anapestic', machine.LD.footlist, machine.P.GetMarks(), cleanup.substitutions,
cleanup.ok, anapSubsInverse, cleanup.fail
);
return {
metron: 'anapestic', metronName, lineFeet: machine.LD.lfeet, lineFeetSet: params.lineFeetSet,
lineLengthName: lengthName(machine.LD.lfeet, params.lineFeetSet),
verdict, promotions: [],
};
}
/** Scan a whole poem (or a single line): deduces Metron/line-length exactly
* once over the full raw-line list -- matching Scandroid's own "loaded
* document" behaviour for 2+ lines, and its "typed single line" behaviour
* (variable length, per-line Metron) for exactly one line -- then scans
* every line under those shared parameters. `rawLines` should be every
* non-blank line of the poem in reading order, spanning stanza breaks: the
* original deduces Metron/length from up to a dozen lines of the whole
* loaded document, not per stanza. */
export function scanTextNatively(rawLines: string[]): ScandroidNativeLineResult[] {
if (rawLines.length === 0) return [];
const params = rawLines.length < 2 ? deduceParametersForSingleLine(rawLines[0]) : deduceParametersForPoem(rawLines);
return rawLines.map(line => scanLineNatively(line, params));
}