Spaces:
Running
Running
| import { describe, it, expect } from 'vitest'; | |
| import { parseDocument, isPunctuation } from '../src/parser.js'; | |
| import { | |
| assignLexicalStress, | |
| applyCompoundStress, | |
| applyNuclearStress, | |
| assignRelativeStresses, | |
| } from '../src/stress.js'; | |
| import { computePhraseStress } from '../src/phrasestress.js'; | |
| import { buildPhonologicalHierarchy } from '../src/phonological.js'; | |
| import { extractKeyStresses, scoreMeters } from '../src/scansion.js'; | |
| import { ScansionMachine } from '../src/scandroidNative/machine.js'; | |
| import { scanLineNatively } from '../src/scandroidNative/engine.js'; | |
| import { analyzeText, feedMultilineEvent, newMLState, ML_IDLE_MS, type MLEvent } from '../src/index.js'; | |
| import type { PhonologicalScansionDetail } from '../src/types.js'; | |
| import { classifyRhymePair, detectScheme, summarizePoem, analyzePhonopoetics, rhymeKey } from '../src/rhyme.js'; | |
| import { metricalityVerdict } from '../src/scansion.js'; | |
| import { analyzeStanzas } from '../src/index.js'; | |
| import { computeCaesurae } from '../src/caesura.js'; | |
| /** Drive the multi-line input reducer with a synthetic event sequence. */ | |
| function runMultiline(events: MLEvent[]): { result: string; lines: string[] } { | |
| const st = newMLState(); | |
| let result = 'continue'; | |
| for (const ev of events) { | |
| result = feedMultilineEvent(st, ev); | |
| if (result !== 'continue') break; | |
| } | |
| return { result, lines: st.lines }; | |
| } | |
| const FAST = ML_IDLE_MS - 50; // burst-speed (paste) | |
| const SLOW = ML_IDLE_MS + 400; // deliberate keystroke | |
| /** A whole line of pasted text: its chars (burst), then a burst-speed newline. */ | |
| function pasteLine(text: string, firstGap = FAST): MLEvent[] { | |
| const evs: MLEvent[] = []; | |
| [...text].forEach((c, i) => evs.push({ kind: 'char', str: c, gap: i === 0 ? firstGap : FAST })); | |
| evs.push({ kind: 'return', gap: FAST }); | |
| return evs; | |
| } | |
| function scanLine(line: string): PhonologicalScansionDetail { | |
| const results = analyzeText(line, false); | |
| return results[0].phonologicalScansion; | |
| } | |
| function runPipeline(line: string) { | |
| const doc = parseDocument(line); | |
| const sent = doc.sentences[0]; | |
| assignLexicalStress(sent.words); | |
| const ius = buildPhonologicalHierarchy(sent); | |
| applyCompoundStress(ius); | |
| applyNuclearStress(ius); | |
| computePhraseStress(sent.words); // McAleese's Phrase-Stress phase (matches the production pipeline) | |
| assignRelativeStresses(sent.words, ius); | |
| const keys = extractKeyStresses(ius, sent.words); | |
| const result = scoreMeters(keys, sent.words, ius); | |
| return { result, sent, ius, keys }; | |
| } | |
| function hasEmptyFeet(s: string): boolean { | |
| return s.includes('||') || s.startsWith('|') || s.endsWith('|'); | |
| } | |
| describe('parser', () => { | |
| it('parses a simple sentence into ClsDocument', () => { | |
| const doc = parseDocument('I love you'); | |
| expect(doc.sentences).toHaveLength(1); | |
| const sent = doc.sentences[0]; | |
| expect(sent.words).toHaveLength(3); | |
| expect(sent.words[0].word).toBe('i'); | |
| expect(sent.words[0].lexicalClass).toBeTruthy(); | |
| expect(sent.dependencies).toHaveLength(3); | |
| const rootDep = sent.dependencies.find(d => d.governorIndex === 0); | |
| expect(rootDep).toBeTruthy(); | |
| }); | |
| }); | |
| describe('contraction handling', () => { | |
| it("merges we'll into a single word", () => { | |
| const r = scanLine("In Petersburg once more we'll be united"); | |
| expect(r).toBeDefined(); | |
| expect(r.meterName).toBeDefined(); | |
| }); | |
| it("merges we've into a single word", () => { | |
| const r = scanLine("Like in a place where we've entombed the sun"); | |
| expect(r).toBeDefined(); | |
| }); | |
| it("merges I've into a single word", () => { | |
| const doc = parseDocument("I've returned to my city, I know it to tears"); | |
| const w = doc.sentences[0].words.find(x => x.word === "i've"); | |
| expect(w).toBeDefined(); | |
| }); | |
| it("merges don't into a single word", () => { | |
| const doc = parseDocument("don't you think the sun is brighter now"); | |
| const w = doc.sentences[0].words.find(x => x.word === "don't"); | |
| expect(w).toBeDefined(); | |
| }); | |
| it("merges it's into a single word", () => { | |
| const doc = parseDocument("it's a nice day"); | |
| const w = doc.sentences[0].words.find(x => x.word === "it's"); | |
| expect(w).toBeDefined(); | |
| expect(doc.sentences[0].words.length).toBe(4); | |
| }); | |
| it("merges can't into a single word", () => { | |
| const doc = parseDocument("I can't do it"); | |
| const w = doc.sentences[0].words.find(x => x.word === "can't"); | |
| expect(w).toBeDefined(); | |
| }); | |
| it("merges I'm into a single word", () => { | |
| const doc = parseDocument("I'm here now"); | |
| const w = doc.sentences[0].words.find(x => x.word === "i'm"); | |
| expect(w).toBeDefined(); | |
| expect(doc.sentences[0].words.length).toBe(3); | |
| }); | |
| it("merges might've into a single word", () => { | |
| const doc = parseDocument("I might've done that"); | |
| const w = doc.sentences[0].words.find(x => x.word === "might've"); | |
| expect(w).toBeDefined(); | |
| }); | |
| it('does not merge non-contraction host+clitic sequences', () => { | |
| const doc = parseDocument('we will go'); | |
| const sent = doc.sentences[0]; | |
| expect(sent.words[0].word).toBe('we'); | |
| expect(sent.words[1].word).toBe('will'); | |
| }); | |
| it('a contraction line has correct syllable count', () => { | |
| const doc = parseDocument("In Petersburg once more we'll be united"); | |
| assignLexicalStress(doc.sentences[0].words); | |
| const sylCount = doc.sentences[0].words.reduce((a, w) => a + w.syllables.length, 0); | |
| expect(sylCount).toBe(11); | |
| }); | |
| }); | |
| describe('stress', () => { | |
| function makeWords() { | |
| return [ | |
| { text: 'city', pos: 'NN', isContent: true }, | |
| { text: 'hall', pos: 'NN', isContent: true }, | |
| { text: 'without', pos: 'IN', isContent: false }, | |
| { text: 'permission', pos: 'NN', isContent: true }, | |
| { text: 'the', pos: 'DT', isContent: false }, | |
| ].map((w, i) => ({ | |
| index: i + 1, | |
| lexicalClass: w.pos, | |
| lexicalDetails: '', | |
| lexicalPlural: false, | |
| position: '', | |
| word: w.text, | |
| absoluteIndex: i, | |
| isContent: w.isContent, | |
| syllables: [], | |
| phraseStress: 0, | |
| })); | |
| } | |
| function mockIus(words: any[]): any { | |
| return [{ phonologicalPhrases: [{ cliticGroups: [{ tokens: words }] }] }]; | |
| } | |
| it('assignLexicalStress sets syllables with stress numbers', () => { | |
| const words = makeWords(); | |
| assignLexicalStress(words); | |
| for (const w of words) { | |
| expect(w.syllables.length).toBeGreaterThan(0); | |
| } | |
| expect(words[0].syllables.some(s => s.stress === 2)).toBe(true); | |
| expect(words[4].syllables.some(s => s.stress === 2)).toBe(false); | |
| }); | |
| it('applyCompoundStress adjusts adjacent noun-noun compounds', () => { | |
| const words = makeWords(); | |
| assignLexicalStress(words); | |
| applyCompoundStress(mockIus(words)); | |
| const cityMax = Math.max(...words[0].syllables.map(s => s.stress)); | |
| const hallMax = Math.max(...words[1].syllables.map(s => s.stress)); | |
| expect(cityMax).toBe(2); | |
| expect(hallMax).toBe(1); | |
| }); | |
| it('applyNuclearStress boosts rightmost content word', () => { | |
| const words = makeWords(); | |
| assignLexicalStress(words); | |
| applyNuclearStress(mockIus(words)); | |
| const rightStress = words[3].syllables.reduce((a, b) => Math.max(a, b.stress), 0); | |
| const leftStress = words[0].syllables.reduce((a, b) => Math.max(a, b.stress), 0); | |
| expect(rightStress).toBeGreaterThanOrEqual(leftStress); | |
| }); | |
| it('assignRelativeStresses converts numeric to symbolic levels', () => { | |
| const words = makeWords(); | |
| assignLexicalStress(words); | |
| if (words[1] && words[4]) { | |
| const dep = { | |
| governor: words[1], dependent: words[4], | |
| governorIndex: 2, dependentIndex: 5, dependentType: 'det', | |
| governorName: 'hall', dependentName: 'the', index: 5, | |
| }; | |
| words[1].dependency = dep as any; | |
| words[4].dependency = dep as any; | |
| } | |
| assignRelativeStresses(words, mockIus(words)); | |
| for (const w of words) { | |
| for (const s of w.syllables) { | |
| expect(['x', 'w', 'n', 'm', 's']).toContain(s.relativeStress); | |
| } | |
| } | |
| }); | |
| }); | |
| describe('phonological', () => { | |
| it('builds IU/PP/CP hierarchy for a simple sentence', () => { | |
| const doc = parseDocument('The planet orbits a star'); | |
| const sent = doc.sentences[0]; | |
| assignLexicalStress(sent.words); | |
| const ius = buildPhonologicalHierarchy(sent); | |
| expect(ius.length).toBeGreaterThan(0); | |
| expect(ius[0].phonologicalPhrases.length).toBeGreaterThan(0); | |
| expect(ius[0].phonologicalPhrases[0].cliticGroups.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| describe('scansion', () => { | |
| it('Shall I compare thee? — iambic', () => { | |
| const { result } = runPipeline("Shall I compare thee to a summer's day?"); | |
| expect(result.meterName).toBe('iambic'); | |
| // Honest baseline: "Shall" (MD) and "I" (PRP) floor to overt-weak 'w' (the old | |
| // key's leading 'n·n' was itself a clash); "to" and the article "a" read 'x' | |
| // (zero-provision clitics). Clash-free. | |
| expect(result.scansion).toBe('ww|ws|wx|xm|ws'); | |
| expect(result.footCount).toBeGreaterThan(0); | |
| }); | |
| it('The Assyrian came down — anapestic', () => { | |
| const { result } = runPipeline('The Assyrian came down like the wolf on the fold'); | |
| expect(result.meterName).toBe('anapestic'); | |
| // The three "the" plus the prepositions "like"/"on" (IN) all read 'x' | |
| // (zero-provision clitics), so the anapestic upbeats "like·the·WOLF / | |
| // on·the·FOLD" read x·x·s — textbook anapests. | |
| expect(result.scansion).toBe('xws|wsm|xxs|xxs'); | |
| }); | |
| it('I\'ve returned to my city — anapestic', () => { | |
| const { result } = runPipeline("I've returned to my city, I know it to tears"); | |
| expect(result.meterName).toBe('anapestic'); | |
| // "to"/"it" and the possessive "my" (PRP$) all read 'x' (zero-provision); | |
| // "I've"/"I"/"know" floor as their classes dictate. "to·my·CIT(y)" = x·x·s. | |
| expect(result.scansion).toBe('wwm|xxs|wws|xxs'); | |
| }); | |
| it('through Eden took — iambic', () => { | |
| const { result } = runPipeline('Through Eden took their solitary way'); | |
| expect(result.meterName).toBe('iambic'); | |
| // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics), | |
| // giving clean rising feet "through·E / …their·SOL". | |
| expect(result.scansion).toBe('xm|ws|xm|wn|ws'); | |
| }); | |
| it('free verse for nonsensical empty input', () => { | |
| const result = scoreMeters([], []); | |
| expect(result.meterName).toBe('free verse'); | |
| expect(result.scansion).toBe(''); | |
| }); | |
| it("You've returned here — anapestic (Mandelstam, 'Leningrad')", () => { | |
| // Translation of Mandelstam's anapestic-tetrameter "Ты вернулся сюда…": | |
| // you've·re·TURNED | here·so·SWAL | low·then·FAST | as·you·MIGHT. Resolved by | |
| // (1) treating the line-final modal "might" as the final beat ("endings | |
| // strict"), (2) the trailing-function-run rule that keeps "as you" as upbeat, | |
| // (3) the coarse rising-onset cue, and (4) the project's slight ternary bias. | |
| const { result } = runPipeline("You've returned here, so swallow then, fast as you might"); | |
| expect(result.meterName).toBe('anapestic'); | |
| }); | |
| it('And onto shores — iambic', () => { | |
| const { result } = runPipeline('And onto shores, with scorching fishes'); | |
| expect(result.meterName).toBe('iambic'); | |
| }); | |
| it('We all live — anapestic', () => { | |
| const { result } = runPipeline('We all live, underneath us no country we sense'); | |
| expect(result.meterName).toBe('anapestic'); | |
| }); | |
| it('Woolen gray hat — dactylic', () => { | |
| const { result } = runPipeline('Woolen gray hat and the crimson stars shining'); | |
| expect(result.meterName).toBe('dactylic'); | |
| }); | |
| it('keeps making blue holes — amphibrachic', () => { | |
| const { result } = runPipeline('keeps making blue holes in the waterproof gloss'); | |
| expect(result.meterName).toBe('amphibrachic'); | |
| }); | |
| it('This ivy resembles — amphibrachic', () => { | |
| const { result } = runPipeline('This ivy resembles the eyes of the deaf.'); | |
| expect(result.meterName).toBe('amphibrachic'); | |
| }); | |
| it('We charged at the foe — anapestic', () => { | |
| const { result } = runPipeline('We charged at the foe, and we camped on the heath,'); | |
| expect(result.meterName).toBe('anapestic'); | |
| }); | |
| it('All this time — trochaic', () => { | |
| const { result } = runPipeline("All this time, you'd softly utter, years on end"); | |
| expect(result.meterName).toBe('trochaic'); | |
| }); | |
| it('The strong wine flows down fast — bacchic', () => { | |
| const { result } = runPipeline('The strong wine flows down fast'); | |
| expect(result.meterName).toBe('bacchic'); | |
| }); | |
| it('But he gave no one else — maximally modulated (Tarlinskaja)', () => { | |
| const { result } = runPipeline("But he gave no one else a laugher's license."); | |
| // Tarlinskaja's textbook *maximally-modulated* iambic pentameter — iambic ONLY | |
| // by its poem's metre. With the function words at honest weak prominence | |
| // ("But"→x, "he"→w, "a"→x) the line opens "but·he·GAVE" (a bare anapest) and | |
| // standalone it scans anapestic; unlike "What had I given" (below) it fits | |
| // iambic too poorly for stanza-consensus to recover. We assert the honest | |
| // standalone reading and the clash-free invariant rather than forcing iambic by | |
| // over-stressing the clitics (the gaming the old 'n·n'-laden key relied on). | |
| // FLAGGED in AGENTS.md (2026-06-16) as the one meter-call casualty of honest | |
| // function-word weakening — a candidate for a context/scorer follow-up. | |
| expect(result.meterName).toBe('anapestic'); | |
| expect(result.scansion).toBe('xws|ns|mxm|wsw'); | |
| expect(hasEmptyFeet(result.scansion)).toBe(false); | |
| }); | |
| }); | |
| describe('expert baseline', () => { | |
| it('If hairs be wires — iambic', () => { | |
| const { result } = runPipeline('If hairs be wires, black wires grow on her head'); | |
| expect(result.meterName).toBe('iambic'); | |
| // "If"/"on" (IN) and the possessive "her" (PRP$) read 'x'; "be" (BE) → 'w'. | |
| // The OLD key carried a *black·wires = m·m clash* (`mm|sw`); the clash filter | |
| // now resolves it to gradient "black(n)·wires(m)" → `nm|sx`. Clash-free. | |
| expect(result.scansion).toBe('xm|ws|nm|sx|xs'); | |
| }); | |
| it('Through Eden took — iambic', () => { | |
| const { result } = runPipeline('Through Eden took their solitary way'); | |
| expect(result.meterName).toBe('iambic'); | |
| // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics). | |
| expect(result.scansion).toBe('xm|ws|xm|wn|ws'); | |
| }); | |
| it('There they are my fifty men — trochaic', () => { | |
| const { result } = runPipeline('There they are my fifty men and women'); | |
| expect(result.meterName).toBe('trochaic'); | |
| }); | |
| it('What had I given — iambic (recovered by stanza context)', () => { | |
| // Standalone, with honest weak function words, this maximally-modulated line | |
| // scans amphibrachic; embedded in its iambic poem the stanza-consensus layer | |
| // correctly recovers iambic (Tarlinskaja: a line's iambicity can be contextual). | |
| // We test that real mechanism — the line among plain iambic neighbours. | |
| const lines = analyzeText( | |
| 'I sat alone and watched the embers leap\n' + | |
| 'What had I given to hear the soft sweep\n' + | |
| 'Of wings that crossed the cold and silent deep', false); | |
| expect(lines[1].phonologicalScansion.meterName).toBe('iambic'); | |
| }); | |
| it('Lies the subject of all verse — trochaic', () => { | |
| const { result } = runPipeline('Lies the subject of all verse'); | |
| expect(result.meterName).toBe('trochaic'); | |
| // 'x' = reduced clitics "the" (DT) and "of" (IN); "all" (PDT) stays content 'n'. | |
| expect(result.scansion).toBe('sx|sw|xn|s'); | |
| }); | |
| }); | |
| describe('scansion quality', () => { | |
| const lines = [ | |
| 'If hairs be wires, black wires grow on her head', | |
| 'Through Eden took their solitary way', | |
| 'What had I given to hear the soft sweep', | |
| "Shall I compare thee to a summer's day?", | |
| 'Gone were but the Winter, come were but the Spring', | |
| 'The Assyrian came down like the wolf on the fold', | |
| ]; | |
| it('no metered scansion has empty feet', () => { | |
| for (const line of lines) { | |
| const { result } = runPipeline(line); | |
| if (result.meterName !== 'free verse') { | |
| expect(hasEmptyFeet(result.scansion)).toBe(false); | |
| } | |
| } | |
| }); | |
| it('metered scansions contain foot breaks', () => { | |
| for (const line of lines) { | |
| const { result } = runPipeline(line); | |
| expect(result.scansion).toContain('|'); | |
| } | |
| }); | |
| }); | |
| describe('phonological notation', () => { | |
| it('includes CP, PP, IU boundary brackets', () => { | |
| const r = scanLine('The planet orbits a star'); | |
| expect(r.all).toMatch(/\[/); | |
| expect(r.all).toMatch(/\{/); | |
| expect(r.all).toMatch(/</); | |
| expect(r.keyStresses).toMatch(/\[/); | |
| }); | |
| it('includes stress symbols w m s n', () => { | |
| const r = scanLine('The planet orbits a star'); | |
| expect(r.all).toMatch(/[wmsn]/); | |
| expect(r.keyStresses).toMatch(/[wmsn]/); | |
| }); | |
| }); | |
| describe('multi-line input (paste-friendly)', () => { | |
| it('paste WITH trailing newline + one Enter submits, keeping stanza breaks', () => { | |
| // Two stanzas of two lines, paste ends in a newline, then a deliberate Enter. | |
| const evs: MLEvent[] = [ | |
| ...pasteLine('line one', SLOW), // first char gap is large (since reader just opened) | |
| ...pasteLine('line two'), | |
| { kind: 'return', gap: FAST }, // blank line within the burst = stanza break | |
| ...pasteLine('line three'), | |
| ...pasteLine('line four'), // burst ends with a trailing newline | |
| { kind: 'return', gap: SLOW }, // the user's single deliberate Enter → submit | |
| ]; | |
| const { result, lines } = runMultiline(evs); | |
| expect(result).toBe('submit'); | |
| // The blank stanza break is preserved between the two couplets. | |
| expect(lines.filter(l => l.trim() === '').length).toBe(1); | |
| expect(lines.filter(l => l.trim() !== '')).toEqual(['line one', 'line two', 'line three', 'line four']); | |
| }); | |
| it('paste WITHOUT trailing newline + one Enter submits, flushing the last line', () => { | |
| const evs: MLEvent[] = [ | |
| ...pasteLine('first line', SLOW), | |
| // last line has no burst newline; the user just presses Enter once: | |
| ...[...'last line'].map((c, i) => ({ kind: 'char', str: c, gap: i === 0 ? FAST : FAST } as MLEvent)), | |
| { kind: 'return', gap: SLOW }, // deliberate Enter after the paste burst → submit + flush | |
| ]; | |
| const { result, lines } = runMultiline(evs); | |
| expect(result).toBe('submit'); | |
| expect(lines.filter(l => l.trim() !== '')).toEqual(['first line', 'last line']); | |
| }); | |
| it('Esc cancels (returns to menu)', () => { | |
| const { result } = runMultiline([...pasteLine('something'), { kind: 'escape' }]); | |
| expect(result).toBe('cancel'); | |
| }); | |
| it('slow hand-typing: a blank line finishes', () => { | |
| const evs: MLEvent[] = [ | |
| ...[...'typed line'].map((c) => ({ kind: 'char', str: c, gap: SLOW } as MLEvent)), | |
| { kind: 'return', gap: SLOW }, // end of the typed line (no burst seen) → line break | |
| { kind: 'return', gap: SLOW }, // blank line → submit | |
| ]; | |
| const { result, lines } = runMultiline(evs); | |
| expect(result).toBe('submit'); | |
| expect(lines.filter(l => l.trim() !== '')).toEqual(['typed line']); | |
| }); | |
| }); | |
| describe('scandroid (independent second opinion)', () => { | |
| it('Corral the Weird (algorithm 1) scans regular iambic', () => { | |
| const m = new ScansionMachine(); | |
| m.SetLineFeet(5, true); | |
| const { footlist, ok } = m.DoAlgorithm(1, 'x/x/x/x/x/'); | |
| expect(ok).toBe(true); | |
| expect(footlist.length).toBe(5); | |
| expect(footlist.every(f => f === 'iamb')).toBe(true); | |
| }); | |
| it('Maximize the Normal (algorithm 2) scans regular iambic', () => { | |
| const m = new ScansionMachine(); | |
| m.SetLineFeet(5, true); | |
| const { footlist, ok } = m.DoAlgorithm(2, 'x/x/x/x/x/'); | |
| expect(ok).toBe(true); | |
| expect(footlist.length).toBe(5); | |
| }); | |
| it('scanLineNatively reads a canonical iambic pentameter line as its own verdict', () => { | |
| const result = scanLineNatively("Shall I compare thee to a summer's day?", { metron: 'iambic', lineFeet: 5, lineFeetSet: true }); | |
| expect(result.metron).toBe('iambic'); | |
| expect(result.verdict?.ok).toBe(true); | |
| expect(result.verdict?.footlist.length).toBe(5); | |
| }); | |
| it('scanLineNatively reads a canonical anapestic line as its own verdict', () => { | |
| // Hartman's own syllabizer (unrelated to Calliope's) scans "Assyrian" as | |
| // 4 syllables, so this line is 13 syllables under Scandroid's own count -- | |
| // a legitimate divergence from Calliope's own syllabification, not a bug. | |
| const result = scanLineNatively('And the sheen of their spears was like stars on the sea', { metron: 'anapestic', lineFeet: 4, lineFeetSet: true }); | |
| expect(result.metron).toBe('anapestic'); | |
| expect(result.verdict?.ok).toBe(true); | |
| expect(result.verdict?.footlist).toEqual(['anapest', 'anapest', 'anapest', 'anapest']); | |
| }); | |
| }); | |
| describe('end‑to‑end', () => { | |
| const poem = `Shall I compare thee to a summer's day? | |
| Thou art more lovely and more temperate. | |
| Rough winds do shake the darling buds of May, | |
| And summer's lease hath all too short a date.`; | |
| it('analyzeText returns results for each line', () => { | |
| const results = analyzeText(poem, false); | |
| expect(results.length).toBe(4); | |
| expect(results[0].phonologicalScansion.meterName).toBe('iambic'); | |
| expect(results[1].phonologicalScansion.meterName).toBe('iambic'); | |
| expect(results[2].phonologicalScansion.meterName).toBe('iambic'); | |
| expect(results[3].phonologicalScansion.meterName).toBe('iambic'); | |
| }); | |
| it('analyzeText with Scandroid includes Scandroid results', () => { | |
| const results = analyzeText(poem, true); | |
| for (const r of results) { | |
| expect(r.phonologicalScansion.meterName).toBeDefined(); | |
| expect(r.scandroidNative).toBeDefined(); | |
| expect(r.scandroidNative?.metron).toBe('iambic'); | |
| } | |
| }); | |
| it('useScandroid=false skips the independent Scandroid pass entirely', () => { | |
| const results = analyzeText(poem, false); | |
| for (const r of results) expect(r.scandroidNative).toBeUndefined(); | |
| }); | |
| it('handles free verse line gracefully', () => { | |
| // A famously free-verse line (Williams): there is no canonical scansion, so we | |
| // assert graceful handling — a defined meter and a non-empty, foot-delimited | |
| // scansion covering every syllable — rather than a brittle exact string. | |
| const results = analyzeText('so much depends upon a red wheel barrow', false); | |
| const d = results[0].phonologicalScansion; | |
| expect(d.meter).toBeDefined(); | |
| expect(d.scansion.length).toBeGreaterThan(0); | |
| expect(d.scansion).toContain('|'); | |
| // syllable letters in the scansion must equal the line's syllable count | |
| const sylLetters = (d.scansion.match(/[xwnms]/g) || []).length; | |
| const totalSyls = results[0].sentence.words.reduce((a, w) => a + w.syllables.length, 0); | |
| expect(sylLetters).toBe(totalSyls); | |
| }); | |
| }); | |
| describe('structural fixes (2026-06-10 audit)', () => { | |
| it('quotation marks are not prosodic breaks: quoted word scans like unquoted', () => { | |
| // Quotes fragmented the IU hierarchy and flipped this line to "trochaic | |
| // hexameter"; quoted and unquoted twins must scan identically. | |
| const quoted = analyzeText('I wonder why we call them "wisdom" teeth.', false)[0].phonologicalScansion; | |
| const plain = analyzeText('I wonder why we call them wisdom teeth.', false)[0].phonologicalScansion; | |
| expect(quoted.meterName).toBe('iambic'); | |
| expect(quoted.footCount).toBe(5); | |
| expect(quoted.meterName).toBe(plain.meterName); | |
| expect(quoted.footCount).toBe(plain.footCount); | |
| }); | |
| it('the LINE is the scansion domain: internal stops do not fragment the meter', () => { | |
| // Three grammatical sentences, one verse line (Woolley) — one iambic pentameter, | |
| // not trimeter + monometer + monometer fragments. | |
| const results = analyzeText("You'll slurp potato soup. No straws! Suck gauze.", false); | |
| expect(results.length).toBe(1); | |
| expect(results[0].phonologicalScansion.meterName).toBe('iambic'); | |
| expect(results[0].phonologicalScansion.footCount).toBe(5); | |
| }); | |
| it('mid-line exclamation keeps the line whole (Stallings-aligned)', () => { | |
| const results = analyzeText('Oh, bloody day! Extractions take their toll;', false); | |
| expect(results.length).toBe(1); | |
| expect(results[0].phonologicalScansion.meterName).toBe('iambic'); | |
| expect(results[0].phonologicalScansion.footCount).toBe(5); | |
| }); | |
| it('single anacrusis upbeat does not inflate the foot count', () => { | |
| // 10 syllables with a leading extrametrical upbeat: pentameter, never hexameter. | |
| const d = analyzeText('The woods around it have it--it is theirs.', false)[0].phonologicalScansion; | |
| expect(d.footCount).toBe(5); | |
| expect(d.meter).not.toContain('hexameter'); | |
| }); | |
| it('double upbeat fills a foot slot: Hiawatha stays tetrameter', () => { | |
| const d = analyzeText('By the shores of Gitche Gumee,', false)[0].phonologicalScansion; | |
| expect(d.meterName).toBe('trochaic'); | |
| expect(d.footCount).toBe(4); | |
| }); | |
| }); | |
| describe('syllable-count integrity (2026-06-10 audit)', () => { | |
| const sylCount = (line: string) => { | |
| const r = analyzeText(line, false)[0]; | |
| return r.sentence.words.reduce((a, w) => a + w.syllables.length, 0); | |
| }; | |
| it('"am" is one syllable (not the letter-name A.M. anomaly)', () => { | |
| expect(sylCount('I am too absent-spirited to count;')).toBe(10); | |
| }); | |
| it('"us" is one syllable (not the letter-name U.S. anomaly)', () => { | |
| expect(sylCount('Just for a handful of silver he left us,')).toBe(11); | |
| }); | |
| it("who'll / there's / she's re-merge to one syllable each", () => { | |
| expect(sylCount("who'll barely recognize your puffy face.")).toBe(10); | |
| expect(sylCount("and there's a light she's seen")).toBe(6); | |
| }); | |
| it("archaic -'d preterite does not swallow the next word", () => { | |
| const r = analyzeText("And heav'n knows wand'ring ev'ry fix'd star", false)[0]; | |
| const words = r.sentence.words.map(w => w.word.toLowerCase()); | |
| expect(words).toContain("fix'd"); | |
| expect(words).toContain('star'); | |
| expect(words).not.toContain('would'); | |
| expect(sylCount("And heav'n knows wand'ring ev'ry fix'd star")).toBe(9); | |
| }); | |
| it("hyphenated archaic -'d (hen-peck'd) stays one 2-syllable word, not compound+would", () => { | |
| const r = analyzeText("Inform us truly, have they not hen-peck'd you all?", false)[0]; | |
| const words = r.sentence.words.map(w => w.word.toLowerCase()); | |
| expect(words).toContain("hen-peck'd"); | |
| expect(words).not.toContain('would'); | |
| const hp = r.sentence.words.find(w => w.word.toLowerCase() === "hen-peck'd")!; | |
| expect(hp.syllables.length).toBe(2); // hen·peckt (the -'d is silent) | |
| }); | |
| it("th'-elision keeps the host's stress (th'ex-PENSE, not TH'EX-pense)", () => { | |
| const r = analyzeText("Th'expense of spirit in a waste of shame", false)[0]; | |
| const w = r.sentence.words.find(x => x.word.toLowerCase().startsWith("th'"))!; | |
| expect(w.syllables.length).toBe(2); | |
| const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 }; | |
| expect(rank[w.syllables[1].relativeStress!]).toBeGreaterThan(rank[w.syllables[0].relativeStress!]); | |
| }); | |
| it("'tis / 'twas are weak aphaeresis clitics, not stressed proper nouns", () => { | |
| const r = analyzeText("'Tis true, 'twas night", false)[0]; | |
| const tis = r.sentence.words.find(w => w.word.toLowerCase().includes('tis'))!; | |
| const twas = r.sentence.words.find(w => w.word.toLowerCase().includes('twas'))!; | |
| expect(['x', 'w']).toContain(tis.syllables[0].relativeStress); | |
| expect(['x', 'w']).toContain(twas.syllables[0].relativeStress); | |
| }); | |
| }); | |
| describe('caesura foot-alignment (2026-06-14)', () => { | |
| // Foot edges = cumulative syllable counts after each foot of the scansion string. | |
| const footEdges = (scan: string) => { | |
| const set = new Set<number>(); let c = 0; | |
| for (const foot of scan.split('|')) { for (const ch of foot) if ('xwnms'.includes(ch)) c++; set.add(c); } | |
| return set; | |
| }; | |
| it('"But—Oh! ye lords…" breaks after the foot, never fragmenting the monosyllable "But"', () => { | |
| const l = analyzeStanzas('But—Oh! ye lords of ladies intellectual,').flat()[0]; | |
| const d = l.phonologicalScansion; | |
| const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion); | |
| const edges = footEdges(d.scansion); | |
| expect(caes.size).toBeGreaterThan(0); | |
| for (const pos of caes.keys()) expect(edges.has(pos)).toBe(true); // every caesura on a foot edge | |
| expect(caes.has(1)).toBe(false); // not after "But" alone | |
| }); | |
| it('Poe gets one medial caesura at the comma (trochaic octameter midpoint)', () => { | |
| const l = analyzeStanzas('Once upon a midnight dreary, while I pondered weak and weary').flat()[0]; | |
| const d = l.phonologicalScansion; | |
| const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion); | |
| for (const pos of caes.keys()) expect(footEdges(d.scansion).has(pos)).toBe(true); | |
| expect([...caes.keys()]).toContain(8); // after "dreary," — the line's medial break | |
| }); | |
| }); | |
| describe('rising function words & copula reduction (2026-06-14)', () => { | |
| const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 }; | |
| const wordOf = (line: string, w: string) => | |
| analyzeText(line, false)[0].sentence.words.find(x => x.word.toLowerCase() === w)!; | |
| // "because" is recorded fully-reduced ("00") in the augmented dictionary; the | |
| // all-zero re-stamp must rise (be·CAUSE), not take the disyllabic forestress | |
| // default (which mis-read it as BE·cause). | |
| it('"because" rises (be·CAUSE): the final syllable outranks the first', () => { | |
| const because = wordOf('Because at least the past were passed away,', 'because'); | |
| expect(because.syllables.length).toBe(2); | |
| expect(rank[because.syllables[1].relativeStress!]) | |
| .toBeGreaterThan(rank[because.syllables[0].relativeStress!]); | |
| }); | |
| // The 1sg copula "am" reduces — it must not surface as a metrical beat ('m') | |
| // the way the letter-name A.M. data once forced it ("As I am BLOOD…"). | |
| it('the copula "am" reduces — no spurious beat in "As I am blood…"', () => { | |
| const am = wordOf('As I am blood, bone, marrow, passion, feeling', 'am'); | |
| expect(am.syllables.length).toBe(1); | |
| expect(rank[am.syllables[0].relativeStress!]).toBeLessThanOrEqual(rank['w']); | |
| }); | |
| // An interjection immediately before "!" is an emphatic peak — it must stand out | |
| // from the flat function-word run around it ("But—Oh! ye…" was a monotone n·n·n). | |
| it('an exclaimed interjection (Oh!) outranks the conjunction/pronoun beside it', () => { | |
| const words = analyzeText('But—Oh! ye lords of ladies intellectual,', false)[0].sentence.words; | |
| const peak = (w: string) => { | |
| const word = words.find(x => x.word.toLowerCase() === w)!; | |
| return Math.max(...word.syllables.map(s => rank[s.relativeStress!])); | |
| }; | |
| expect(peak('oh')).toBeGreaterThan(peak('but')); | |
| expect(peak('oh')).toBeGreaterThan(peak('ye')); | |
| }); | |
| }); | |
| describe('rhyme & form layer (2026-06-12)', () => { | |
| it('classifies canonical rhyme pairs (LYRICAL typology)', () => { | |
| expect(classifyRhymePair('grace', 'face')).toMatchObject({ type: 'perfect', structure: 'masculine' }); | |
| expect(classifyRhymePair('picky', 'tricky')).toMatchObject({ type: 'perfect', structure: 'feminine' }); | |
| expect(classifyRhymePair('belief', 'leaf')).toMatchObject({ type: 'rich' }); // homophone tails | |
| expect(classifyRhymePair('dame', 'grain')).toMatchObject({ type: 'family' }); // M/N nasals | |
| expect(classifyRhymePair('love', 'move')).toMatchObject({ type: 'consonant' }); // para-rhyme | |
| expect(classifyRhymePair('shaken', 'shaken')).toMatchObject({ type: 'identical' }); | |
| }); | |
| it('detects ABCB with unrhymed lines marked', () => { | |
| const rs = detectScheme(['Mariner', 'three', 'eye', 'me']); | |
| expect(rs.map(r => r.letter).join('')).toBe('\u00b7A\u00b7A'); | |
| }); | |
| it('Coleridge quatrain \u2192 ballad stanza (rhyme + 4\u00b73 gate)', () => { | |
| const r = analyzeStanzas('It is an ancient Mariner,\nAnd he stoppeth one of three.\nBy thy long grey beard and glittering eye,\nNow wherefore stopp\u2019st thou me?'); | |
| expect(r[0][0].phonologicalScansion.formNote).toBe('ballad stanza (ABCB, 4\u00b73)'); | |
| }); | |
| it('Pope couplet \u2192 couplet', () => { | |
| const r = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne\u2019er so well expressed.'); | |
| expect(r[0][0].phonologicalScansion.formNote).toBe('couplet'); | |
| }); | |
| it('Frost (Mending Wall opening) \u2192 blank verse', () => { | |
| const r = analyzeStanzas("Something there is that doesn't love a wall,\nThat sends the frozen-ground-swell under it,\nAnd spills the upper boulders in the sun;\nAnd makes gaps even two can pass abreast."); | |
| expect(r[0][0].phonologicalScansion.formNote).toBe('blank verse'); | |
| }); | |
| it('Sonnet 130 \u2192 Shakespearean Sonnet, full scheme', () => { | |
| const text = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\nIf snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\nI have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\nAnd in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\nI love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\nI grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\nAnd yet, by heaven, I think my love as rare\nAs any she belied with false compare."; | |
| const r = analyzeStanzas(text); | |
| expect(r[0][0].phonologicalScansion.formNote).toBe('Shakespearean Sonnet'); | |
| expect(r[0].map(l => l.phonologicalScansion.rhyme!.letter).join('')).toBe('ABABCDCDEFEFGG'); | |
| }); | |
| it('Lear limerick \u2192 limerick (AABBA, ternary)', () => { | |
| const r = analyzeStanzas('There was an Old Man with a beard,\nWho said, It is just as I feared!\nTwo Owls and a Hen,\nFour Larks and a Wren,\nHave all built their nests in my beard!'); | |
| expect(r[0][0].phonologicalScansion.formNote).toMatch(/^limerick/); | |
| }); | |
| }); | |
| describe('continuity rename (2026-06-14)', () => { | |
| it('near-tie line adopts the stanza-dominant meter; standalone kept as note', () => { | |
| // Exile opening: L2 standalone reads dactylic (spondaic "book-" anacrusis | |
| // seizes a beat), but the stanza is amphibrachic — continuity renames it. | |
| const r = analyzeStanzas( | |
| 'He happens to be a French poet, that thin,\n' + | |
| 'book-carrying man with a bristly gray chin;\n' + | |
| 'you meet him whenever you go'); | |
| const d = r[0][1].phonologicalScansion; | |
| expect(d.meterName).toBe('amphibrachic'); | |
| expect(d.standaloneMeter).toMatch(/dactylic/); | |
| expect(d.consensusMeter).toBeUndefined(); | |
| }); | |
| it('accentual stanzas are exempt: Wyatt keeps 4-beat accentual, no renames', () => { | |
| const wyatt = | |
| 'They fle from me that sometyme did me seke\n' + | |
| 'With naked fote stalking in my chambre.\n' + | |
| 'I have sene theim gentill tame and meke\n' + | |
| 'That nowe are wyld and do not remembre\n' + | |
| 'That sometyme they put theimself in daunger\n' + | |
| 'To take bred at my hand; and nowe they raunge\n' + | |
| 'Besely seking with a continuell chaunge.'; | |
| const r = analyzeStanzas(wyatt); | |
| for (const l of r[0]) { | |
| expect(l.phonologicalScansion.rhythmNote).toBe('4-beat accentual'); | |
| expect(l.phonologicalScansion.standaloneMeter).toBeUndefined(); | |
| } | |
| }); | |
| }); | |
| describe('parse-correction layer (2026-06-14)', () => { | |
| it('staged pipeline repairs "I had quit the programming paradigm"', () => { | |
| const doc = parseDocument('I had quit the programming paradigm'); | |
| const words = doc.sentences[0].words; | |
| expect(words.find(w => w.word === 'i')!.lexicalClass).toBe('PRP'); | |
| expect(words.find(w => w.word === 'quit')!.lexicalClass).toBe('VBN'); | |
| const deps = doc.sentences[0].dependencies; | |
| const prog = deps.find(d => d.dependentName === 'programming')!; | |
| // Re-baselined 2026-07-02 (UDPIPE_MIGRATION.md Group B): the old golden 'amod' | |
| // encoded the depfix repair of en-parse's broken reading ("programming ←DOBJ← | |
| // quit"). UDPipe parses the gerund-noun pair directly as `compound` — the | |
| // standard UD treatment, and the one that feeds the correct CSR forestress | |
| // ("PROgramming paradigm"). | |
| expect(prog.dependentType).toBe('compound'); | |
| expect(prog.governorName).toBe('paradigm'); | |
| const det = deps.find(d => d.dependentName === 'the')!; | |
| expect(det.dependentType).toBe('det'); | |
| expect(det.governorName).toBe('paradigm'); | |
| }); | |
| it('archaic forms get verse-correct tags (thy/PRP$ floors at w)', () => { | |
| const doc = parseDocument('By thy long grey beard and glittering eye,'); | |
| const thy = doc.sentences[0].words.find(w => w.word === 'thy')!; | |
| expect(thy.lexicalClass).toBe('PRP$'); | |
| }); | |
| it('context-guarded gerund fix: "they bicycle through" keeps the verb', () => { | |
| const doc = parseDocument('young beauties, all legs, as they bicycle through'); | |
| const bike = doc.sentences[0].words.find(w => w.word === 'bicycle')!; | |
| expect(bike.lexicalClass).toBe('VBP'); | |
| }); | |
| }); | |
| describe('metricality, foot names, beats & poem-wide rhyme (2026-06-13)', () => { | |
| it('extends foot-count nomenclature through icosameter', () => { | |
| // A long run of clean "the cat" iambs names the line all the way up. | |
| const feet14 = analyzeText(Array.from({ length: 14 }, () => 'the cat').join(' '))[0] | |
| .phonologicalScansion; | |
| expect(feet14.footCount).toBe(14); | |
| expect(feet14.meter).toBe('iambic tetradecameter'); | |
| const feet20 = analyzeText(Array.from({ length: 20 }, () => 'the cat').join(' '))[0] | |
| .phonologicalScansion; | |
| expect(feet20.meter).toBe('iambic icosameter'); | |
| }); | |
| it('prose-likeness hedge fires on long un-lineated prose', () => { | |
| const d = analyzeText( | |
| "Another thing I've noticed more generally is that the current engine's caesural mechanics may be disruptive", | |
| )[0].phonologicalScansion; | |
| expect(d.metricalityNote).toBeDefined(); | |
| expect(d.metricalityNote).toContain('plausible prose'); | |
| expect(d.metricalityNote).toContain(d.meter); // closest-fit name echoed | |
| expect(d.meter).toMatch(/decameter$/); // long foot-name in use | |
| }); | |
| it('hedge spares short verse, incl. low-certainty / ambiguous lines', () => { | |
| // Real verse below the 9-foot length gate is never flagged — not even | |
| // V6 ("Half a league", certainty ~40) or L2 (Prufrock, an iamb/troch tie). | |
| for (const v of [ | |
| "Shall I compare thee to a summer's day?", | |
| 'Half a league, half a league, half a league onward', | |
| 'Let us go then, you and I', | |
| 'This is the forest primeval, the murmuring pines and the hemlocks', | |
| ]) { | |
| expect(analyzeText(v)[0].phonologicalScansion.metricalityNote).toBeUndefined(); | |
| } | |
| }); | |
| it('metricalityVerdict is purely a function of the detail fields', () => { | |
| // No straddle (top-3 all one polarity/size) ⇒ never prose, even if long. | |
| const committed: PhonologicalScansionDetail = { | |
| all: '', keyStresses: '', meter: 'iambic dodecameter', meterName: 'iambic', | |
| footCount: 12, summary: '', scansion: '', certainty: 40, | |
| weightScore: 0, maxPossibleWeight: 0, | |
| ranking: [{ meter: 'iambic', score: 1.0 }, { meter: 'trochaic', score: 0.95 }], | |
| }; | |
| expect(metricalityVerdict(committed)).toBeUndefined(); | |
| }); | |
| it('end-rhyme types are preserved; lettering is poem-wide (not per-stanza)', () => { | |
| // The rhyme TYPE rides on the line that completes the rhyme (dressed/expressed → rich). | |
| const c = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne’er so well expressed.'); | |
| expect(c.flat()[1].phonologicalScansion.rhyme!.type).toBe('rich'); | |
| // Two couplets of UNRELATED sounds, in separate stanzas: each stanza still | |
| // reads AA on its own, but the two A's must be DIFFERENT poem-wide keys — | |
| // lettering runs once over the whole poem, so an unrelated stanza never | |
| // reuses an earlier stanza's letter. | |
| const two = analyzeStanzas('The cat sat on the mat,\nbeside a sleeping rat.\n\nThe sun was in the sky,\nas clouds went drifting by.'); | |
| expect(two.map(st => st.map(l => l.phonologicalScansion.rhyme!.letter).join(''))).toEqual(['AA', 'BB']); | |
| }); | |
| it('a stanza-1 line rhyming ONLY with a stanza-2 line still shares one poem-wide key', () => { | |
| // L1 "still" and L4 "chill" bind ACROSS the stanza break; L2 "wall" and | |
| // L3 "light" are each unrhymed on their own. The end-rhyme letter must | |
| // be identical on both lines, matchedLine must be the POEM-GLOBAL index | |
| // of L1 (0, not stanza-local), and the synopsis' End-Rhyme Scheme row | |
| // must read off exactly the same per-line letters (single source of truth). | |
| const text = 'The house was calm and still,\nbeyond the garden wall,\n\n' | |
| + 'we watched the fading light,\nuntil the coming chill.'; | |
| const r = analyzeStanzas(text); | |
| const l1 = r[0][0].phonologicalScansion.rhyme!; | |
| const l4 = r[1][1].phonologicalScansion.rhyme!; | |
| expect(l1.letter).not.toBe('·'); | |
| expect(l1.letter).toBe(l4.letter); // (i) same poem-wide letter | |
| expect(l4.matchedLine).toBe(0); // (iii) poem-global index of L1 | |
| const perLine = r.flatMap(st => st.map(l => l.phonologicalScansion.rhyme!.letter)).join(''); | |
| const row = summarizePoem(r).find(row => row.label === 'End-Rhyme Scheme')!.value; | |
| expect(row).toBe(perLine); // (ii) synopsis matches per-line letters | |
| }); | |
| it('rhymeKey: unbounded bijective base-26 (A…Z, then AA, AB, …)', () => { | |
| expect(rhymeKey(0)).toBe('A'); | |
| expect(rhymeKey(25)).toBe('Z'); | |
| expect(rhymeKey(26)).toBe('AA'); | |
| expect(rhymeKey(27)).toBe('AB'); | |
| expect(rhymeKey(51)).toBe('AZ'); | |
| expect(rhymeKey(52)).toBe('BA'); | |
| }); | |
| it('pre-caesural internal rhyme is additive (own letter + type), end rhyme intact', () => { | |
| // end beam/dream → A (perfect); internal night/bright form their own class → B. | |
| const r = analyzeStanzas('The stars at night, a silver beam,\nshine ever bright as in a dream.'); | |
| const lines = r.flat(); | |
| expect(lines.map(l => l.phonologicalScansion.rhyme!.notation)).toEqual(['(B)A', '(B)A']); | |
| expect(lines[0].phonologicalScansion.rhyme!.letter).toBe('A'); // end letter untouched | |
| expect(lines[0].phonologicalScansion.rhyme!.internal) | |
| .toEqual([{ word: 'night', letter: 'B', type: 'perfect' }]); | |
| }); | |
| it('heterometric is an advisory only — never stamped on per-line rhythmNote', () => { | |
| const song = 'If each wire looks barbed, it actually is\nBlessed are they lounged at cemeteries\n' + | |
| 'Not quite enough for all, but we still don’t care\nNo shit exists so noxious, we don’t binge and share\n' + | |
| 'But it looks like, it’s for the long haul\nLooks like it is habitual\n' + | |
| 'Looks like it’s quite a catch-all\nLooks like it’s just a lack of\nSOMEBODY MORE\nOF SOMEBODY MORE'; | |
| const r = analyzeStanzas(song); | |
| for (const l of r.flat()) { | |
| expect(l.phonologicalScansion.rhythmNote ?? '').not.toContain('heterometric'); | |
| expect(l.phonologicalScansion.meterName).not.toBe('free verse'); // lines keep a real meter | |
| } | |
| // The line-length-variation observation lives in the synopsis instead | |
| // ("heterometric" wording dropped 2026-06-16 per the maintainer). | |
| const note = summarizePoem(r).find(row => row.label === 'Note')?.value ?? ''; | |
| expect(note).toContain('line lengths vary'); | |
| }); | |
| it('poem synopsis: accentual reported in beats; sonnet form + meter', () => { | |
| const wyatt = 'They fle from me that sometyme did me seke\n' + | |
| 'With naked fote stalking in my chambre.\nI have sene theim gentill tame and meke\n' + | |
| 'That nowe are wyld and do not remembre\nThat sometyme they put theimself in daunger\n' + | |
| 'To take bred at my hand; and nowe they raunge\nBesely seking with a continuell chaunge.'; | |
| // The metre make-up lives in the "Rhythm" row (relabelled 2026-06-16). | |
| const wMeter = summarizePoem(analyzeStanzas(wyatt)).find(r => r.label === 'Rhythm')!.value; | |
| expect(wMeter).toContain('accentual'); // beats, not feet | |
| expect(wMeter).not.toMatch(/meter\b/); // no classical foot-length name | |
| const sonnet = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" + | |
| "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\n" + | |
| "I have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\n" + | |
| 'And in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\n' + | |
| "I love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\n" + | |
| 'I grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\n' + | |
| 'And yet, by heaven, I think my love as rare\nAs any she belied with false compare.'; | |
| const rows = summarizePoem(analyzeStanzas(sonnet)); | |
| expect(rows.find(r => r.label === 'Form')!.value).toContain('Shakespearean Sonnet'); | |
| // Metre make-up now in "Rhythm", abbreviated ("iamb penta") 2026-06-16. | |
| expect(rows.find(r => r.label === 'Rhythm')!.value).toContain('iamb penta'); | |
| }); | |
| }); | |
| describe('phonopoetics (2026-06-16)', () => { | |
| it('end-rhyme pair + alliteration + head rhyme (three-phase lettering)', () => { | |
| const p = analyzePhonopoetics(analyzeStanzas( | |
| 'Here were fond climates and sweet singers suddenly\n' + | |
| 'Nap on the hill and gap in the rolling cloud\n' + | |
| 'Come in the morning where I wandered and rhyme\n' + | |
| 'Gap on the crest where weary wanderers time')); | |
| // END: rhyme/time bind to one letter | |
| expect(p.end.some(r => (r.fromWord === 'rhyme' && r.toWord === 'time') || (r.fromWord === 'time' && r.toWord === 'rhyme'))).toBe(true); | |
| // HEAD: Nap/Gap, lettered AFTER the end letters (continue the alphabet), once | |
| expect(p.head.length).toBe(1); | |
| expect(new Set([p.head[0].fromWord, p.head[0].toWord])).toEqual(new Set(['Gap', 'Nap'])); | |
| // ALLITERATION carries no letters; "sweet singers suddenly" is one run | |
| expect(p.alliteration.some(a => a.words.join(' ').toLowerCase() === 'sweet singers suddenly')).toBe(true); | |
| }); | |
| it('caesural rhyme reuses the exact end-pair letter it echoes', () => { | |
| const p = analyzePhonopoetics(analyzeStanzas( | |
| 'In the silent night, I saw a gleaming light\n' + | |
| 'With all of my might, I walked the path of sight')); | |
| const c = p.caesural.find(r => r.fromWord === 'night'); | |
| expect(c).toBeTruthy(); | |
| const e = p.end.find(r => r.fromWord === 'light' || r.toWord === 'light'); | |
| expect(e).toBeTruthy(); | |
| expect(c!.letter).toBe(e!.letter); // night (pre-caesural) reused light's letter | |
| }); | |
| it('acrostic spells a dictionary word from line initials', () => { | |
| const p = analyzePhonopoetics(analyzeStanzas( | |
| 'Bright was the morning sun\nIn the cold and dawn\nNever a cloud above\nGone is the silent night\nOver the hills it came')); | |
| expect(p.acrostics.some(a => a.word === 'BINGO')).toBe(true); | |
| }); | |
| }); | |
| describe('stress-clash invariant (2026-06-16)', () => { | |
| // The maintainer's categorical, input-independent rule (generalising McAleese's | |
| // "stress clashes (ss,ms) > s-s" and Liberman & Prince's grid alternation): | |
| // on the STRESSED tier {n,m,s} NO two adjacent syllables may share a level — that | |
| // is a clash (two equal prominences with no gradation). Gradient pairs | |
| // (sm/ms/sn/ns/mn/nm) are fine; the unstressed tiers {w,x} MAY repeat. This must | |
| // hold for ANY input — it is enforced by the clash filter (resolveLinearClashes). | |
| // "It's kind of a crime it's not a test already." — so here it is. | |
| const STRESSED = new Set(['n', 'm', 's']); | |
| function clashesIn(text: string): string[] { | |
| const bad: string[] = []; | |
| for (const lr of analyzeText(text, false)) { | |
| // A clash is two equal stressed syllables CONTIGUOUS in pronunciation. | |
| // Punctuation is a prosodic break, so syllables either side of it are not | |
| // contiguous (a '|' sentinel, not in STRESSED, breaks the adjacency scan). | |
| const seq: { lvl: string; word: string }[] = []; | |
| for (const w of lr.sentence.words) { | |
| if (isPunctuation(w.lexicalClass)) { seq.push({ lvl: '|', word: '' }); continue; } | |
| for (const s of w.syllables) seq.push({ lvl: s.relativeStress ?? '?', word: w.word }); | |
| } | |
| const line = lr.sentence.words.filter(w => !isPunctuation(w.lexicalClass)).map(w => w.word).join(' '); | |
| for (let i = 0; i < seq.length - 1; i++) { | |
| if (seq[i].lvl === seq[i + 1].lvl && STRESSED.has(seq[i].lvl)) { | |
| bad.push(`${seq[i].lvl}${seq[i + 1].lvl} ("${seq[i].word}"+"${seq[i + 1].word}") in "${line}"`); | |
| } | |
| } | |
| } | |
| return bad; | |
| } | |
| // The maintainer's own example battery (the regression set that exposed the | |
| // clashes), plus canonical verse of every metre. | |
| const battery: Record<string, string> = { | |
| 'Thomas — There could I marvel': 'There could I marvel my birthday', | |
| 'Thomas — Poem in October (st.1)': | |
| 'My birthday began with the water-\nBirds and the birds of the winged trees flying my name\n' + | |
| 'Above the farms and the white horses\nAnd I rose\nIn rainy autumn\n' + | |
| 'And walked abroad in a shower of all my days.', | |
| 'Gypsies — bachelor / gypsies': | |
| "O, I am a bachelor, I live by myself\n" + | |
| "And the only, only thing that I ever did was wrong\n" + | |
| "So all night long I held her in my arms\n" + | |
| "One night she came to my bedside\n" + | |
| "I fear you have had some ill sickness\n" + | |
| "One sang high and the other sang low\n" + | |
| "The ragged, ragged rags about our door\n" + | |
| "It was late last night when my lord came home", | |
| 'Byron — intellectual / hen-peck\'d': | |
| "But—Oh! ye lords of ladies intellectual,\nInform us truly, have they not hen-peck'd you all?", | |
| 'Byron — vaunt / Don Juan': | |
| 'Of such as these I should not care to vaunt,\nI\'ll therefore take our ancient friend Don Juan', | |
| 'Shakespeare — Sonnet 130 (q.1)': | |
| "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" + | |
| "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.", | |
| 'Poe — The Raven (opening)': | |
| 'Once upon a midnight dreary, while I pondered weak and weary,\n' + | |
| 'Over many a quaint and curious volume of forgotten lore', | |
| 'Milton — Paradise Lost (opening)': | |
| 'Of Mans First Disobedience, and the Fruit\nOf that Forbidden Tree, whose mortal tast', | |
| }; | |
| for (const [name, text] of Object.entries(battery)) { | |
| it(`no adjacent equal stress on {n,m,s} — ${name}`, () => { | |
| const bad = clashesIn(text); | |
| expect(bad, bad.join(' ; ')).toEqual([]); | |
| }); | |
| } | |
| }); | |
| // Stress-level tuning from the Sosnora-translation review (2026-06-21). | |
| describe('stress-level tuning (Sosnora review, 2026-06-21)', () => { | |
| it('curly-apostrophe contractions tokenise like straight ones (won’t = 1 modal)', () => { | |
| // A curly ’ bypassed the contraction path and en-norm dehiscised "won’t" | |
| // into "will not" (2 syllables); the apostrophe is now normalised first. | |
| const curly = parseDocument('I won’t tell anyone').sentences[0].words.map(w => w.word); | |
| const straight = parseDocument("I won't tell anyone").sentences[0].words.map(w => w.word); | |
| expect(curly).toEqual(straight); | |
| expect(curly).toContain("won't"); | |
| expect(curly).not.toContain('will'); // not expanded to "will not" | |
| }); | |
| it('aren’t / Nature’s with curly apostrophe tokenise correctly', () => { | |
| const w = parseDocument('boats aren’t tears').sentences[0].words.map(x => x.word); | |
| expect(w).toContain("aren't"); | |
| const poss = parseDocument('Nature’s first green').sentences[0].words.map(x => x.word); | |
| expect(poss).toContain("'s"); // possessive clitic split off | |
| }); | |
| it('N+N compound fore-stresses even when en-parse inverts the dependency arrow', () => { | |
| // "slate roof"/"clay jar" parse as roof→slate / jar→clay (head on the LEFT), | |
| // the inverse of "ice cream"; the left noun must still fore-stress (CSR). | |
| for (const np of ['a slate roof', 'a clay jar']) { | |
| const d = analyzeText(np, false)[0].phonologicalScansion; | |
| expect(d.scansion, np).toBe('xsn'); // a·SLATE·roof (beat on the modifier) | |
| } | |
| // the canonical NOMD case is unchanged | |
| expect(analyzeText('ice cream', false)[0].phonologicalScansion.scansion).toBe('sn'); | |
| }); | |
| it('a reduced copula after a function word floors below it (it·is gradient)', () => { | |
| // "it is" was a flat w·w; the copula now reduces to x, restoring the gradient. | |
| // Re-derived 2026-06-29 (Wagner/Krifka rebuild): the assertion verifies the | |
| // copula reduction it(w)·is(x) at the head; the foot boundary after it is no | |
| // longer pinned, because deictic "here" now carries a light content beat | |
| // (it·is·GOLD·here → wxsn), a defensible differentiation that re-segments the | |
| // feet without touching the is→x reduction the test exists to check. | |
| expect(analyzeText('it is gold here', false)[0].phonologicalScansion.scansion).toMatch(/^wx/); | |
| }); | |
| }); | |
| // ─── UDPipe feature leverage & challenging constructions (2026-06-23) ───────── | |
| // These exercise the PRODUCTION (Calliope) engine — `analyzeText` — not the | |
| // legacy runPipeline path, and lean on UDPipe features en-parse never produced: | |
| // the `compound` relation (genuine N+N fore-stress) and the model-agnostic | |
| // UPOS+FEATS POS derivation. See AGENTS.md log 2026-06-23. | |
| describe('UDPipe feature leverage & challenging constructions (2026-06-23)', () => { | |
| const RANK: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 }; | |
| const lineResult = (line: string) => analyzeText(line, false)[0]; | |
| const peakRel = (w: any) => | |
| Math.max(...w.syllables.map((s: any) => RANK[s.relativeStress ?? 'w'])); | |
| const wordByText = (lr: any, t: string) => | |
| lr.sentence.words.find((w: any) => w.word === t); | |
| const phiCount = (lr: any) => | |
| lr.phonologicalHierarchy.reduce((n: number, iu: any) => n + iu.phonologicalPhrases.length, 0); | |
| it('N+N compounds fore-stress via the UD `compound` relation (ICE cream, DOOR man, STONE wall)', () => { | |
| for (const [phrase, a, b] of [ | |
| ['ice cream', 'ice', 'cream'], | |
| ['the door man', 'door', 'man'], | |
| ['a stone wall', 'stone', 'wall'], | |
| ]) { | |
| const lr = lineResult(phrase); | |
| expect(peakRel(wordByText(lr, a)), `${phrase}: ${a} should fore-stress over ${b}`) | |
| .toBeGreaterThan(peakRel(wordByText(lr, b))); | |
| } | |
| }); | |
| it('canonical iambic lines read iambic on the production engine', () => { | |
| // Milton, Paradise Lost 1.1 (iambic pentameter); Kilmer, Trees (iambic tetrameter). | |
| expect(scanLine("Of Man's first disobedience, and the fruit").meterName).toBe('iambic'); | |
| expect(scanLine('I think that I shall never see').meterName).toBe('iambic'); | |
| }); | |
| it('ϕ-grouping is not over-segmented: a verb + light oblique PP stay one phrase', () => { | |
| // Selkirk: "{[More than fifteen][carpenters]}{[are working][in the house]}" = 2 ϕ. | |
| // Before the OBL fix this shattered into 5 ϕ, washing out the key stresses. | |
| const lr = lineResult('More than fifteen carpenters are working in the house'); | |
| expect(phiCount(lr)).toBeLessThanOrEqual(3); | |
| }); | |
| it('carpenters line: subject NP is ONE ϕ, "in the house" its own ϕ (3 ϕ total)', () => { | |
| // Expert/Gee&Grosjean gold (McAleese p.213): | |
| // <{[More than fifteen carpenters]}{[are working]}{[in the house]}> — 3 ϕ. | |
| // The subject NP must NOT shatter ("more" is not its own ϕ) and the verbal | |
| // oblique "in the house" IS its own ϕ. (A prior build gave 5 ϕ — every | |
| // argument a phrase — which made every spurious ϕ-end a key stress.) | |
| const lr = lineResult('More than fifteen carpenters are working in the house'); | |
| expect(phiCount(lr)).toBe(3); | |
| // "more" sits in the subject ϕ with "carpenters" (not its own singleton ϕ), so | |
| // it is a SECONDARY beat under the ϕ nuclear "carpenters", never an 's'. | |
| expect(peakRel(wordByText(lr, 'more'))).toBeLessThan(peakRel(wordByText(lr, 'carpenters'))); | |
| expect(peakRel(wordByText(lr, 'more'))).toBeGreaterThan(RANK.w); // a real beat (m), not flattened | |
| // The ϕ-initial preposition "in" carries the subtle left-edge beat the maintainer | |
| // hears — above the reduced article "the". | |
| expect(peakRel(wordByText(lr, 'in'))).toBeGreaterThan(peakRel(wordByText(lr, 'the'))); | |
| }); | |
| it('phrase-initial preposition outranks an interior determiner (Blake: through > each)', () => { | |
| // "I wander through each chartered street": the ϕ-initial "through" takes a beat, | |
| // the interior determiner-slot "each" reduces — so through > each, not "through EACH". | |
| const lr = lineResult('I wander through each chartered street'); | |
| expect(peakRel(wordByText(lr, 'through'))).toBeGreaterThan(peakRel(wordByText(lr, 'each'))); | |
| }); | |
| }); | |