calliope-ts 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -13
- package/dist/caesura.d.ts +33 -0
- package/dist/caesura.d.ts.map +1 -0
- package/dist/caesura.js +202 -0
- package/dist/calliope/boundaries.d.ts +19 -0
- package/dist/calliope/boundaries.d.ts.map +1 -0
- package/dist/calliope/boundaries.js +182 -0
- package/dist/calliope/bracketing.d.ts +11 -0
- package/dist/calliope/bracketing.d.ts.map +1 -0
- package/dist/calliope/bracketing.js +416 -0
- package/dist/calliope/deps.d.ts +4 -0
- package/dist/calliope/deps.d.ts.map +1 -0
- package/dist/calliope/deps.js +181 -0
- package/dist/calliope/engine.d.ts +3 -0
- package/dist/calliope/engine.d.ts.map +1 -0
- package/dist/calliope/engine.js +71 -0
- package/dist/calliope/feats.d.ts +9 -0
- package/dist/calliope/feats.d.ts.map +1 -0
- package/dist/calliope/feats.js +45 -0
- package/dist/calliope/names.d.ts +7 -0
- package/dist/calliope/names.d.ts.map +1 -0
- package/dist/calliope/names.js +42 -0
- package/dist/calliope/postag.d.ts +8 -0
- package/dist/calliope/postag.d.ts.map +1 -0
- package/dist/calliope/postag.js +250 -0
- package/dist/calliope/prosodic.d.ts +3 -0
- package/dist/calliope/prosodic.d.ts.map +1 -0
- package/dist/calliope/prosodic.js +275 -0
- package/dist/calliope/relstress.d.ts +4 -0
- package/dist/calliope/relstress.d.ts.map +1 -0
- package/dist/calliope/relstress.js +688 -0
- package/dist/calliope/stressrules.d.ts +28 -0
- package/dist/calliope/stressrules.d.ts.map +1 -0
- package/dist/calliope/stressrules.js +147 -0
- package/dist/calliope/syntax.d.ts +38 -0
- package/dist/calliope/syntax.d.ts.map +1 -0
- package/dist/calliope/syntax.js +234 -0
- package/dist/calliope/udpos.d.ts +13 -0
- package/dist/calliope/udpos.d.ts.map +1 -0
- package/dist/calliope/udpos.js +156 -0
- package/dist/clio/caesura.d.ts +27 -0
- package/dist/clio/caesura.d.ts.map +1 -0
- package/dist/clio/caesura.js +148 -0
- package/dist/clio/depfix.d.ts +13 -0
- package/dist/clio/depfix.d.ts.map +1 -0
- package/dist/clio/depfix.js +84 -0
- package/dist/clio/display.d.ts +32 -0
- package/dist/clio/display.d.ts.map +1 -0
- package/dist/clio/display.js +976 -0
- package/dist/clio/engine.d.ts +3 -0
- package/dist/clio/engine.d.ts.map +1 -0
- package/dist/clio/engine.js +28 -0
- package/dist/clio/parser.d.ts +10 -0
- package/dist/clio/parser.d.ts.map +1 -0
- package/dist/clio/parser.js +696 -0
- package/dist/clio/phonological.d.ts +41 -0
- package/dist/clio/phonological.d.ts.map +1 -0
- package/dist/clio/phonological.js +788 -0
- package/dist/clio/phrasestress.d.ts +6 -0
- package/dist/clio/phrasestress.d.ts.map +1 -0
- package/dist/clio/phrasestress.js +106 -0
- package/dist/clio/pipeline.d.ts +11 -0
- package/dist/clio/pipeline.d.ts.map +1 -0
- package/dist/clio/pipeline.js +147 -0
- package/dist/clio/rhyme.d.ts +65 -0
- package/dist/clio/rhyme.d.ts.map +1 -0
- package/dist/clio/rhyme.js +761 -0
- package/dist/clio/scandroid.d.ts +17 -0
- package/dist/clio/scandroid.d.ts.map +1 -0
- package/dist/clio/scandroid.js +435 -0
- package/dist/clio/scansion.d.ts +46 -0
- package/dist/clio/scansion.d.ts.map +1 -0
- package/dist/clio/scansion.js +1086 -0
- package/dist/clio/semantics.d.ts +44 -0
- package/dist/clio/semantics.d.ts.map +1 -0
- package/dist/clio/semantics.js +139 -0
- package/dist/clio/stress.d.ts +83 -0
- package/dist/clio/stress.d.ts.map +1 -0
- package/dist/clio/stress.js +1737 -0
- package/dist/clio/tagfix.d.ts +6 -0
- package/dist/clio/tagfix.d.ts.map +1 -0
- package/dist/clio/tagfix.js +101 -0
- package/dist/display.d.ts +0 -6
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +322 -141
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +12 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +169 -26
- package/dist/parser.d.ts +3 -3
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +320 -563
- package/dist/phonological.d.ts +1 -1
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +73 -4
- package/dist/phrasestress.d.ts +6 -0
- package/dist/phrasestress.d.ts.map +1 -0
- package/dist/phrasestress.js +106 -0
- package/dist/rhyme.d.ts +40 -1
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +435 -7
- package/dist/scansion.d.ts +9 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +145 -18
- package/dist/semantics.d.ts +44 -0
- package/dist/semantics.d.ts.map +1 -0
- package/dist/semantics.js +139 -0
- package/dist/stress.d.ts +127 -4
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +834 -61
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/caesura.ts +201 -0
- package/src/calliope/boundaries.ts +190 -0
- package/src/calliope/bracketing.ts +390 -0
- package/src/calliope/deps.ts +160 -0
- package/src/calliope/engine.ts +77 -0
- package/src/calliope/feats.ts +46 -0
- package/src/calliope/names.ts +44 -0
- package/src/calliope/postag.ts +253 -0
- package/src/calliope/prosodic.ts +262 -0
- package/src/calliope/relstress.ts +645 -0
- package/src/calliope/stressrules.ts +147 -0
- package/src/calliope/syntax.ts +218 -0
- package/src/calliope/udpos.ts +152 -0
- package/src/clio/caesura.ts +145 -0
- package/src/clio/depfix.ts +88 -0
- package/src/clio/display.ts +1042 -0
- package/src/clio/engine.ts +38 -0
- package/src/clio/parser.ts +845 -0
- package/src/clio/phonological.ts +849 -0
- package/src/clio/phrasestress.ts +108 -0
- package/src/clio/pipeline.ts +154 -0
- package/src/clio/rhyme.ts +740 -0
- package/src/clio/scandroid.ts +434 -0
- package/src/clio/scansion.ts +1130 -0
- package/src/clio/semantics.ts +134 -0
- package/src/clio/stress.ts +1731 -0
- package/src/clio/tagfix.ts +104 -0
- package/src/display.ts +321 -137
- package/src/engine.ts +22 -0
- package/src/index.ts +176 -32
- package/src/parser.ts +346 -682
- package/src/phonological.ts +72 -4
- package/src/phrasestress.ts +108 -0
- package/src/rhyme.ts +428 -8
- package/src/scansion.ts +136 -15
- package/src/semantics.ts +134 -0
- package/src/stress.ts +794 -64
- package/src/types.ts +39 -4
- package/tests/DataForHayesLinesOnly.txt +364 -0
- package/tests/DataForHayesStressSymbolsRevised.txt +728 -0
- package/tests/basic.test.ts +443 -37
- package/tests/bench-hayes.mjs +72 -0
- package/tests/wagner-stress.test.ts +188 -0
- package/vitest.config.ts +0 -15
package/src/rhyme.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// (Couplet, Triplet, Quatrain, Limerick, Petrarchan/Shakespearean Sonnet…).
|
|
15
15
|
|
|
16
16
|
import * as nounsing from 'nounsing-pro';
|
|
17
|
-
import { LineResult, PhonologicalScansionDetail } from './types.js';
|
|
17
|
+
import { ClsWord, LineResult, PhonologicalScansionDetail, StressLevel } from './types.js';
|
|
18
18
|
import { isPunctuation } from './parser.js';
|
|
19
19
|
import { ictusProfile } from './scansion.js';
|
|
20
|
+
import { preCaesuralWords } from './caesura.js';
|
|
20
21
|
|
|
21
22
|
export type RhymeTypeName =
|
|
22
23
|
| 'identical' | 'rich' | 'perfect' | 'family'
|
|
@@ -144,6 +145,8 @@ export function classifyRhymePair(a: string, b: string): RhymePair | null {
|
|
|
144
145
|
// Strength tiers for scheme detection.
|
|
145
146
|
const STRONG: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect', 'family']);
|
|
146
147
|
const SLANT: Set<RhymeTypeName> = new Set(['assonant', 'consonant', 'augmented', 'diminished', 'wrenched', 'eye']);
|
|
148
|
+
// Full rhyme only — the stricter bar a pre-caesural INTERNAL rhyme must clear.
|
|
149
|
+
const STRICT_INTERNAL: Set<RhymeTypeName> = new Set(['identical', 'rich', 'perfect']);
|
|
147
150
|
|
|
148
151
|
export interface LineRhyme {
|
|
149
152
|
endWord: string;
|
|
@@ -298,10 +301,110 @@ function poemForm(stanzas: { rhymes: LineRhyme[]; details: PhonologicalScansionD
|
|
|
298
301
|
return undefined;
|
|
299
302
|
}
|
|
300
303
|
|
|
304
|
+
/** Syllable-bearing (non-punctuation) words of a line, in linear order. */
|
|
305
|
+
function lineWords(line: LineResult): ClsWord[] {
|
|
306
|
+
return line.sentence.words.filter(w => !isPunctuation(w.lexicalClass) && w.syllables.length > 0);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Reader-facing surface of a word: the original case when the parser's
|
|
310
|
+
* sentence-initial de-capitalisation touched it ("Nap"->word:"nap"), else `word`.
|
|
311
|
+
* Rhyme CLASSIFICATION stays on `word` (it lowercases internally anyway); only
|
|
312
|
+
* what we REPORT uses this. */
|
|
313
|
+
function surfaceOf(w: ClsWord): string {
|
|
314
|
+
return w.displayWord ?? w.word;
|
|
315
|
+
}
|
|
316
|
+
|
|
301
317
|
/** Last syllable-bearing word of a line (across its merged sentences). */
|
|
302
318
|
function lineEndWord(line: LineResult): string {
|
|
303
|
-
const ws = line
|
|
304
|
-
return ws.length ? ws[ws.length - 1]
|
|
319
|
+
const ws = lineWords(line);
|
|
320
|
+
return ws.length ? surfaceOf(ws[ws.length - 1]) : '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── INTERNAL (PRE-CAESURAL) RHYME — additive layer ─────────────────
|
|
324
|
+
//
|
|
325
|
+
// The per-stanza END-rhyme scheme (detectScheme) is the primary, UNTOUCHED
|
|
326
|
+
// system: every line keeps its end letter AND its rhyme type. Layered ON TOP,
|
|
327
|
+
// a word immediately preceding a caesura that FULLY rhymes (identical/rich/
|
|
328
|
+
// perfect — never the looser slant tiers, which on a caesura word are mostly
|
|
329
|
+
// coincidental noise) with an end word, or with another such internal word, is
|
|
330
|
+
// annotated as an INTERNAL rhyme. It reuses the end-rhyme letter it echoes, or
|
|
331
|
+
// — for an internal-only pair — a fresh letter that does not collide with the
|
|
332
|
+
// stanza's end letters. Internal rhymes render parenthesised before the end
|
|
333
|
+
// letter ("(A)B"), each carrying its own type. Purely additive: the end
|
|
334
|
+
// scheme's letters and types are never modified.
|
|
335
|
+
|
|
336
|
+
/** Attach pre-caesural internal rhymes to each line's `detail.rhyme`, on top of
|
|
337
|
+
* the per-stanza end scheme `rhymes` (from detectScheme). Mutates in place. */
|
|
338
|
+
function attachInternalRhymes(lines: LineResult[], rhymes: LineRhyme[]): void {
|
|
339
|
+
interface Pos { line: number; word: string; kind: 'end' | 'internal'; }
|
|
340
|
+
const positions: Pos[] = [];
|
|
341
|
+
for (let i = 0; i < lines.length; i++) {
|
|
342
|
+
const lw = lineWords(lines[i]);
|
|
343
|
+
const endObj = lw.length ? lw[lw.length - 1] : undefined;
|
|
344
|
+
positions.push({ line: i, word: rhymes[i].endWord, kind: 'end' });
|
|
345
|
+
const pre = preCaesuralWords(
|
|
346
|
+
lines[i].sentence.words, lines[i].phonologicalHierarchy, lines[i].phonologicalScansion.scansion);
|
|
347
|
+
for (const { word } of pre) {
|
|
348
|
+
if (word === endObj || !word.isContent) continue; // the end word / function words are not internal-rhyme bearers
|
|
349
|
+
positions.push({ line: i, word: word.word, kind: 'internal' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Union-find over positions. End-end links are NOT made (detectScheme already
|
|
353
|
+
// lettered the ends); only links INVOLVING an internal, at the full-rhyme tier.
|
|
354
|
+
const parent = positions.map((_, i) => i);
|
|
355
|
+
const find = (x: number): number => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
|
356
|
+
const union = (a: number, b: number) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
|
|
357
|
+
for (let a = 0; a < positions.length; a++) {
|
|
358
|
+
for (let b = a + 1; b < positions.length; b++) {
|
|
359
|
+
if (positions[a].kind === 'end' && positions[b].kind === 'end') continue;
|
|
360
|
+
if (!positions[a].word || !positions[b].word) continue;
|
|
361
|
+
const pair = classifyRhymePair(positions[a].word, positions[b].word);
|
|
362
|
+
if (pair && STRICT_INTERNAL.has(pair.type)) union(a, b);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Letter per class: a class containing a lettered end takes that end's letter;
|
|
366
|
+
// an internal-only class of >=2 gets a fresh letter clear of the stanza's ends.
|
|
367
|
+
const endLetterAt = new Map<number, string>();
|
|
368
|
+
positions.forEach((p, i) => { if (p.kind === 'end' && rhymes[p.line].letter !== '\u00b7') endLetterAt.set(i, rhymes[p.line].letter); });
|
|
369
|
+
const used = new Set<string>(rhymes.filter(r => r.letter !== '\u00b7').map(r => r.letter));
|
|
370
|
+
let code = 0;
|
|
371
|
+
const freshLetter = () => { let c: string; do { c = String.fromCharCode(65 + (code++ % 26)); } while (used.has(c)); used.add(c); return c; };
|
|
372
|
+
const classMembers = new Map<number, number[]>();
|
|
373
|
+
for (let i = 0; i < positions.length; i++) { const r = find(i); if (!classMembers.has(r)) classMembers.set(r, []); classMembers.get(r)!.push(i); }
|
|
374
|
+
const classLetter = new Map<number, string>();
|
|
375
|
+
for (const [root, members] of classMembers) {
|
|
376
|
+
if (members.length < 2) continue;
|
|
377
|
+
const endMember = members.find(m => endLetterAt.has(m));
|
|
378
|
+
if (endMember !== undefined) classLetter.set(root, endLetterAt.get(endMember)!);
|
|
379
|
+
else if (members.some(m => positions[m].kind === 'internal')) classLetter.set(root, freshLetter());
|
|
380
|
+
}
|
|
381
|
+
const typeOf = (p: number): RhymeTypeName | undefined => {
|
|
382
|
+
for (const m of classMembers.get(find(p)) ?? []) {
|
|
383
|
+
if (m === p) continue;
|
|
384
|
+
const pair = classifyRhymePair(positions[m].word, positions[p].word);
|
|
385
|
+
if (pair && STRICT_INTERNAL.has(pair.type)) return pair.type;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
};
|
|
389
|
+
for (let i = 0; i < lines.length; i++) {
|
|
390
|
+
const internal: { word: string; letter: string; type?: string }[] = [];
|
|
391
|
+
positions.forEach((p, idx) => {
|
|
392
|
+
if (p.line !== i || p.kind !== 'internal') return;
|
|
393
|
+
const letter = classLetter.get(find(idx));
|
|
394
|
+
if (letter) internal.push({ word: p.word, letter, type: typeOf(idx) });
|
|
395
|
+
});
|
|
396
|
+
const endLetter = rhymes[i].letter;
|
|
397
|
+
const intStr = internal.map(x => `(${x.letter})`).join('');
|
|
398
|
+
const notation = intStr + (endLetter !== '\u00b7' ? endLetter : (internal.length ? '' : '\u00b7'));
|
|
399
|
+
lines[i].phonologicalScansion.rhyme = {
|
|
400
|
+
endWord: rhymes[i].endWord,
|
|
401
|
+
letter: endLetter,
|
|
402
|
+
type: rhymes[i].type,
|
|
403
|
+
matchedLine: rhymes[i].matchedLine,
|
|
404
|
+
internal: internal.length ? internal : undefined,
|
|
405
|
+
notation,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
305
408
|
}
|
|
306
409
|
|
|
307
410
|
/**
|
|
@@ -311,18 +414,335 @@ function lineEndWord(line: LineResult): string {
|
|
|
311
414
|
* meter/scansion/certainty is touched.
|
|
312
415
|
*/
|
|
313
416
|
export function applyRhymeAndForm(stanzas: LineResult[][]): void {
|
|
417
|
+
// FORM detection uses per-stanza end-rhyme schemes (relative, restarting each
|
|
418
|
+
// stanza) — that is what couplet/quatrain/sonnet patterns are defined over.
|
|
314
419
|
const analyzed = stanzas.map(lines => {
|
|
315
420
|
const details = lines.map(l => l.phonologicalScansion);
|
|
316
421
|
const rhymes = detectScheme(lines.map(lineEndWord));
|
|
317
|
-
return { details, rhymes };
|
|
422
|
+
return { lines, details, rhymes };
|
|
318
423
|
});
|
|
319
424
|
for (const { details, rhymes } of analyzed) {
|
|
320
425
|
const form = stanzaForm(rhymes, details);
|
|
321
|
-
for (
|
|
322
|
-
details[i].rhyme = rhymes[i];
|
|
323
|
-
details[i].formNote = form;
|
|
324
|
-
}
|
|
426
|
+
for (const d of details) d.formNote = form;
|
|
325
427
|
}
|
|
326
428
|
const pform = poemForm(analyzed);
|
|
327
429
|
if (pform) for (const { details } of analyzed) for (const d of details) d.formNote = pform;
|
|
430
|
+
// Per-stanza end-rhyme letters + TYPES (detectScheme), then the additive
|
|
431
|
+
// pre-caesural internal-rhyme layer. Writes `detail.rhyme`; the original
|
|
432
|
+
// end-rhyme display is restored and extended, never replaced.
|
|
433
|
+
for (const { lines, rhymes } of analyzed) attachInternalRhymes(lines, rhymes);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── PHONOPOETICS — poem-wide end / caesural / head rhyme, alliteration,
|
|
437
|
+
// acrostic, with the maintainer's three-phase lettering ─────────────
|
|
438
|
+
//
|
|
439
|
+
// Lettering proceeds in three phases over ONE shared alphabet:
|
|
440
|
+
// 1. END words (line 1→N): each rhyme group gets a letter A,B,C…; a later word
|
|
441
|
+
// reuses an earlier group's letter when it rhymes with it (across stanzas).
|
|
442
|
+
// 2. CAESURAL words (pre-caesura): continue the alphabet (new letter per new
|
|
443
|
+
// caesural rhyme); a caesural word reuses an END letter ONLY if it rhymes
|
|
444
|
+
// with that exact end group.
|
|
445
|
+
// 3. HEAD words (line-initial): continue the alphabet, a new letter per head
|
|
446
|
+
// rhyme (head rhymes do not reuse end/caesural letters).
|
|
447
|
+
// Alliteration carries NO letters; an acrostic's letters are inherent.
|
|
448
|
+
// Each rhyme letter is to be COLOURED by the strongest relative-stress tier among
|
|
449
|
+
// the syllables it spans (`topStress`) — done at the display layer.
|
|
450
|
+
|
|
451
|
+
const STRESS_ORDER: StressLevel[] = ['x', 'w', 'n', 'm', 's'];
|
|
452
|
+
function topStressOf(...ws: (ClsWord | undefined)[]): StressLevel {
|
|
453
|
+
let best: StressLevel = 'x';
|
|
454
|
+
for (const w of ws) {
|
|
455
|
+
if (!w) continue;
|
|
456
|
+
for (const s of w.syllables) {
|
|
457
|
+
const r = s.relativeStress ?? 'w';
|
|
458
|
+
if (STRESS_ORDER.indexOf(r) > STRESS_ORDER.indexOf(best)) best = r;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return best;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** First consonant phoneme of a word (for alliteration), or '' if vowel-initial/OOV. */
|
|
465
|
+
function onsetPhone(word: string): string {
|
|
466
|
+
const ph = phonesOf(word);
|
|
467
|
+
if (!ph || ph.length === 0) return '';
|
|
468
|
+
const p0 = base(ph[0]);
|
|
469
|
+
return isVowelPhone(p0) ? '' : p0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export interface RhymeRel {
|
|
473
|
+
fromWord: string; fromLabel: string; // the rhyme site being annotated
|
|
474
|
+
toWord: string; toLabel: string; // its partner
|
|
475
|
+
letter: string;
|
|
476
|
+
type?: RhymeTypeName;
|
|
477
|
+
kind: 'end' | 'caesural' | 'head';
|
|
478
|
+
topStress: StressLevel; // strongest tier spanned (letter colour)
|
|
479
|
+
}
|
|
480
|
+
export interface Phonopoetics {
|
|
481
|
+
endScheme: string; // poem-wide canonical end scheme ("ABAB…")
|
|
482
|
+
end: RhymeRel[];
|
|
483
|
+
caesural: RhymeRel[];
|
|
484
|
+
head: RhymeRel[];
|
|
485
|
+
alliteration: { label: string; words: string[] }[];
|
|
486
|
+
acrostics: { labels: string[]; firsts: string[]; word: string }[];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Whole-poem phonopoetic analysis: structured data for the Phonopoetics
|
|
490
|
+
* synopsis section. Read-only — never mutates the per-line scansion. */
|
|
491
|
+
export function analyzePhonopoetics(stanzas: LineResult[][]): Phonopoetics {
|
|
492
|
+
const multi = stanzas.length > 1;
|
|
493
|
+
interface PL { line: LineResult; label: string; }
|
|
494
|
+
const PLs: PL[] = [];
|
|
495
|
+
stanzas.forEach((st, s) => st.forEach((line, l) =>
|
|
496
|
+
PLs.push({ line, label: multi ? `S${s + 1}L${l + 1}` : `L${l + 1}` })));
|
|
497
|
+
|
|
498
|
+
const endObj = (pl: PL): ClsWord | undefined => { const ws = lineWords(pl.line); return ws[ws.length - 1]; };
|
|
499
|
+
const headObj = (pl: PL): ClsWord | undefined => lineWords(pl.line).find(w => w.isContent);
|
|
500
|
+
const caesuralObjs = (pl: PL): ClsWord[] => {
|
|
501
|
+
const end = endObj(pl);
|
|
502
|
+
const pre = preCaesuralWords(pl.line.sentence.words, pl.line.phonologicalHierarchy, pl.line.phonologicalScansion.scansion);
|
|
503
|
+
return pre.map(p => p.word).filter(w => w !== end && w.isContent);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// ── Phase 1: END rhymes (poem-wide) ──
|
|
507
|
+
const endWords = PLs.map(pl => lineEndWord(pl.line));
|
|
508
|
+
const es = detectScheme(endWords);
|
|
509
|
+
const endScheme = canonicalScheme(es);
|
|
510
|
+
const byLetter = new Map<string, number[]>();
|
|
511
|
+
es.forEach((r, i) => { if (r.letter !== '·') { (byLetter.get(r.letter) ?? byLetter.set(r.letter, []).get(r.letter)!).push(i); } });
|
|
512
|
+
let nextCode = byLetter.size;
|
|
513
|
+
const freshLetter = (): string => String.fromCharCode(65 + (nextCode++ % 26));
|
|
514
|
+
|
|
515
|
+
const end: RhymeRel[] = [];
|
|
516
|
+
for (const [letter, idxs] of byLetter) {
|
|
517
|
+
for (let k = 1; k < idxs.length; k++) {
|
|
518
|
+
const i = idxs[k], j = idxs[k - 1]; // j earlier, i later → show earlier → later
|
|
519
|
+
end.push({
|
|
520
|
+
fromWord: endWords[j], fromLabel: PLs[j].label,
|
|
521
|
+
toWord: endWords[i], toLabel: PLs[i].label,
|
|
522
|
+
letter, type: es[i].type, kind: 'end',
|
|
523
|
+
topStress: topStressOf(endObj(PLs[i]), endObj(PLs[j])),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Phase 2: CAESURAL rhymes ──
|
|
529
|
+
interface CW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
|
|
530
|
+
const cws: CW[] = [];
|
|
531
|
+
PLs.forEach((pl, i) => caesuralObjs(pl).forEach(w => cws.push({ pl: i, word: w })));
|
|
532
|
+
// (a) bind to an exact END group it rhymes with → reuse that end letter
|
|
533
|
+
for (const cw of cws) {
|
|
534
|
+
for (let i = 0; i < PLs.length; i++) {
|
|
535
|
+
if (es[i].letter === '·') continue; // unrhymed end → not a "pair"
|
|
536
|
+
const ew = endObj(PLs[i]); if (!ew || ew === cw.word) continue;
|
|
537
|
+
const pair = classifyRhymePair(cw.word.word, ew.word);
|
|
538
|
+
if (pair && STRICT_INTERNAL.has(pair.type)) {
|
|
539
|
+
cw.letter = es[i].letter; cw.pLabel = PLs[i].label; cw.pWord = surfaceOf(ew); cw.type = pair.type; break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// (b) caesural↔caesural among the still-unbound → fresh letters
|
|
544
|
+
const unbound = cws.filter(c => !c.letter);
|
|
545
|
+
for (let i = 0; i < unbound.length; i++) {
|
|
546
|
+
if (unbound[i].letter) continue;
|
|
547
|
+
for (let j = 0; j < i; j++) {
|
|
548
|
+
const pair = classifyRhymePair(unbound[i].word.word, unbound[j].word.word);
|
|
549
|
+
if (pair && STRICT_INTERNAL.has(pair.type)) {
|
|
550
|
+
if (!unbound[j].letter) unbound[j].letter = freshLetter();
|
|
551
|
+
unbound[i].letter = unbound[j].letter;
|
|
552
|
+
// only the later member is annotated (points back) so each pair shows once
|
|
553
|
+
unbound[i].pLabel = PLs[unbound[j].pl].label; unbound[i].pWord = surfaceOf(unbound[j].word); unbound[i].type = pair.type;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const caesural: RhymeRel[] = cws.filter(c => c.letter && c.pLabel).map(c => ({
|
|
559
|
+
fromWord: surfaceOf(c.word), fromLabel: PLs[c.pl].label,
|
|
560
|
+
toWord: c.pWord!, toLabel: c.pLabel!,
|
|
561
|
+
letter: c.letter!, type: c.type, kind: 'caesural' as const,
|
|
562
|
+
topStress: topStressOf(c.word),
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
// ── Phase 3: HEAD rhymes (line-initial) — fresh letters, no reuse ──
|
|
566
|
+
interface HW { pl: number; word: ClsWord; letter?: string; pLabel?: string; pWord?: string; type?: RhymeTypeName; }
|
|
567
|
+
const hws: HW[] = PLs.map((pl, i) => ({ pl: i, word: headObj(pl)! })).filter(h => h.word);
|
|
568
|
+
for (let i = 0; i < hws.length; i++) {
|
|
569
|
+
if (hws[i].letter) continue;
|
|
570
|
+
for (let j = 0; j < i; j++) {
|
|
571
|
+
const pair = classifyRhymePair(hws[i].word.word, hws[j].word.word);
|
|
572
|
+
if (pair && STRICT_INTERNAL.has(pair.type)) {
|
|
573
|
+
if (!hws[j].letter) hws[j].letter = freshLetter();
|
|
574
|
+
hws[i].letter = hws[j].letter;
|
|
575
|
+
// only the later member is annotated (points back) so each pair shows once
|
|
576
|
+
hws[i].pLabel = PLs[hws[j].pl].label; hws[i].pWord = surfaceOf(hws[j].word); hws[i].type = pair.type;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const head: RhymeRel[] = hws.filter(h => h.letter && h.pLabel).map(h => ({
|
|
582
|
+
fromWord: surfaceOf(h.word), fromLabel: PLs[h.pl].label,
|
|
583
|
+
toWord: h.pWord!, toLabel: h.pLabel!,
|
|
584
|
+
letter: h.letter!, type: h.type, kind: 'head' as const,
|
|
585
|
+
topStress: topStressOf(h.word),
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
// ── Alliteration: maximal runs (≥2) of content words sharing first letter AND
|
|
589
|
+
// first consonant phoneme (function words may sit between, but do not count). ──
|
|
590
|
+
const alliteration: { label: string; words: string[] }[] = [];
|
|
591
|
+
for (const pl of PLs) {
|
|
592
|
+
const cw = lineWords(pl.line).filter(w => w.isContent);
|
|
593
|
+
let run: ClsWord[] = [];
|
|
594
|
+
const flush = () => { if (run.length >= 2) alliteration.push({ label: pl.label, words: run.map(surfaceOf) }); run = []; };
|
|
595
|
+
for (const w of cw) {
|
|
596
|
+
const letter0 = (w.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
|
|
597
|
+
const phone0 = onsetPhone(w.word);
|
|
598
|
+
if (!letter0 || !phone0) { flush(); continue; }
|
|
599
|
+
if (run.length === 0) { run = [w]; continue; }
|
|
600
|
+
const prev = run[run.length - 1];
|
|
601
|
+
const pLetter = (prev.word.match(/[a-z]/i)?.[0] ?? '').toLowerCase();
|
|
602
|
+
if (letter0 === pLetter && phone0 === onsetPhone(prev.word)) run.push(w);
|
|
603
|
+
else { flush(); run = [w]; }
|
|
604
|
+
}
|
|
605
|
+
flush();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Acrostic: per-stanza (and, if multi-stanza, whole-poem) first letters that
|
|
609
|
+
// spell a dictionary word (≥3 letters). ──
|
|
610
|
+
const acrostics: { labels: string[]; firsts: string[]; word: string }[] = [];
|
|
611
|
+
const firstLetterOf = (line: LineResult): string => {
|
|
612
|
+
const ws = lineWords(line);
|
|
613
|
+
return ws.length ? (ws[0].word.match(/[a-z]/i)?.[0] ?? '').toUpperCase() : '';
|
|
614
|
+
};
|
|
615
|
+
const checkAcrostic = (lines: LineResult[], labels: string[]) => {
|
|
616
|
+
const firsts = lines.map(firstLetterOf);
|
|
617
|
+
if (firsts.some(f => !f)) return;
|
|
618
|
+
const word = firsts.join('');
|
|
619
|
+
if (word.length >= 3 && phonesOf(word)) acrostics.push({ labels, firsts, word });
|
|
620
|
+
};
|
|
621
|
+
stanzas.forEach((st, s) => checkAcrostic(st, st.map((_, l) => multi ? `S${s + 1}L${l + 1}` : `L${l + 1}`)));
|
|
622
|
+
if (multi) checkAcrostic(stanzas.flat(), PLs.map(p => p.label));
|
|
623
|
+
|
|
624
|
+
return { endScheme, end, caesural, head, alliteration, acrostics };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ─── POEM-LEVEL META-MEASURE (synopsis) ─────────────────────────────
|
|
628
|
+
//
|
|
629
|
+
// A cumulative, NON-INTERFERING reading of the whole poem, shown at the foot of
|
|
630
|
+
// the reading views. It draws solely on determinations already made per line
|
|
631
|
+
// (meter, rhythm, rhyme, form) and never overrides them. Deliberately offers
|
|
632
|
+
// SEVERAL top conclusions rather than forcing a single verdict.
|
|
633
|
+
|
|
634
|
+
// Canonical end-rhyme schemes of the LYRICAL FORM_REGISTRY forms whose pattern is
|
|
635
|
+
// expressible in plain letters (refrain forms — villanelle, pantoum, rondeau … —
|
|
636
|
+
// need repetition checking and are deferred). Used to NOTE whole-poem rhyme-
|
|
637
|
+
// scheme alignment in the synopsis; the per-stanza/poem form layer remains the
|
|
638
|
+
// authority for the FORM verdict itself.
|
|
639
|
+
const REGISTRY_FORM_SCHEMES: { lines: number; scheme: string; name: string }[] = [
|
|
640
|
+
{ lines: 2, scheme: 'AA', name: 'Couplet' },
|
|
641
|
+
{ lines: 3, scheme: 'ABA', name: 'Triplet' },
|
|
642
|
+
{ lines: 4, scheme: 'ABAB', name: 'Quatrain' },
|
|
643
|
+
{ lines: 5, scheme: 'AABBA', name: 'Limerick' },
|
|
644
|
+
{ lines: 6, scheme: 'ABABCC', name: 'Sextilla' },
|
|
645
|
+
{ lines: 7, scheme: 'ABABBCC', name: 'Septet (rhyme royal)' },
|
|
646
|
+
{ lines: 9, scheme: 'AAABBBCCC', name: 'Triad' },
|
|
647
|
+
{ lines: 10, scheme: 'ABABCDECDE', name: 'English Ode' },
|
|
648
|
+
{ lines: 14, scheme: 'ABABCDCDEFEFGG', name: 'Shakespearean Sonnet' },
|
|
649
|
+
{ lines: 14, scheme: 'ABBAABBACDCDCD', name: 'Petrarchan Sonnet' },
|
|
650
|
+
{ lines: 14, scheme: 'ABBAABBACDECDE', name: 'Petrarchan Sonnet' },
|
|
651
|
+
];
|
|
652
|
+
|
|
653
|
+
function matchRegistryForm(totalLines: number, canonicalEndScheme: string): string | undefined {
|
|
654
|
+
return REGISTRY_FORM_SCHEMES.find(f => f.lines === totalLines && f.scheme === canonicalEndScheme)?.name;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A labelled line of the poem synopsis (display renders each). */
|
|
658
|
+
export interface SynopsisRow { label: string; value: string; }
|
|
659
|
+
|
|
660
|
+
const descriptorOf = (d: PhonologicalScansionDetail): string =>
|
|
661
|
+
d.metricalityNote ? 'plausible prose'
|
|
662
|
+
: d.rhythmNote ? d.rhythmNote // accentual/dolnik/taktovik → beats
|
|
663
|
+
: d.meterName === 'free verse' ? 'free verse'
|
|
664
|
+
: d.meter; // e.g. "iambic pentameter"
|
|
665
|
+
|
|
666
|
+
// Compact metre labels for the synopsis ("iambic pentameter" → "iamb penta").
|
|
667
|
+
// Rhythm notes ("4/3 ♪beat accentual"), "free verse", and "plausible prose" are
|
|
668
|
+
// left untouched (they are not family+foot labels).
|
|
669
|
+
const _FAMILY_ABBR: Record<string, string> = {
|
|
670
|
+
iambic: 'iamb', trochaic: 'troch', dactylic: 'dact', anapestic: 'anap',
|
|
671
|
+
amphibrachic: 'amph', bacchic: 'bacch', spondaic: 'spon', pyrrhic: 'pyrr',
|
|
672
|
+
};
|
|
673
|
+
const _FOOT_ABBR: Record<string, string> = {
|
|
674
|
+
monometer: 'mono', dimeter: 'di', trimeter: 'tri', tetrameter: 'tetra',
|
|
675
|
+
pentameter: 'penta', hexameter: 'hexa', heptameter: 'hepta', octameter: 'octa',
|
|
676
|
+
};
|
|
677
|
+
function abbrevMeter(label: string): string {
|
|
678
|
+
const parts = label.split(' ');
|
|
679
|
+
if (parts.length === 2 && _FAMILY_ABBR[parts[0]] && _FOOT_ABBR[parts[1]]) {
|
|
680
|
+
return `${_FAMILY_ABBR[parts[0]]} ${_FOOT_ABBR[parts[1]]}`;
|
|
681
|
+
}
|
|
682
|
+
return label;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Build the cumulative poem synopsis: top meter(s)/mixed meters (accentual forms
|
|
687
|
+
* reported in beats), the poem-wide rhyme scheme (with internal rhymes), and the
|
|
688
|
+
* poetic-form alignment. Reads only existing per-line determinations.
|
|
689
|
+
*/
|
|
690
|
+
export function summarizePoem(stanzas: LineResult[][]): SynopsisRow[] {
|
|
691
|
+
const lines = stanzas.flat();
|
|
692
|
+
const details = lines.map(l => l.phonologicalScansion);
|
|
693
|
+
const N = details.length;
|
|
694
|
+
const rows: SynopsisRow[] = [];
|
|
695
|
+
if (N === 0) return rows;
|
|
696
|
+
|
|
697
|
+
// ── Meter — several top conclusions, never forced into one ──
|
|
698
|
+
const tally = new Map<string, number>();
|
|
699
|
+
for (const d of details) tally.set(descriptorOf(d), (tally.get(descriptorOf(d)) ?? 0) + 1);
|
|
700
|
+
const ranked = [...tally.entries()].sort((a, b) => b[1] - a[1]);
|
|
701
|
+
let meterVal: string;
|
|
702
|
+
if (N === 1) {
|
|
703
|
+
meterVal = abbrevMeter(ranked[0][0]);
|
|
704
|
+
} else if (ranked[0][1] / N >= 0.6) {
|
|
705
|
+
const rest = ranked.slice(1, 3).map(([m, c]) => `${abbrevMeter(m)} (${c})`);
|
|
706
|
+
meterVal = `predominantly ${abbrevMeter(ranked[0][0])} (${ranked[0][1]}/${N})`
|
|
707
|
+
+ (rest.length ? `; also ${rest.join(', ')}` : '');
|
|
708
|
+
} else {
|
|
709
|
+
meterVal = 'Mixed | ' + ranked.slice(0, 3).map(([m, c]) => `${abbrevMeter(m)} (${c}/${N})`).join(' · ');
|
|
710
|
+
}
|
|
711
|
+
// "Rhythm" = the metre make-up; "Meter" = the mean fit % (the maintainer's
|
|
712
|
+
// synopsis taxonomy).
|
|
713
|
+
rows.push({ label: 'Rhythm', value: meterVal });
|
|
714
|
+
|
|
715
|
+
// ── Meter — mean of the per-line fit certainties ──
|
|
716
|
+
const meanCert = Math.round(details.reduce((s, d) => s + d.certainty, 0) / N);
|
|
717
|
+
rows.push({ label: 'Meter', value: `~${meanCert}% mean line-wise fit` });
|
|
718
|
+
|
|
719
|
+
// ── Form — the form layer's verdict(s) + registry-scheme alignment. (The
|
|
720
|
+
// detailed rhyme listing now lives in the Phonopoetics section; the synopsis
|
|
721
|
+
// carries only the canonical End-Rhyme Scheme, below.) ──
|
|
722
|
+
const endRhymes = N >= 2 ? detectScheme(lines.map(lineEndWord)) : [];
|
|
723
|
+
const endScheme = endRhymes.length ? canonicalScheme(endRhymes) : '';
|
|
724
|
+
const hasEndRhyme = endRhymes.some(r => r.letter !== '·');
|
|
725
|
+
const forms = [...new Set(details.map(d => d.formNote).filter((x): x is string => !!x))];
|
|
726
|
+
let formVal: string | undefined = forms.length ? forms.join(' · ') : undefined;
|
|
727
|
+
if (N >= 2) {
|
|
728
|
+
const reg = matchRegistryForm(N, endScheme);
|
|
729
|
+
if (reg && !(formVal && formVal.includes(reg))) {
|
|
730
|
+
formVal = (formVal ? formVal + ' · ' : '') + `aligns with the ${reg} scheme`;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (formVal) rows.push({ label: 'Form', value: formVal });
|
|
734
|
+
|
|
735
|
+
// ── End-Rhyme Scheme — poem-wide canonical scheme, its own row (only end
|
|
736
|
+
// rhymes are matched against forms; caesural/head live in Phonopoetics). ──
|
|
737
|
+
if (hasEndRhyme) rows.push({ label: 'End-Rhyme Scheme', value: endScheme });
|
|
738
|
+
|
|
739
|
+
// ── Heterometric advisory — the whole-poem observation that per-line beat
|
|
740
|
+
// counts vary widely (kept OUT of the per-line display, as an advisory here). ──
|
|
741
|
+
const beats = details.map(d => (d.footCount > 0 ? d.footCount : ictusProfile(d.scansion).ictuses));
|
|
742
|
+
const lo = Math.min(...beats), hi = Math.max(...beats);
|
|
743
|
+
if (N >= 3 && hi - lo >= 3) {
|
|
744
|
+
rows.push({ label: 'Note', value: `line lengths vary — ${lo}–${hi} beats per line` });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return rows;
|
|
328
748
|
}
|