calliope-ts 0.0.4 → 0.1.0

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.
Files changed (183) hide show
  1. package/README.md +69 -3
  2. package/dist/calliope/bracketing.d.ts.map +1 -1
  3. package/dist/calliope/bracketing.js +59 -5
  4. package/dist/calliope/deps.d.ts +5 -1
  5. package/dist/calliope/deps.d.ts.map +1 -1
  6. package/dist/calliope/deps.js +53 -3
  7. package/dist/calliope/engine.d.ts.map +1 -1
  8. package/dist/calliope/engine.js +6 -1
  9. package/dist/calliope/relstress.d.ts.map +1 -1
  10. package/dist/calliope/relstress.js +411 -15
  11. package/dist/calliope/syntax.d.ts +33 -0
  12. package/dist/calliope/syntax.d.ts.map +1 -1
  13. package/dist/calliope/syntax.js +199 -0
  14. package/dist/data/en_common_words.json +1 -0
  15. package/dist/display.d.ts +15 -1
  16. package/dist/display.d.ts.map +1 -1
  17. package/dist/display.js +112 -10
  18. package/dist/fabbhalle.d.ts +54 -0
  19. package/dist/fabbhalle.d.ts.map +1 -0
  20. package/dist/fabbhalle.js +404 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +86 -33
  23. package/dist/phonological.d.ts.map +1 -1
  24. package/dist/phonological.js +7 -1
  25. package/dist/rewriteEn.d.ts +14 -0
  26. package/dist/rewriteEn.d.ts.map +1 -0
  27. package/dist/rewriteEn.js +392 -0
  28. package/dist/rhyme.d.ts +35 -3
  29. package/dist/rhyme.d.ts.map +1 -1
  30. package/dist/rhyme.js +176 -11
  31. package/dist/russian/accentuator.d.ts +31 -0
  32. package/dist/russian/accentuator.d.ts.map +1 -0
  33. package/dist/russian/accentuator.js +546 -0
  34. package/dist/russian/collocations.d.ts +7 -0
  35. package/dist/russian/collocations.d.ts.map +1 -0
  36. package/dist/russian/collocations.js +38 -0
  37. package/dist/russian/compounds.d.ts +6 -0
  38. package/dist/russian/compounds.d.ts.map +1 -0
  39. package/dist/russian/compounds.js +81 -0
  40. package/dist/russian/download.d.ts +23 -0
  41. package/dist/russian/download.d.ts.map +1 -0
  42. package/dist/russian/download.js +117 -0
  43. package/dist/russian/engine.d.ts +6 -0
  44. package/dist/russian/engine.d.ts.map +1 -0
  45. package/dist/russian/engine.js +1424 -0
  46. package/dist/russian/parser.d.ts +8 -0
  47. package/dist/russian/parser.d.ts.map +1 -0
  48. package/dist/russian/parser.js +92 -0
  49. package/dist/russian/paths.d.ts +55 -0
  50. package/dist/russian/paths.d.ts.map +1 -0
  51. package/dist/russian/paths.js +114 -0
  52. package/dist/russian/rewrite.d.ts +6 -0
  53. package/dist/russian/rewrite.d.ts.map +1 -0
  54. package/dist/russian/rewrite.js +400 -0
  55. package/dist/russian/rhyme.d.ts +18 -0
  56. package/dist/russian/rhyme.d.ts.map +1 -0
  57. package/dist/russian/rhyme.js +230 -0
  58. package/dist/russian/syllabifier.d.ts +14 -0
  59. package/dist/russian/syllabifier.d.ts.map +1 -0
  60. package/dist/russian/syllabifier.js +475 -0
  61. package/dist/russian/types.d.ts +94 -0
  62. package/dist/russian/types.d.ts.map +1 -0
  63. package/dist/russian/types.js +27 -0
  64. package/dist/scandroidNative/constants.d.ts +16 -0
  65. package/dist/scandroidNative/constants.d.ts.map +1 -0
  66. package/dist/scandroidNative/constants.js +60 -0
  67. package/dist/scandroidNative/deduceParameters.d.ts +16 -0
  68. package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
  69. package/dist/scandroidNative/deduceParameters.js +69 -0
  70. package/dist/scandroidNative/dictionary-data.d.ts +2 -0
  71. package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
  72. package/dist/scandroidNative/dictionary-data.js +744 -0
  73. package/dist/scandroidNative/dictionary.d.ts +12 -0
  74. package/dist/scandroidNative/dictionary.d.ts.map +1 -0
  75. package/dist/scandroidNative/dictionary.js +50 -0
  76. package/dist/scandroidNative/engine.d.ts +41 -0
  77. package/dist/scandroidNative/engine.d.ts.map +1 -0
  78. package/dist/scandroidNative/engine.js +119 -0
  79. package/dist/scandroidNative/machine.d.ts +93 -0
  80. package/dist/scandroidNative/machine.d.ts.map +1 -0
  81. package/dist/scandroidNative/machine.js +795 -0
  82. package/dist/scandroidNative/positioner.d.ts +32 -0
  83. package/dist/scandroidNative/positioner.d.ts.map +1 -0
  84. package/dist/scandroidNative/positioner.js +208 -0
  85. package/dist/scandroidNative/syllabizer.d.ts +3 -0
  86. package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
  87. package/dist/scandroidNative/syllabizer.js +215 -0
  88. package/dist/scandroidNative/utilities.d.ts +37 -0
  89. package/dist/scandroidNative/utilities.d.ts.map +1 -0
  90. package/dist/scandroidNative/utilities.js +86 -0
  91. package/dist/scansion.d.ts.map +1 -1
  92. package/dist/scansion.js +118 -14
  93. package/dist/stress.d.ts +1 -0
  94. package/dist/stress.d.ts.map +1 -1
  95. package/dist/stress.js +502 -42
  96. package/dist/types.d.ts +3 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/package.json +27 -6
  99. package/tools/fetch-russian-data.mjs +30 -0
  100. package/webapp/public/app.js +2418 -0
  101. package/webapp/public/assets/GithubLogo.png +0 -0
  102. package/webapp/public/assets/NPM2.png +0 -0
  103. package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
  104. package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
  105. package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
  106. package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
  107. package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
  108. package/webapp/public/assets/fonts/butterflies.ttf +0 -0
  109. package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
  110. package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
  111. package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
  112. package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
  113. package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
  114. package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
  115. package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
  116. package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
  117. package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
  118. package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
  119. package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
  120. package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
  121. package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
  122. package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
  123. package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
  124. package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
  125. package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
  126. package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
  127. package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
  128. package/webapp/public/index.html +316 -0
  129. package/webapp/public/styles.css +1114 -0
  130. package/webapp/server.mjs +680 -0
  131. package/src/caesura.ts +0 -201
  132. package/src/calliope/boundaries.ts +0 -190
  133. package/src/calliope/bracketing.ts +0 -390
  134. package/src/calliope/deps.ts +0 -160
  135. package/src/calliope/engine.ts +0 -77
  136. package/src/calliope/feats.ts +0 -46
  137. package/src/calliope/names.ts +0 -44
  138. package/src/calliope/postag.ts +0 -253
  139. package/src/calliope/prosodic.ts +0 -262
  140. package/src/calliope/relstress.ts +0 -645
  141. package/src/calliope/stressrules.ts +0 -147
  142. package/src/calliope/syntax.ts +0 -218
  143. package/src/calliope/udpos.ts +0 -152
  144. package/src/calliope_src_contents.md +0 -19049
  145. package/src/clio/caesura.ts +0 -145
  146. package/src/clio/depfix.ts +0 -88
  147. package/src/clio/display.ts +0 -1042
  148. package/src/clio/engine.ts +0 -38
  149. package/src/clio/parser.ts +0 -845
  150. package/src/clio/phonological.ts +0 -849
  151. package/src/clio/phrasestress.ts +0 -108
  152. package/src/clio/pipeline.ts +0 -154
  153. package/src/clio/rhyme.ts +0 -740
  154. package/src/clio/scandroid.ts +0 -434
  155. package/src/clio/scansion.ts +0 -1130
  156. package/src/clio/semantics.ts +0 -134
  157. package/src/clio/stress.ts +0 -1731
  158. package/src/clio/tagfix.ts +0 -104
  159. package/src/depfix.ts +0 -88
  160. package/src/display.ts +0 -1138
  161. package/src/engine.ts +0 -22
  162. package/src/index.ts +0 -690
  163. package/src/parser.ts +0 -501
  164. package/src/phonological.ts +0 -917
  165. package/src/phrasestress.ts +0 -108
  166. package/src/rhyme.ts +0 -748
  167. package/src/scandroid.ts +0 -434
  168. package/src/scansion.ts +0 -1174
  169. package/src/semantics.ts +0 -134
  170. package/src/stress.ts +0 -2111
  171. package/src/tagfix.ts +0 -104
  172. package/src/types.ts +0 -265
  173. package/tests/DataForHayesLinesOnly.txt +0 -364
  174. package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
  175. package/tests/basic.test.ts +0 -1102
  176. package/tests/bench-hayes.mjs +0 -72
  177. package/tests/epg64.meter.train.txt +0 -8139
  178. package/tests/litlab-sample-2016.txt +0 -1738
  179. package/tests/prosodic.meter.train.txt +0 -13192
  180. package/tests/wagner-stress.test.ts +0 -188
  181. package/tsconfig.json +0 -29
  182. package/vitest.config.d.ts +0 -2
  183. package/vitest.config.js +0 -14
@@ -1,1102 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseDocument, isPunctuation } from '../src/parser.js';
3
- import {
4
- assignLexicalStress,
5
- applyCompoundStress,
6
- applyNuclearStress,
7
- assignRelativeStresses,
8
- } from '../src/stress.js';
9
- import { computePhraseStress } from '../src/phrasestress.js';
10
- import { buildPhonologicalHierarchy } from '../src/phonological.js';
11
- import { extractKeyStresses, scoreMeters } from '../src/scansion.js';
12
- import {
13
- scandroidCorralWeird,
14
- scandroidMaximizeNormal,
15
- stressToMarks,
16
- } from '../src/scandroid.js';
17
- import { analyzeText, feedMultilineEvent, newMLState, ML_IDLE_MS, type MLEvent } from '../src/index.js';
18
- import type { PhonologicalScansionDetail } from '../src/types.js';
19
- import { classifyRhymePair, detectScheme, summarizePoem, analyzePhonopoetics } from '../src/rhyme.js';
20
- import { metricalityVerdict } from '../src/scansion.js';
21
- import { analyzeStanzas } from '../src/index.js';
22
- import { computeCaesurae } from '../src/caesura.js';
23
-
24
- /** Drive the multi-line input reducer with a synthetic event sequence. */
25
- function runMultiline(events: MLEvent[]): { result: string; lines: string[] } {
26
- const st = newMLState();
27
- let result = 'continue';
28
- for (const ev of events) {
29
- result = feedMultilineEvent(st, ev);
30
- if (result !== 'continue') break;
31
- }
32
- return { result, lines: st.lines };
33
- }
34
- const FAST = ML_IDLE_MS - 50; // burst-speed (paste)
35
- const SLOW = ML_IDLE_MS + 400; // deliberate keystroke
36
- /** A whole line of pasted text: its chars (burst), then a burst-speed newline. */
37
- function pasteLine(text: string, firstGap = FAST): MLEvent[] {
38
- const evs: MLEvent[] = [];
39
- [...text].forEach((c, i) => evs.push({ kind: 'char', str: c, gap: i === 0 ? firstGap : FAST }));
40
- evs.push({ kind: 'return', gap: FAST });
41
- return evs;
42
- }
43
-
44
- function scanLine(line: string): PhonologicalScansionDetail {
45
- const results = analyzeText(line, false);
46
- return results[0].phonologicalScansion;
47
- }
48
-
49
- function runPipeline(line: string) {
50
- const doc = parseDocument(line);
51
- const sent = doc.sentences[0];
52
- assignLexicalStress(sent.words);
53
- const ius = buildPhonologicalHierarchy(sent);
54
- applyCompoundStress(ius);
55
- applyNuclearStress(ius);
56
- computePhraseStress(sent.words); // McAleese's Phrase-Stress phase (matches the production pipeline)
57
- assignRelativeStresses(sent.words, ius);
58
- const keys = extractKeyStresses(ius, sent.words);
59
- const result = scoreMeters(keys, sent.words, ius);
60
- return { result, sent, ius, keys };
61
- }
62
-
63
- function hasEmptyFeet(s: string): boolean {
64
- return s.includes('||') || s.startsWith('|') || s.endsWith('|');
65
- }
66
-
67
- describe('parser', () => {
68
- it('parses a simple sentence into ClsDocument', () => {
69
- const doc = parseDocument('I love you');
70
- expect(doc.sentences).toHaveLength(1);
71
- const sent = doc.sentences[0];
72
- expect(sent.words).toHaveLength(3);
73
- expect(sent.words[0].word).toBe('i');
74
- expect(sent.words[0].lexicalClass).toBeTruthy();
75
- expect(sent.dependencies).toHaveLength(3);
76
- const rootDep = sent.dependencies.find(d => d.governorIndex === 0);
77
- expect(rootDep).toBeTruthy();
78
- });
79
- });
80
-
81
- describe('contraction handling', () => {
82
- it("merges we'll into a single word", () => {
83
- const r = scanLine("In Petersburg once more we'll be united");
84
- expect(r).toBeDefined();
85
- expect(r.meterName).toBeDefined();
86
- });
87
- it("merges we've into a single word", () => {
88
- const r = scanLine("Like in a place where we've entombed the sun");
89
- expect(r).toBeDefined();
90
- });
91
- it("merges I've into a single word", () => {
92
- const doc = parseDocument("I've returned to my city, I know it to tears");
93
- const w = doc.sentences[0].words.find(x => x.word === "i've");
94
- expect(w).toBeDefined();
95
- });
96
- it("merges don't into a single word", () => {
97
- const doc = parseDocument("don't you think the sun is brighter now");
98
- const w = doc.sentences[0].words.find(x => x.word === "don't");
99
- expect(w).toBeDefined();
100
- });
101
- it("merges it's into a single word", () => {
102
- const doc = parseDocument("it's a nice day");
103
- const w = doc.sentences[0].words.find(x => x.word === "it's");
104
- expect(w).toBeDefined();
105
- expect(doc.sentences[0].words.length).toBe(4);
106
- });
107
- it("merges can't into a single word", () => {
108
- const doc = parseDocument("I can't do it");
109
- const w = doc.sentences[0].words.find(x => x.word === "can't");
110
- expect(w).toBeDefined();
111
- });
112
- it("merges I'm into a single word", () => {
113
- const doc = parseDocument("I'm here now");
114
- const w = doc.sentences[0].words.find(x => x.word === "i'm");
115
- expect(w).toBeDefined();
116
- expect(doc.sentences[0].words.length).toBe(3);
117
- });
118
- it("merges might've into a single word", () => {
119
- const doc = parseDocument("I might've done that");
120
- const w = doc.sentences[0].words.find(x => x.word === "might've");
121
- expect(w).toBeDefined();
122
- });
123
- it('does not merge non-contraction host+clitic sequences', () => {
124
- const doc = parseDocument('we will go');
125
- const sent = doc.sentences[0];
126
- expect(sent.words[0].word).toBe('we');
127
- expect(sent.words[1].word).toBe('will');
128
- });
129
- it('a contraction line has correct syllable count', () => {
130
- const doc = parseDocument("In Petersburg once more we'll be united");
131
- assignLexicalStress(doc.sentences[0].words);
132
- const sylCount = doc.sentences[0].words.reduce((a, w) => a + w.syllables.length, 0);
133
- expect(sylCount).toBe(11);
134
- });
135
- });
136
-
137
- describe('stress', () => {
138
- function makeWords() {
139
- return [
140
- { text: 'city', pos: 'NN', isContent: true },
141
- { text: 'hall', pos: 'NN', isContent: true },
142
- { text: 'without', pos: 'IN', isContent: false },
143
- { text: 'permission', pos: 'NN', isContent: true },
144
- { text: 'the', pos: 'DT', isContent: false },
145
- ].map((w, i) => ({
146
- index: i + 1,
147
- lexicalClass: w.pos,
148
- lexicalDetails: '',
149
- lexicalPlural: false,
150
- position: '',
151
- word: w.text,
152
- absoluteIndex: i,
153
- isContent: w.isContent,
154
- syllables: [],
155
- phraseStress: 0,
156
- }));
157
- }
158
-
159
- function mockIus(words: any[]): any {
160
- return [{ phonologicalPhrases: [{ cliticGroups: [{ tokens: words }] }] }];
161
- }
162
-
163
- it('assignLexicalStress sets syllables with stress numbers', () => {
164
- const words = makeWords();
165
- assignLexicalStress(words);
166
- for (const w of words) {
167
- expect(w.syllables.length).toBeGreaterThan(0);
168
- }
169
- expect(words[0].syllables.some(s => s.stress === 2)).toBe(true);
170
- expect(words[4].syllables.some(s => s.stress === 2)).toBe(false);
171
- });
172
-
173
- it('applyCompoundStress adjusts adjacent noun-noun compounds', () => {
174
- const words = makeWords();
175
- assignLexicalStress(words);
176
- applyCompoundStress(mockIus(words));
177
- const cityMax = Math.max(...words[0].syllables.map(s => s.stress));
178
- const hallMax = Math.max(...words[1].syllables.map(s => s.stress));
179
- expect(cityMax).toBe(2);
180
- expect(hallMax).toBe(1);
181
- });
182
-
183
- it('applyNuclearStress boosts rightmost content word', () => {
184
- const words = makeWords();
185
- assignLexicalStress(words);
186
- applyNuclearStress(mockIus(words));
187
- const rightStress = words[3].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
188
- const leftStress = words[0].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
189
- expect(rightStress).toBeGreaterThanOrEqual(leftStress);
190
- });
191
-
192
- it('assignRelativeStresses converts numeric to symbolic levels', () => {
193
- const words = makeWords();
194
- assignLexicalStress(words);
195
- if (words[1] && words[4]) {
196
- const dep = {
197
- governor: words[1], dependent: words[4],
198
- governorIndex: 2, dependentIndex: 5, dependentType: 'det',
199
- governorName: 'hall', dependentName: 'the', index: 5,
200
- };
201
- words[1].dependency = dep as any;
202
- words[4].dependency = dep as any;
203
- }
204
- assignRelativeStresses(words, mockIus(words));
205
- for (const w of words) {
206
- for (const s of w.syllables) {
207
- expect(['x', 'w', 'n', 'm', 's']).toContain(s.relativeStress);
208
- }
209
- }
210
- });
211
- });
212
-
213
- describe('phonological', () => {
214
- it('builds IU/PP/CP hierarchy for a simple sentence', () => {
215
- const doc = parseDocument('The planet orbits a star');
216
- const sent = doc.sentences[0];
217
- assignLexicalStress(sent.words);
218
- const ius = buildPhonologicalHierarchy(sent);
219
- expect(ius.length).toBeGreaterThan(0);
220
- expect(ius[0].phonologicalPhrases.length).toBeGreaterThan(0);
221
- expect(ius[0].phonologicalPhrases[0].cliticGroups.length).toBeGreaterThan(0);
222
- });
223
- });
224
-
225
- describe('scansion', () => {
226
- it('Shall I compare thee? — iambic', () => {
227
- const { result } = runPipeline("Shall I compare thee to a summer's day?");
228
- expect(result.meterName).toBe('iambic');
229
- // Honest baseline: "Shall" (MD) and "I" (PRP) floor to overt-weak 'w' (the old
230
- // key's leading 'n·n' was itself a clash); "to" and the article "a" read 'x'
231
- // (zero-provision clitics). Clash-free.
232
- expect(result.scansion).toBe('ww|ws|wx|xm|ws');
233
- expect(result.footCount).toBeGreaterThan(0);
234
- });
235
-
236
- it('The Assyrian came down — anapestic', () => {
237
- const { result } = runPipeline('The Assyrian came down like the wolf on the fold');
238
- expect(result.meterName).toBe('anapestic');
239
- // The three "the" plus the prepositions "like"/"on" (IN) all read 'x'
240
- // (zero-provision clitics), so the anapestic upbeats "like·the·WOLF /
241
- // on·the·FOLD" read x·x·s — textbook anapests.
242
- expect(result.scansion).toBe('xws|wsm|xxs|xxs');
243
- });
244
-
245
- it('I\'ve returned to my city — anapestic', () => {
246
- const { result } = runPipeline("I've returned to my city, I know it to tears");
247
- expect(result.meterName).toBe('anapestic');
248
- // "to"/"it" and the possessive "my" (PRP$) all read 'x' (zero-provision);
249
- // "I've"/"I"/"know" floor as their classes dictate. "to·my·CIT(y)" = x·x·s.
250
- expect(result.scansion).toBe('wwm|xxs|wws|xxs');
251
- });
252
-
253
- it('through Eden took — iambic', () => {
254
- const { result } = runPipeline('Through Eden took their solitary way');
255
- expect(result.meterName).toBe('iambic');
256
- // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics),
257
- // giving clean rising feet "through·E / …their·SOL".
258
- expect(result.scansion).toBe('xm|ws|xm|wn|ws');
259
- });
260
-
261
- it('free verse for nonsensical empty input', () => {
262
- const result = scoreMeters([], []);
263
- expect(result.meterName).toBe('free verse');
264
- expect(result.scansion).toBe('');
265
- });
266
-
267
- it("You've returned here — anapestic (Mandelstam, 'Leningrad')", () => {
268
- // Translation of Mandelstam's anapestic-tetrameter "Ты вернулся сюда…":
269
- // you've·re·TURNED | here·so·SWAL | low·then·FAST | as·you·MIGHT. Resolved by
270
- // (1) treating the line-final modal "might" as the final beat ("endings
271
- // strict"), (2) the trailing-function-run rule that keeps "as you" as upbeat,
272
- // (3) the coarse rising-onset cue, and (4) the project's slight ternary bias.
273
- const { result } = runPipeline("You've returned here, so swallow then, fast as you might");
274
- expect(result.meterName).toBe('anapestic');
275
- });
276
-
277
- it('And onto shores — iambic', () => {
278
- const { result } = runPipeline('And onto shores, with scorching fishes');
279
- expect(result.meterName).toBe('iambic');
280
- });
281
-
282
- it('We all live — anapestic', () => {
283
- const { result } = runPipeline('We all live, underneath us no country we sense');
284
- expect(result.meterName).toBe('anapestic');
285
- });
286
-
287
- it('Woolen gray hat — dactylic', () => {
288
- const { result } = runPipeline('Woolen gray hat and the crimson stars shining');
289
- expect(result.meterName).toBe('dactylic');
290
- });
291
-
292
- it('keeps making blue holes — amphibrachic', () => {
293
- const { result } = runPipeline('keeps making blue holes in the waterproof gloss');
294
- expect(result.meterName).toBe('amphibrachic');
295
- });
296
-
297
- it('This ivy resembles — amphibrachic', () => {
298
- const { result } = runPipeline('This ivy resembles the eyes of the deaf.');
299
- expect(result.meterName).toBe('amphibrachic');
300
- });
301
-
302
- it('We charged at the foe — anapestic', () => {
303
- const { result } = runPipeline('We charged at the foe, and we camped on the heath,');
304
- expect(result.meterName).toBe('anapestic');
305
- });
306
-
307
- it('All this time — trochaic', () => {
308
- const { result } = runPipeline("All this time, you'd softly utter, years on end");
309
- expect(result.meterName).toBe('trochaic');
310
- });
311
-
312
- it('The strong wine flows down fast — bacchic', () => {
313
- const { result } = runPipeline('The strong wine flows down fast');
314
- expect(result.meterName).toBe('bacchic');
315
- });
316
-
317
- it('But he gave no one else — maximally modulated (Tarlinskaja)', () => {
318
- const { result } = runPipeline("But he gave no one else a laugher's license.");
319
- // Tarlinskaja's textbook *maximally-modulated* iambic pentameter — iambic ONLY
320
- // by its poem's metre. With the function words at honest weak prominence
321
- // ("But"→x, "he"→w, "a"→x) the line opens "but·he·GAVE" (a bare anapest) and
322
- // standalone it scans anapestic; unlike "What had I given" (below) it fits
323
- // iambic too poorly for stanza-consensus to recover. We assert the honest
324
- // standalone reading and the clash-free invariant rather than forcing iambic by
325
- // over-stressing the clitics (the gaming the old 'n·n'-laden key relied on).
326
- // FLAGGED in AGENTS.md (2026-06-16) as the one meter-call casualty of honest
327
- // function-word weakening — a candidate for a context/scorer follow-up.
328
- expect(result.meterName).toBe('anapestic');
329
- expect(result.scansion).toBe('xws|ns|mxm|wsw');
330
- expect(hasEmptyFeet(result.scansion)).toBe(false);
331
- });
332
- });
333
-
334
- describe('expert baseline', () => {
335
- it('If hairs be wires — iambic', () => {
336
- const { result } = runPipeline('If hairs be wires, black wires grow on her head');
337
- expect(result.meterName).toBe('iambic');
338
- // "If"/"on" (IN) and the possessive "her" (PRP$) read 'x'; "be" (BE) → 'w'.
339
- // The OLD key carried a *black·wires = m·m clash* (`mm|sw`); the clash filter
340
- // now resolves it to gradient "black(n)·wires(m)" → `nm|sx`. Clash-free.
341
- expect(result.scansion).toBe('xm|ws|nm|sx|xs');
342
- });
343
-
344
- it('Through Eden took — iambic', () => {
345
- const { result } = runPipeline('Through Eden took their solitary way');
346
- expect(result.meterName).toBe('iambic');
347
- // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics).
348
- expect(result.scansion).toBe('xm|ws|xm|wn|ws');
349
- });
350
-
351
- it('There they are my fifty men — trochaic', () => {
352
- const { result } = runPipeline('There they are my fifty men and women');
353
- expect(result.meterName).toBe('trochaic');
354
- });
355
-
356
- it('What had I given — iambic (recovered by stanza context)', () => {
357
- // Standalone, with honest weak function words, this maximally-modulated line
358
- // scans amphibrachic; embedded in its iambic poem the stanza-consensus layer
359
- // correctly recovers iambic (Tarlinskaja: a line's iambicity can be contextual).
360
- // We test that real mechanism — the line among plain iambic neighbours.
361
- const lines = analyzeText(
362
- 'I sat alone and watched the embers leap\n' +
363
- 'What had I given to hear the soft sweep\n' +
364
- 'Of wings that crossed the cold and silent deep', false);
365
- expect(lines[1].phonologicalScansion.meterName).toBe('iambic');
366
- });
367
-
368
- it('Lies the subject of all verse — trochaic', () => {
369
- const { result } = runPipeline('Lies the subject of all verse');
370
- expect(result.meterName).toBe('trochaic');
371
- // 'x' = reduced clitics "the" (DT) and "of" (IN); "all" (PDT) stays content 'n'.
372
- expect(result.scansion).toBe('sx|sw|xn|s');
373
- });
374
-
375
- });
376
-
377
- describe('scansion quality', () => {
378
- const lines = [
379
- 'If hairs be wires, black wires grow on her head',
380
- 'Through Eden took their solitary way',
381
- 'What had I given to hear the soft sweep',
382
- "Shall I compare thee to a summer's day?",
383
- 'Gone were but the Winter, come were but the Spring',
384
- 'The Assyrian came down like the wolf on the fold',
385
- ];
386
-
387
- it('no metered scansion has empty feet', () => {
388
- for (const line of lines) {
389
- const { result } = runPipeline(line);
390
- if (result.meterName !== 'free verse') {
391
- expect(hasEmptyFeet(result.scansion)).toBe(false);
392
- }
393
- }
394
- });
395
-
396
- it('metered scansions contain foot breaks', () => {
397
- for (const line of lines) {
398
- const { result } = runPipeline(line);
399
- expect(result.scansion).toContain('|');
400
- }
401
- });
402
- });
403
-
404
- describe('phonological notation', () => {
405
- it('includes CP, PP, IU boundary brackets', () => {
406
- const r = scanLine('The planet orbits a star');
407
- expect(r.all).toMatch(/\[/);
408
- expect(r.all).toMatch(/\{/);
409
- expect(r.all).toMatch(/</);
410
- expect(r.keyStresses).toMatch(/\[/);
411
- });
412
-
413
- it('includes stress symbols w m s n', () => {
414
- const r = scanLine('The planet orbits a star');
415
- expect(r.all).toMatch(/[wmsn]/);
416
- expect(r.keyStresses).toMatch(/[wmsn]/);
417
- });
418
- });
419
-
420
- describe('multi-line input (paste-friendly)', () => {
421
- it('paste WITH trailing newline + one Enter submits, keeping stanza breaks', () => {
422
- // Two stanzas of two lines, paste ends in a newline, then a deliberate Enter.
423
- const evs: MLEvent[] = [
424
- ...pasteLine('line one', SLOW), // first char gap is large (since reader just opened)
425
- ...pasteLine('line two'),
426
- { kind: 'return', gap: FAST }, // blank line within the burst = stanza break
427
- ...pasteLine('line three'),
428
- ...pasteLine('line four'), // burst ends with a trailing newline
429
- { kind: 'return', gap: SLOW }, // the user's single deliberate Enter → submit
430
- ];
431
- const { result, lines } = runMultiline(evs);
432
- expect(result).toBe('submit');
433
- // The blank stanza break is preserved between the two couplets.
434
- expect(lines.filter(l => l.trim() === '').length).toBe(1);
435
- expect(lines.filter(l => l.trim() !== '')).toEqual(['line one', 'line two', 'line three', 'line four']);
436
- });
437
-
438
- it('paste WITHOUT trailing newline + one Enter submits, flushing the last line', () => {
439
- const evs: MLEvent[] = [
440
- ...pasteLine('first line', SLOW),
441
- // last line has no burst newline; the user just presses Enter once:
442
- ...[...'last line'].map((c, i) => ({ kind: 'char', str: c, gap: i === 0 ? FAST : FAST } as MLEvent)),
443
- { kind: 'return', gap: SLOW }, // deliberate Enter after the paste burst → submit + flush
444
- ];
445
- const { result, lines } = runMultiline(evs);
446
- expect(result).toBe('submit');
447
- expect(lines.filter(l => l.trim() !== '')).toEqual(['first line', 'last line']);
448
- });
449
-
450
- it('Esc cancels (returns to menu)', () => {
451
- const { result } = runMultiline([...pasteLine('something'), { kind: 'escape' }]);
452
- expect(result).toBe('cancel');
453
- });
454
-
455
- it('slow hand-typing: a blank line finishes', () => {
456
- const evs: MLEvent[] = [
457
- ...[...'typed line'].map((c) => ({ kind: 'char', str: c, gap: SLOW } as MLEvent)),
458
- { kind: 'return', gap: SLOW }, // end of the typed line (no burst seen) → line break
459
- { kind: 'return', gap: SLOW }, // blank line → submit
460
- ];
461
- const { result, lines } = runMultiline(evs);
462
- expect(result).toBe('submit');
463
- expect(lines.filter(l => l.trim() !== '')).toEqual(['typed line']);
464
- });
465
- });
466
-
467
- describe('scandroid', () => {
468
- it('Corral the Weird scans regular iambic', () => {
469
- const result = scandroidCorralWeird('x/x/x/x/x/', 5);
470
- expect(result.footlist.length).toBe(5);
471
- });
472
-
473
- it('Maximize Normal works on regular iambic', () => {
474
- const result = scandroidMaximizeNormal('x/x/x/x/x/', 5);
475
- expect(result.footlist.length).toBeGreaterThan(0);
476
- });
477
-
478
- it('stressToMarks converts correctly', () => {
479
- expect(stressToMarks(['w', 's', 'w', 's'])).toBe('x/x/');
480
- });
481
- });
482
-
483
- describe('end‑to‑end', () => {
484
- const poem = `Shall I compare thee to a summer's day?
485
- Thou art more lovely and more temperate.
486
- Rough winds do shake the darling buds of May,
487
- And summer's lease hath all too short a date.`;
488
-
489
- it('analyzeText returns results for each line', () => {
490
- const results = analyzeText(poem, false);
491
- expect(results.length).toBe(4);
492
- expect(results[0].phonologicalScansion.meterName).toBe('iambic');
493
- expect(results[1].phonologicalScansion.meterName).toBe('iambic');
494
- expect(results[2].phonologicalScansion.meterName).toBe('iambic');
495
- expect(results[3].phonologicalScansion.meterName).toBe('iambic');
496
- });
497
-
498
- it('analyzeText with Scandroid includes Scandroid results', () => {
499
- const results = analyzeText(poem, true);
500
- for (const r of results) {
501
- expect(r.phonologicalScansion.meterName).toBeDefined();
502
- }
503
- });
504
-
505
- it('handles free verse line gracefully', () => {
506
- // A famously free-verse line (Williams): there is no canonical scansion, so we
507
- // assert graceful handling — a defined meter and a non-empty, foot-delimited
508
- // scansion covering every syllable — rather than a brittle exact string.
509
- const results = analyzeText('so much depends upon a red wheel barrow', false);
510
- const d = results[0].phonologicalScansion;
511
- expect(d.meter).toBeDefined();
512
- expect(d.scansion.length).toBeGreaterThan(0);
513
- expect(d.scansion).toContain('|');
514
- // syllable letters in the scansion must equal the line's syllable count
515
- const sylLetters = (d.scansion.match(/[xwnms]/g) || []).length;
516
- const totalSyls = results[0].sentence.words.reduce((a, w) => a + w.syllables.length, 0);
517
- expect(sylLetters).toBe(totalSyls);
518
- });
519
- });
520
-
521
- describe('structural fixes (2026-06-10 audit)', () => {
522
- it('quotation marks are not prosodic breaks: quoted word scans like unquoted', () => {
523
- // Quotes fragmented the IU hierarchy and flipped this line to "trochaic
524
- // hexameter"; quoted and unquoted twins must scan identically.
525
- const quoted = analyzeText('I wonder why we call them "wisdom" teeth.', false)[0].phonologicalScansion;
526
- const plain = analyzeText('I wonder why we call them wisdom teeth.', false)[0].phonologicalScansion;
527
- expect(quoted.meterName).toBe('iambic');
528
- expect(quoted.footCount).toBe(5);
529
- expect(quoted.meterName).toBe(plain.meterName);
530
- expect(quoted.footCount).toBe(plain.footCount);
531
- });
532
-
533
- it('the LINE is the scansion domain: internal stops do not fragment the meter', () => {
534
- // Three grammatical sentences, one verse line (Woolley) — one iambic pentameter,
535
- // not trimeter + monometer + monometer fragments.
536
- const results = analyzeText("You'll slurp potato soup. No straws! Suck gauze.", false);
537
- expect(results.length).toBe(1);
538
- expect(results[0].phonologicalScansion.meterName).toBe('iambic');
539
- expect(results[0].phonologicalScansion.footCount).toBe(5);
540
- });
541
-
542
- it('mid-line exclamation keeps the line whole (Stallings-aligned)', () => {
543
- const results = analyzeText('Oh, bloody day! Extractions take their toll;', false);
544
- expect(results.length).toBe(1);
545
- expect(results[0].phonologicalScansion.meterName).toBe('iambic');
546
- expect(results[0].phonologicalScansion.footCount).toBe(5);
547
- });
548
-
549
- it('single anacrusis upbeat does not inflate the foot count', () => {
550
- // 10 syllables with a leading extrametrical upbeat: pentameter, never hexameter.
551
- const d = analyzeText('The woods around it have it--it is theirs.', false)[0].phonologicalScansion;
552
- expect(d.footCount).toBe(5);
553
- expect(d.meter).not.toContain('hexameter');
554
- });
555
-
556
- it('double upbeat fills a foot slot: Hiawatha stays tetrameter', () => {
557
- const d = analyzeText('By the shores of Gitche Gumee,', false)[0].phonologicalScansion;
558
- expect(d.meterName).toBe('trochaic');
559
- expect(d.footCount).toBe(4);
560
- });
561
- });
562
-
563
- describe('syllable-count integrity (2026-06-10 audit)', () => {
564
- const sylCount = (line: string) => {
565
- const r = analyzeText(line, false)[0];
566
- return r.sentence.words.reduce((a, w) => a + w.syllables.length, 0);
567
- };
568
-
569
- it('"am" is one syllable (not the letter-name A.M. anomaly)', () => {
570
- expect(sylCount('I am too absent-spirited to count;')).toBe(10);
571
- });
572
-
573
- it('"us" is one syllable (not the letter-name U.S. anomaly)', () => {
574
- expect(sylCount('Just for a handful of silver he left us,')).toBe(11);
575
- });
576
-
577
- it("who'll / there's / she's re-merge to one syllable each", () => {
578
- expect(sylCount("who'll barely recognize your puffy face.")).toBe(10);
579
- expect(sylCount("and there's a light she's seen")).toBe(6);
580
- });
581
-
582
- it("archaic -'d preterite does not swallow the next word", () => {
583
- const r = analyzeText("And heav'n knows wand'ring ev'ry fix'd star", false)[0];
584
- const words = r.sentence.words.map(w => w.word.toLowerCase());
585
- expect(words).toContain("fix'd");
586
- expect(words).toContain('star');
587
- expect(words).not.toContain('would');
588
- expect(sylCount("And heav'n knows wand'ring ev'ry fix'd star")).toBe(9);
589
- });
590
-
591
- it("hyphenated archaic -'d (hen-peck'd) stays one 2-syllable word, not compound+would", () => {
592
- const r = analyzeText("Inform us truly, have they not hen-peck'd you all?", false)[0];
593
- const words = r.sentence.words.map(w => w.word.toLowerCase());
594
- expect(words).toContain("hen-peck'd");
595
- expect(words).not.toContain('would');
596
- const hp = r.sentence.words.find(w => w.word.toLowerCase() === "hen-peck'd")!;
597
- expect(hp.syllables.length).toBe(2); // hen·peckt (the -'d is silent)
598
- });
599
-
600
- it("th'-elision keeps the host's stress (th'ex-PENSE, not TH'EX-pense)", () => {
601
- const r = analyzeText("Th'expense of spirit in a waste of shame", false)[0];
602
- const w = r.sentence.words.find(x => x.word.toLowerCase().startsWith("th'"))!;
603
- expect(w.syllables.length).toBe(2);
604
- const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
605
- expect(rank[w.syllables[1].relativeStress!]).toBeGreaterThan(rank[w.syllables[0].relativeStress!]);
606
- });
607
-
608
- it("'tis / 'twas are weak aphaeresis clitics, not stressed proper nouns", () => {
609
- const r = analyzeText("'Tis true, 'twas night", false)[0];
610
- const tis = r.sentence.words.find(w => w.word.toLowerCase().includes('tis'))!;
611
- const twas = r.sentence.words.find(w => w.word.toLowerCase().includes('twas'))!;
612
- expect(['x', 'w']).toContain(tis.syllables[0].relativeStress);
613
- expect(['x', 'w']).toContain(twas.syllables[0].relativeStress);
614
- });
615
- });
616
-
617
- describe('caesura foot-alignment (2026-06-14)', () => {
618
- // Foot edges = cumulative syllable counts after each foot of the scansion string.
619
- const footEdges = (scan: string) => {
620
- const set = new Set<number>(); let c = 0;
621
- for (const foot of scan.split('|')) { for (const ch of foot) if ('xwnms'.includes(ch)) c++; set.add(c); }
622
- return set;
623
- };
624
-
625
- it('"But—Oh! ye lords…" breaks after the foot, never fragmenting the monosyllable "But"', () => {
626
- const l = analyzeStanzas('But—Oh! ye lords of ladies intellectual,').flat()[0];
627
- const d = l.phonologicalScansion;
628
- const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion);
629
- const edges = footEdges(d.scansion);
630
- expect(caes.size).toBeGreaterThan(0);
631
- for (const pos of caes.keys()) expect(edges.has(pos)).toBe(true); // every caesura on a foot edge
632
- expect(caes.has(1)).toBe(false); // not after "But" alone
633
- });
634
-
635
- it('Poe gets one medial caesura at the comma (trochaic octameter midpoint)', () => {
636
- const l = analyzeStanzas('Once upon a midnight dreary, while I pondered weak and weary').flat()[0];
637
- const d = l.phonologicalScansion;
638
- const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion);
639
- for (const pos of caes.keys()) expect(footEdges(d.scansion).has(pos)).toBe(true);
640
- expect([...caes.keys()]).toContain(8); // after "dreary," — the line's medial break
641
- });
642
- });
643
-
644
- describe('rising function words & copula reduction (2026-06-14)', () => {
645
- const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
646
- const wordOf = (line: string, w: string) =>
647
- analyzeText(line, false)[0].sentence.words.find(x => x.word.toLowerCase() === w)!;
648
-
649
- // "because" is recorded fully-reduced ("00") in the augmented dictionary; the
650
- // all-zero re-stamp must rise (be·CAUSE), not take the disyllabic forestress
651
- // default (which mis-read it as BE·cause).
652
- it('"because" rises (be·CAUSE): the final syllable outranks the first', () => {
653
- const because = wordOf('Because at least the past were passed away,', 'because');
654
- expect(because.syllables.length).toBe(2);
655
- expect(rank[because.syllables[1].relativeStress!])
656
- .toBeGreaterThan(rank[because.syllables[0].relativeStress!]);
657
- });
658
-
659
- // The 1sg copula "am" reduces — it must not surface as a metrical beat ('m')
660
- // the way the letter-name A.M. data once forced it ("As I am BLOOD…").
661
- it('the copula "am" reduces — no spurious beat in "As I am blood…"', () => {
662
- const am = wordOf('As I am blood, bone, marrow, passion, feeling', 'am');
663
- expect(am.syllables.length).toBe(1);
664
- expect(rank[am.syllables[0].relativeStress!]).toBeLessThanOrEqual(rank['w']);
665
- });
666
-
667
- // An interjection immediately before "!" is an emphatic peak — it must stand out
668
- // from the flat function-word run around it ("But—Oh! ye…" was a monotone n·n·n).
669
- it('an exclaimed interjection (Oh!) outranks the conjunction/pronoun beside it', () => {
670
- const words = analyzeText('But—Oh! ye lords of ladies intellectual,', false)[0].sentence.words;
671
- const peak = (w: string) => {
672
- const word = words.find(x => x.word.toLowerCase() === w)!;
673
- return Math.max(...word.syllables.map(s => rank[s.relativeStress!]));
674
- };
675
- expect(peak('oh')).toBeGreaterThan(peak('but'));
676
- expect(peak('oh')).toBeGreaterThan(peak('ye'));
677
- });
678
- });
679
-
680
- describe('rhyme & form layer (2026-06-12)', () => {
681
- it('classifies canonical rhyme pairs (LYRICAL typology)', () => {
682
- expect(classifyRhymePair('grace', 'face')).toMatchObject({ type: 'perfect', structure: 'masculine' });
683
- expect(classifyRhymePair('picky', 'tricky')).toMatchObject({ type: 'perfect', structure: 'feminine' });
684
- expect(classifyRhymePair('belief', 'leaf')).toMatchObject({ type: 'rich' }); // homophone tails
685
- expect(classifyRhymePair('dame', 'grain')).toMatchObject({ type: 'family' }); // M/N nasals
686
- expect(classifyRhymePair('love', 'move')).toMatchObject({ type: 'consonant' }); // para-rhyme
687
- expect(classifyRhymePair('shaken', 'shaken')).toMatchObject({ type: 'identical' });
688
- });
689
-
690
- it('detects ABCB with unrhymed lines marked', () => {
691
- const rs = detectScheme(['Mariner', 'three', 'eye', 'me']);
692
- expect(rs.map(r => r.letter).join('')).toBe('\u00b7A\u00b7A');
693
- });
694
-
695
- it('Coleridge quatrain \u2192 ballad stanza (rhyme + 4\u00b73 gate)', () => {
696
- 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?');
697
- expect(r[0][0].phonologicalScansion.formNote).toBe('ballad stanza (ABCB, 4\u00b73)');
698
- });
699
-
700
- it('Pope couplet \u2192 couplet', () => {
701
- const r = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne\u2019er so well expressed.');
702
- expect(r[0][0].phonologicalScansion.formNote).toBe('couplet');
703
- });
704
-
705
- it('Frost (Mending Wall opening) \u2192 blank verse', () => {
706
- 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.");
707
- expect(r[0][0].phonologicalScansion.formNote).toBe('blank verse');
708
- });
709
-
710
- it('Sonnet 130 \u2192 Shakespearean Sonnet, full scheme', () => {
711
- 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.";
712
- const r = analyzeStanzas(text);
713
- expect(r[0][0].phonologicalScansion.formNote).toBe('Shakespearean Sonnet');
714
- expect(r[0].map(l => l.phonologicalScansion.rhyme!.letter).join('')).toBe('ABABCDCDEFEFGG');
715
- });
716
-
717
- it('Lear limerick \u2192 limerick (AABBA, ternary)', () => {
718
- 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!');
719
- expect(r[0][0].phonologicalScansion.formNote).toMatch(/^limerick/);
720
- });
721
- });
722
-
723
- describe('continuity rename (2026-06-14)', () => {
724
- it('near-tie line adopts the stanza-dominant meter; standalone kept as note', () => {
725
- // Exile opening: L2 standalone reads dactylic (spondaic "book-" anacrusis
726
- // seizes a beat), but the stanza is amphibrachic — continuity renames it.
727
- const r = analyzeStanzas(
728
- 'He happens to be a French poet, that thin,\n' +
729
- 'book-carrying man with a bristly gray chin;\n' +
730
- 'you meet him whenever you go');
731
- const d = r[0][1].phonologicalScansion;
732
- expect(d.meterName).toBe('amphibrachic');
733
- expect(d.standaloneMeter).toMatch(/dactylic/);
734
- expect(d.consensusMeter).toBeUndefined();
735
- });
736
-
737
- it('accentual stanzas are exempt: Wyatt keeps 4-beat accentual, no renames', () => {
738
- const wyatt =
739
- 'They fle from me that sometyme did me seke\n' +
740
- 'With naked fote stalking in my chambre.\n' +
741
- 'I have sene theim gentill tame and meke\n' +
742
- 'That nowe are wyld and do not remembre\n' +
743
- 'That sometyme they put theimself in daunger\n' +
744
- 'To take bred at my hand; and nowe they raunge\n' +
745
- 'Besely seking with a continuell chaunge.';
746
- const r = analyzeStanzas(wyatt);
747
- for (const l of r[0]) {
748
- expect(l.phonologicalScansion.rhythmNote).toBe('4-beat accentual');
749
- expect(l.phonologicalScansion.standaloneMeter).toBeUndefined();
750
- }
751
- });
752
- });
753
-
754
- describe('parse-correction layer (2026-06-14)', () => {
755
- it('staged pipeline repairs "I had quit the programming paradigm"', () => {
756
- const doc = parseDocument('I had quit the programming paradigm');
757
- const words = doc.sentences[0].words;
758
- expect(words.find(w => w.word === 'i')!.lexicalClass).toBe('PRP');
759
- expect(words.find(w => w.word === 'quit')!.lexicalClass).toBe('VBN');
760
- const deps = doc.sentences[0].dependencies;
761
- const prog = deps.find(d => d.dependentName === 'programming')!;
762
- // Re-baselined 2026-07-02 (UDPIPE_MIGRATION.md Group B): the old golden 'amod'
763
- // encoded the depfix repair of en-parse's broken reading ("programming ←DOBJ←
764
- // quit"). UDPipe parses the gerund-noun pair directly as `compound` — the
765
- // standard UD treatment, and the one that feeds the correct CSR forestress
766
- // ("PROgramming paradigm").
767
- expect(prog.dependentType).toBe('compound');
768
- expect(prog.governorName).toBe('paradigm');
769
- const det = deps.find(d => d.dependentName === 'the')!;
770
- expect(det.dependentType).toBe('det');
771
- expect(det.governorName).toBe('paradigm');
772
- });
773
-
774
- it('archaic forms get verse-correct tags (thy/PRP$ floors at w)', () => {
775
- const doc = parseDocument('By thy long grey beard and glittering eye,');
776
- const thy = doc.sentences[0].words.find(w => w.word === 'thy')!;
777
- expect(thy.lexicalClass).toBe('PRP$');
778
- });
779
-
780
- it('context-guarded gerund fix: "they bicycle through" keeps the verb', () => {
781
- const doc = parseDocument('young beauties, all legs, as they bicycle through');
782
- const bike = doc.sentences[0].words.find(w => w.word === 'bicycle')!;
783
- expect(bike.lexicalClass).toBe('VBP');
784
- });
785
- });
786
-
787
- describe('metricality, foot names, beats & poem-wide rhyme (2026-06-13)', () => {
788
- it('extends foot-count nomenclature through icosameter', () => {
789
- // A long run of clean "the cat" iambs names the line all the way up.
790
- const feet14 = analyzeText(Array.from({ length: 14 }, () => 'the cat').join(' '))[0]
791
- .phonologicalScansion;
792
- expect(feet14.footCount).toBe(14);
793
- expect(feet14.meter).toBe('iambic tetradecameter');
794
- const feet20 = analyzeText(Array.from({ length: 20 }, () => 'the cat').join(' '))[0]
795
- .phonologicalScansion;
796
- expect(feet20.meter).toBe('iambic icosameter');
797
- });
798
-
799
- it('prose-likeness hedge fires on long un-lineated prose', () => {
800
- const d = analyzeText(
801
- "Another thing I've noticed more generally is that the current engine's caesural mechanics may be disruptive",
802
- )[0].phonologicalScansion;
803
- expect(d.metricalityNote).toBeDefined();
804
- expect(d.metricalityNote).toContain('plausible prose');
805
- expect(d.metricalityNote).toContain(d.meter); // closest-fit name echoed
806
- expect(d.meter).toMatch(/decameter$/); // long foot-name in use
807
- });
808
-
809
- it('hedge spares short verse, incl. low-certainty / ambiguous lines', () => {
810
- // Real verse below the 9-foot length gate is never flagged — not even
811
- // V6 ("Half a league", certainty ~40) or L2 (Prufrock, an iamb/troch tie).
812
- for (const v of [
813
- "Shall I compare thee to a summer's day?",
814
- 'Half a league, half a league, half a league onward',
815
- 'Let us go then, you and I',
816
- 'This is the forest primeval, the murmuring pines and the hemlocks',
817
- ]) {
818
- expect(analyzeText(v)[0].phonologicalScansion.metricalityNote).toBeUndefined();
819
- }
820
- });
821
-
822
- it('metricalityVerdict is purely a function of the detail fields', () => {
823
- // No straddle (top-3 all one polarity/size) ⇒ never prose, even if long.
824
- const committed: PhonologicalScansionDetail = {
825
- all: '', keyStresses: '', meter: 'iambic dodecameter', meterName: 'iambic',
826
- footCount: 12, summary: '', scansion: '', certainty: 40,
827
- weightScore: 0, maxPossibleWeight: 0,
828
- ranking: [{ meter: 'iambic', score: 1.0 }, { meter: 'trochaic', score: 0.95 }],
829
- };
830
- expect(metricalityVerdict(committed)).toBeUndefined();
831
- });
832
-
833
- it('end-rhyme types are preserved; lettering stays per-stanza (not poem-wide)', () => {
834
- // The rhyme TYPE rides on the line that completes the rhyme (dressed/expressed → rich).
835
- const c = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne’er so well expressed.');
836
- expect(c.flat()[1].phonologicalScansion.rhyme!.type).toBe('rich');
837
- // Two couplets of different sounds, in separate stanzas, each restart at A —
838
- // the scheme is per-stanza, NOT a poem-wide letterer.
839
- 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.');
840
- expect(two.map(st => st.map(l => l.phonologicalScansion.rhyme!.letter).join(''))).toEqual(['AA', 'AA']);
841
- });
842
-
843
- it('pre-caesural internal rhyme is additive (own letter + type), end rhyme intact', () => {
844
- // end beam/dream → A (perfect); internal night/bright form their own class → B.
845
- const r = analyzeStanzas('The stars at night, a silver beam,\nshine ever bright as in a dream.');
846
- const lines = r.flat();
847
- expect(lines.map(l => l.phonologicalScansion.rhyme!.notation)).toEqual(['(B)A', '(B)A']);
848
- expect(lines[0].phonologicalScansion.rhyme!.letter).toBe('A'); // end letter untouched
849
- expect(lines[0].phonologicalScansion.rhyme!.internal)
850
- .toEqual([{ word: 'night', letter: 'B', type: 'perfect' }]);
851
- });
852
-
853
- it('heterometric is an advisory only — never stamped on per-line rhythmNote', () => {
854
- const song = 'If each wire looks barbed, it actually is\nBlessed are they lounged at cemeteries\n' +
855
- 'Not quite enough for all, but we still don’t care\nNo shit exists so noxious, we don’t binge and share\n' +
856
- 'But it looks like, it’s for the long haul\nLooks like it is habitual\n' +
857
- 'Looks like it’s quite a catch-all\nLooks like it’s just a lack of\nSOMEBODY MORE\nOF SOMEBODY MORE';
858
- const r = analyzeStanzas(song);
859
- for (const l of r.flat()) {
860
- expect(l.phonologicalScansion.rhythmNote ?? '').not.toContain('heterometric');
861
- expect(l.phonologicalScansion.meterName).not.toBe('free verse'); // lines keep a real meter
862
- }
863
- // The line-length-variation observation lives in the synopsis instead
864
- // ("heterometric" wording dropped 2026-06-16 per the maintainer).
865
- const note = summarizePoem(r).find(row => row.label === 'Note')?.value ?? '';
866
- expect(note).toContain('line lengths vary');
867
- });
868
-
869
- it('poem synopsis: accentual reported in beats; sonnet form + meter', () => {
870
- const wyatt = 'They fle from me that sometyme did me seke\n' +
871
- 'With naked fote stalking in my chambre.\nI have sene theim gentill tame and meke\n' +
872
- 'That nowe are wyld and do not remembre\nThat sometyme they put theimself in daunger\n' +
873
- 'To take bred at my hand; and nowe they raunge\nBesely seking with a continuell chaunge.';
874
- // The metre make-up lives in the "Rhythm" row (relabelled 2026-06-16).
875
- const wMeter = summarizePoem(analyzeStanzas(wyatt)).find(r => r.label === 'Rhythm')!.value;
876
- expect(wMeter).toContain('accentual'); // beats, not feet
877
- expect(wMeter).not.toMatch(/meter\b/); // no classical foot-length name
878
-
879
- const sonnet = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" +
880
- "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\n" +
881
- "I have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\n" +
882
- 'And in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\n' +
883
- "I love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\n" +
884
- 'I grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\n' +
885
- 'And yet, by heaven, I think my love as rare\nAs any she belied with false compare.';
886
- const rows = summarizePoem(analyzeStanzas(sonnet));
887
- expect(rows.find(r => r.label === 'Form')!.value).toContain('Shakespearean Sonnet');
888
- // Metre make-up now in "Rhythm", abbreviated ("iamb penta") 2026-06-16.
889
- expect(rows.find(r => r.label === 'Rhythm')!.value).toContain('iamb penta');
890
- });
891
- });
892
-
893
- describe('phonopoetics (2026-06-16)', () => {
894
- it('end-rhyme pair + alliteration + head rhyme (three-phase lettering)', () => {
895
- const p = analyzePhonopoetics(analyzeStanzas(
896
- 'Here were fond climates and sweet singers suddenly\n' +
897
- 'Nap on the hill and gap in the rolling cloud\n' +
898
- 'Come in the morning where I wandered and rhyme\n' +
899
- 'Gap on the crest where weary wanderers time'));
900
- // END: rhyme/time bind to one letter
901
- expect(p.end.some(r => (r.fromWord === 'rhyme' && r.toWord === 'time') || (r.fromWord === 'time' && r.toWord === 'rhyme'))).toBe(true);
902
- // HEAD: Nap/Gap, lettered AFTER the end letters (continue the alphabet), once
903
- expect(p.head.length).toBe(1);
904
- expect(new Set([p.head[0].fromWord, p.head[0].toWord])).toEqual(new Set(['Gap', 'Nap']));
905
- // ALLITERATION carries no letters; "sweet singers suddenly" is one run
906
- expect(p.alliteration.some(a => a.words.join(' ').toLowerCase() === 'sweet singers suddenly')).toBe(true);
907
- });
908
-
909
- it('caesural rhyme reuses the exact end-pair letter it echoes', () => {
910
- const p = analyzePhonopoetics(analyzeStanzas(
911
- 'In the silent night, I saw a gleaming light\n' +
912
- 'With all of my might, I walked the path of sight'));
913
- const c = p.caesural.find(r => r.fromWord === 'night');
914
- expect(c).toBeTruthy();
915
- const e = p.end.find(r => r.fromWord === 'light' || r.toWord === 'light');
916
- expect(e).toBeTruthy();
917
- expect(c!.letter).toBe(e!.letter); // night (pre-caesural) reused light's letter
918
- });
919
-
920
- it('acrostic spells a dictionary word from line initials', () => {
921
- const p = analyzePhonopoetics(analyzeStanzas(
922
- 'Bright was the morning sun\nIn the cold and dawn\nNever a cloud above\nGone is the silent night\nOver the hills it came'));
923
- expect(p.acrostics.some(a => a.word === 'BINGO')).toBe(true);
924
- });
925
- });
926
-
927
- describe('stress-clash invariant (2026-06-16)', () => {
928
- // The maintainer's categorical, input-independent rule (generalising McAleese's
929
- // "stress clashes (ss,ms) > s-s" and Liberman & Prince's grid alternation):
930
- // on the STRESSED tier {n,m,s} NO two adjacent syllables may share a level — that
931
- // is a clash (two equal prominences with no gradation). Gradient pairs
932
- // (sm/ms/sn/ns/mn/nm) are fine; the unstressed tiers {w,x} MAY repeat. This must
933
- // hold for ANY input — it is enforced by the clash filter (resolveLinearClashes).
934
- // "It's kind of a crime it's not a test already." — so here it is.
935
- const STRESSED = new Set(['n', 'm', 's']);
936
-
937
- function clashesIn(text: string): string[] {
938
- const bad: string[] = [];
939
- for (const lr of analyzeText(text, false)) {
940
- // A clash is two equal stressed syllables CONTIGUOUS in pronunciation.
941
- // Punctuation is a prosodic break, so syllables either side of it are not
942
- // contiguous (a '|' sentinel, not in STRESSED, breaks the adjacency scan).
943
- const seq: { lvl: string; word: string }[] = [];
944
- for (const w of lr.sentence.words) {
945
- if (isPunctuation(w.lexicalClass)) { seq.push({ lvl: '|', word: '' }); continue; }
946
- for (const s of w.syllables) seq.push({ lvl: s.relativeStress ?? '?', word: w.word });
947
- }
948
- const line = lr.sentence.words.filter(w => !isPunctuation(w.lexicalClass)).map(w => w.word).join(' ');
949
- for (let i = 0; i < seq.length - 1; i++) {
950
- if (seq[i].lvl === seq[i + 1].lvl && STRESSED.has(seq[i].lvl)) {
951
- bad.push(`${seq[i].lvl}${seq[i + 1].lvl} ("${seq[i].word}"+"${seq[i + 1].word}") in "${line}"`);
952
- }
953
- }
954
- }
955
- return bad;
956
- }
957
-
958
- // The maintainer's own example battery (the regression set that exposed the
959
- // clashes), plus canonical verse of every metre.
960
- const battery: Record<string, string> = {
961
- 'Thomas — There could I marvel': 'There could I marvel my birthday',
962
- 'Thomas — Poem in October (st.1)':
963
- 'My birthday began with the water-\nBirds and the birds of the winged trees flying my name\n' +
964
- 'Above the farms and the white horses\nAnd I rose\nIn rainy autumn\n' +
965
- 'And walked abroad in a shower of all my days.',
966
- 'Gypsies — bachelor / gypsies':
967
- "O, I am a bachelor, I live by myself\n" +
968
- "And the only, only thing that I ever did was wrong\n" +
969
- "So all night long I held her in my arms\n" +
970
- "One night she came to my bedside\n" +
971
- "I fear you have had some ill sickness\n" +
972
- "One sang high and the other sang low\n" +
973
- "The ragged, ragged rags about our door\n" +
974
- "It was late last night when my lord came home",
975
- 'Byron — intellectual / hen-peck\'d':
976
- "But—Oh! ye lords of ladies intellectual,\nInform us truly, have they not hen-peck'd you all?",
977
- 'Byron — vaunt / Don Juan':
978
- 'Of such as these I should not care to vaunt,\nI\'ll therefore take our ancient friend Don Juan',
979
- 'Shakespeare — Sonnet 130 (q.1)':
980
- "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" +
981
- "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.",
982
- 'Poe — The Raven (opening)':
983
- 'Once upon a midnight dreary, while I pondered weak and weary,\n' +
984
- 'Over many a quaint and curious volume of forgotten lore',
985
- 'Milton — Paradise Lost (opening)':
986
- 'Of Mans First Disobedience, and the Fruit\nOf that Forbidden Tree, whose mortal tast',
987
- };
988
-
989
- for (const [name, text] of Object.entries(battery)) {
990
- it(`no adjacent equal stress on {n,m,s} — ${name}`, () => {
991
- const bad = clashesIn(text);
992
- expect(bad, bad.join(' ; ')).toEqual([]);
993
- });
994
- }
995
- });
996
-
997
- // Stress-level tuning from the Sosnora-translation review (2026-06-21).
998
- describe('stress-level tuning (Sosnora review, 2026-06-21)', () => {
999
- it('curly-apostrophe contractions tokenise like straight ones (won’t = 1 modal)', () => {
1000
- // A curly ’ bypassed the contraction path and en-norm dehiscised "won’t"
1001
- // into "will not" (2 syllables); the apostrophe is now normalised first.
1002
- const curly = parseDocument('I won’t tell anyone').sentences[0].words.map(w => w.word);
1003
- const straight = parseDocument("I won't tell anyone").sentences[0].words.map(w => w.word);
1004
- expect(curly).toEqual(straight);
1005
- expect(curly).toContain("won't");
1006
- expect(curly).not.toContain('will'); // not expanded to "will not"
1007
- });
1008
-
1009
- it('aren’t / Nature’s with curly apostrophe tokenise correctly', () => {
1010
- const w = parseDocument('boats aren’t tears').sentences[0].words.map(x => x.word);
1011
- expect(w).toContain("aren't");
1012
- const poss = parseDocument('Nature’s first green').sentences[0].words.map(x => x.word);
1013
- expect(poss).toContain("'s"); // possessive clitic split off
1014
- });
1015
-
1016
- it('N+N compound fore-stresses even when en-parse inverts the dependency arrow', () => {
1017
- // "slate roof"/"clay jar" parse as roof→slate / jar→clay (head on the LEFT),
1018
- // the inverse of "ice cream"; the left noun must still fore-stress (CSR).
1019
- for (const np of ['a slate roof', 'a clay jar']) {
1020
- const d = analyzeText(np, false)[0].phonologicalScansion;
1021
- expect(d.scansion, np).toBe('xsn'); // a·SLATE·roof (beat on the modifier)
1022
- }
1023
- // the canonical NOMD case is unchanged
1024
- expect(analyzeText('ice cream', false)[0].phonologicalScansion.scansion).toBe('sn');
1025
- });
1026
-
1027
- it('a reduced copula after a function word floors below it (it·is gradient)', () => {
1028
- // "it is" was a flat w·w; the copula now reduces to x, restoring the gradient.
1029
- // Re-derived 2026-06-29 (Wagner/Krifka rebuild): the assertion verifies the
1030
- // copula reduction it(w)·is(x) at the head; the foot boundary after it is no
1031
- // longer pinned, because deictic "here" now carries a light content beat
1032
- // (it·is·GOLD·here → wxsn), a defensible differentiation that re-segments the
1033
- // feet without touching the is→x reduction the test exists to check.
1034
- expect(analyzeText('it is gold here', false)[0].phonologicalScansion.scansion).toMatch(/^wx/);
1035
- });
1036
- });
1037
-
1038
-
1039
- // ─── UDPipe feature leverage & challenging constructions (2026-06-23) ─────────
1040
- // These exercise the PRODUCTION (Calliope) engine — `analyzeText` — not the
1041
- // legacy runPipeline path, and lean on UDPipe features en-parse never produced:
1042
- // the `compound` relation (genuine N+N fore-stress) and the model-agnostic
1043
- // UPOS+FEATS POS derivation. See AGENTS.md log 2026-06-23.
1044
- describe('UDPipe feature leverage & challenging constructions (2026-06-23)', () => {
1045
- const RANK: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
1046
- const lineResult = (line: string) => analyzeText(line, false)[0];
1047
- const peakRel = (w: any) =>
1048
- Math.max(...w.syllables.map((s: any) => RANK[s.relativeStress ?? 'w']));
1049
- const wordByText = (lr: any, t: string) =>
1050
- lr.sentence.words.find((w: any) => w.word === t);
1051
- const phiCount = (lr: any) =>
1052
- lr.phonologicalHierarchy.reduce((n: number, iu: any) => n + iu.phonologicalPhrases.length, 0);
1053
-
1054
- it('N+N compounds fore-stress via the UD `compound` relation (ICE cream, DOOR man, STONE wall)', () => {
1055
- for (const [phrase, a, b] of [
1056
- ['ice cream', 'ice', 'cream'],
1057
- ['the door man', 'door', 'man'],
1058
- ['a stone wall', 'stone', 'wall'],
1059
- ]) {
1060
- const lr = lineResult(phrase);
1061
- expect(peakRel(wordByText(lr, a)), `${phrase}: ${a} should fore-stress over ${b}`)
1062
- .toBeGreaterThan(peakRel(wordByText(lr, b)));
1063
- }
1064
- });
1065
-
1066
- it('canonical iambic lines read iambic on the production engine', () => {
1067
- // Milton, Paradise Lost 1.1 (iambic pentameter); Kilmer, Trees (iambic tetrameter).
1068
- expect(scanLine("Of Man's first disobedience, and the fruit").meterName).toBe('iambic');
1069
- expect(scanLine('I think that I shall never see').meterName).toBe('iambic');
1070
- });
1071
-
1072
- it('ϕ-grouping is not over-segmented: a verb + light oblique PP stay one phrase', () => {
1073
- // Selkirk: "{[More than fifteen][carpenters]}{[are working][in the house]}" = 2 ϕ.
1074
- // Before the OBL fix this shattered into 5 ϕ, washing out the key stresses.
1075
- const lr = lineResult('More than fifteen carpenters are working in the house');
1076
- expect(phiCount(lr)).toBeLessThanOrEqual(3);
1077
- });
1078
-
1079
- it('carpenters line: subject NP is ONE ϕ, "in the house" its own ϕ (3 ϕ total)', () => {
1080
- // Expert/Gee&Grosjean gold (McAleese p.213):
1081
- // <{[More than fifteen carpenters]}{[are working]}{[in the house]}> — 3 ϕ.
1082
- // The subject NP must NOT shatter ("more" is not its own ϕ) and the verbal
1083
- // oblique "in the house" IS its own ϕ. (A prior build gave 5 ϕ — every
1084
- // argument a phrase — which made every spurious ϕ-end a key stress.)
1085
- const lr = lineResult('More than fifteen carpenters are working in the house');
1086
- expect(phiCount(lr)).toBe(3);
1087
- // "more" sits in the subject ϕ with "carpenters" (not its own singleton ϕ), so
1088
- // it is a SECONDARY beat under the ϕ nuclear "carpenters", never an 's'.
1089
- expect(peakRel(wordByText(lr, 'more'))).toBeLessThan(peakRel(wordByText(lr, 'carpenters')));
1090
- expect(peakRel(wordByText(lr, 'more'))).toBeGreaterThan(RANK.w); // a real beat (m), not flattened
1091
- // The ϕ-initial preposition "in" carries the subtle left-edge beat the maintainer
1092
- // hears — above the reduced article "the".
1093
- expect(peakRel(wordByText(lr, 'in'))).toBeGreaterThan(peakRel(wordByText(lr, 'the')));
1094
- });
1095
-
1096
- it('phrase-initial preposition outranks an interior determiner (Blake: through > each)', () => {
1097
- // "I wander through each chartered street": the ϕ-initial "through" takes a beat,
1098
- // the interior determiner-slot "each" reduces — so through > each, not "through EACH".
1099
- const lr = lineResult('I wander through each chartered street');
1100
- expect(peakRel(wordByText(lr, 'through'))).toBeGreaterThan(peakRel(wordByText(lr, 'each')));
1101
- });
1102
- });