File size: 57,559 Bytes
dd311c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
import { describe, it, expect } from 'vitest';
import { parseDocument, isPunctuation } from '../src/parser.js';
import {
  assignLexicalStress,
  applyCompoundStress,
  applyNuclearStress,
  assignRelativeStresses,
} from '../src/stress.js';
import { computePhraseStress } from '../src/phrasestress.js';
import { buildPhonologicalHierarchy } from '../src/phonological.js';
import { extractKeyStresses, scoreMeters } from '../src/scansion.js';
import { ScansionMachine } from '../src/scandroidNative/machine.js';
import { scanLineNatively } from '../src/scandroidNative/engine.js';
import { analyzeText, feedMultilineEvent, newMLState, ML_IDLE_MS, type MLEvent } from '../src/index.js';
import type { PhonologicalScansionDetail } from '../src/types.js';
import { classifyRhymePair, detectScheme, summarizePoem, analyzePhonopoetics, rhymeKey } from '../src/rhyme.js';
import { metricalityVerdict } from '../src/scansion.js';
import { analyzeStanzas } from '../src/index.js';
import { computeCaesurae } from '../src/caesura.js';

/** Drive the multi-line input reducer with a synthetic event sequence. */
function runMultiline(events: MLEvent[]): { result: string; lines: string[] } {
  const st = newMLState();
  let result = 'continue';
  for (const ev of events) {
    result = feedMultilineEvent(st, ev);
    if (result !== 'continue') break;
  }
  return { result, lines: st.lines };
}
const FAST = ML_IDLE_MS - 50;   // burst-speed (paste)
const SLOW = ML_IDLE_MS + 400;  // deliberate keystroke
/** A whole line of pasted text: its chars (burst), then a burst-speed newline. */
function pasteLine(text: string, firstGap = FAST): MLEvent[] {
  const evs: MLEvent[] = [];
  [...text].forEach((c, i) => evs.push({ kind: 'char', str: c, gap: i === 0 ? firstGap : FAST }));
  evs.push({ kind: 'return', gap: FAST });
  return evs;
}

function scanLine(line: string): PhonologicalScansionDetail {
  const results = analyzeText(line, false);
  return results[0].phonologicalScansion;
}

function runPipeline(line: string) {
  const doc = parseDocument(line);
  const sent = doc.sentences[0];
  assignLexicalStress(sent.words);
  const ius = buildPhonologicalHierarchy(sent);
  applyCompoundStress(ius);
  applyNuclearStress(ius);
  computePhraseStress(sent.words);   // McAleese's Phrase-Stress phase (matches the production pipeline)
  assignRelativeStresses(sent.words, ius);
  const keys = extractKeyStresses(ius, sent.words);
  const result = scoreMeters(keys, sent.words, ius);
  return { result, sent, ius, keys };
}

function hasEmptyFeet(s: string): boolean {
  return s.includes('||') || s.startsWith('|') || s.endsWith('|');
}

describe('parser', () => {
  it('parses a simple sentence into ClsDocument', () => {
    const doc = parseDocument('I love you');
    expect(doc.sentences).toHaveLength(1);
    const sent = doc.sentences[0];
    expect(sent.words).toHaveLength(3);
    expect(sent.words[0].word).toBe('i');
    expect(sent.words[0].lexicalClass).toBeTruthy();
    expect(sent.dependencies).toHaveLength(3);
    const rootDep = sent.dependencies.find(d => d.governorIndex === 0);
    expect(rootDep).toBeTruthy();
  });
});

describe('contraction handling', () => {
  it("merges we'll into a single word", () => {
    const r = scanLine("In Petersburg once more we'll be united");
    expect(r).toBeDefined();
    expect(r.meterName).toBeDefined();
  });
  it("merges we've into a single word", () => {
    const r = scanLine("Like in a place where we've entombed the sun");
    expect(r).toBeDefined();
  });
  it("merges I've into a single word", () => {
    const doc = parseDocument("I've returned to my city, I know it to tears");
    const w = doc.sentences[0].words.find(x => x.word === "i've");
    expect(w).toBeDefined();
  });
  it("merges don't into a single word", () => {
    const doc = parseDocument("don't you think the sun is brighter now");
    const w = doc.sentences[0].words.find(x => x.word === "don't");
    expect(w).toBeDefined();
  });
  it("merges it's into a single word", () => {
    const doc = parseDocument("it's a nice day");
    const w = doc.sentences[0].words.find(x => x.word === "it's");
    expect(w).toBeDefined();
    expect(doc.sentences[0].words.length).toBe(4);
  });
  it("merges can't into a single word", () => {
    const doc = parseDocument("I can't do it");
    const w = doc.sentences[0].words.find(x => x.word === "can't");
    expect(w).toBeDefined();
  });
  it("merges I'm into a single word", () => {
    const doc = parseDocument("I'm here now");
    const w = doc.sentences[0].words.find(x => x.word === "i'm");
    expect(w).toBeDefined();
    expect(doc.sentences[0].words.length).toBe(3);
  });
  it("merges might've into a single word", () => {
    const doc = parseDocument("I might've done that");
    const w = doc.sentences[0].words.find(x => x.word === "might've");
    expect(w).toBeDefined();
  });
  it('does not merge non-contraction host+clitic sequences', () => {
    const doc = parseDocument('we will go');
    const sent = doc.sentences[0];
    expect(sent.words[0].word).toBe('we');
    expect(sent.words[1].word).toBe('will');
  });
  it('a contraction line has correct syllable count', () => {
    const doc = parseDocument("In Petersburg once more we'll be united");
    assignLexicalStress(doc.sentences[0].words);
    const sylCount = doc.sentences[0].words.reduce((a, w) => a + w.syllables.length, 0);
    expect(sylCount).toBe(11);
  });
});

describe('stress', () => {
  function makeWords() {
    return [
      { text: 'city', pos: 'NN', isContent: true },
      { text: 'hall', pos: 'NN', isContent: true },
      { text: 'without', pos: 'IN', isContent: false },
      { text: 'permission', pos: 'NN', isContent: true },
      { text: 'the', pos: 'DT', isContent: false },
    ].map((w, i) => ({
      index: i + 1,
      lexicalClass: w.pos,
      lexicalDetails: '',
      lexicalPlural: false,
      position: '',
      word: w.text,
      absoluteIndex: i,
      isContent: w.isContent,
      syllables: [],
      phraseStress: 0,
    }));
  }

  function mockIus(words: any[]): any {
    return [{ phonologicalPhrases: [{ cliticGroups: [{ tokens: words }] }] }];
  }

  it('assignLexicalStress sets syllables with stress numbers', () => {
    const words = makeWords();
    assignLexicalStress(words);
    for (const w of words) {
      expect(w.syllables.length).toBeGreaterThan(0);
    }
    expect(words[0].syllables.some(s => s.stress === 2)).toBe(true);
    expect(words[4].syllables.some(s => s.stress === 2)).toBe(false);
  });

  it('applyCompoundStress adjusts adjacent noun-noun compounds', () => {
    const words = makeWords();
    assignLexicalStress(words);
    applyCompoundStress(mockIus(words));
    const cityMax = Math.max(...words[0].syllables.map(s => s.stress));
    const hallMax = Math.max(...words[1].syllables.map(s => s.stress));
    expect(cityMax).toBe(2);
    expect(hallMax).toBe(1);
  });

  it('applyNuclearStress boosts rightmost content word', () => {
    const words = makeWords();
    assignLexicalStress(words);
    applyNuclearStress(mockIus(words));
    const rightStress = words[3].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
    const leftStress = words[0].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
    expect(rightStress).toBeGreaterThanOrEqual(leftStress);
  });

  it('assignRelativeStresses converts numeric to symbolic levels', () => {
    const words = makeWords();
    assignLexicalStress(words);
    if (words[1] && words[4]) {
      const dep = {
        governor: words[1], dependent: words[4],
        governorIndex: 2, dependentIndex: 5, dependentType: 'det',
        governorName: 'hall', dependentName: 'the', index: 5,
      };
      words[1].dependency = dep as any;
      words[4].dependency = dep as any;
    }
    assignRelativeStresses(words, mockIus(words));
    for (const w of words) {
      for (const s of w.syllables) {
        expect(['x', 'w', 'n', 'm', 's']).toContain(s.relativeStress);
      }
    }
  });
});

describe('phonological', () => {
  it('builds IU/PP/CP hierarchy for a simple sentence', () => {
    const doc = parseDocument('The planet orbits a star');
    const sent = doc.sentences[0];
    assignLexicalStress(sent.words);
    const ius = buildPhonologicalHierarchy(sent);
    expect(ius.length).toBeGreaterThan(0);
    expect(ius[0].phonologicalPhrases.length).toBeGreaterThan(0);
    expect(ius[0].phonologicalPhrases[0].cliticGroups.length).toBeGreaterThan(0);
  });
});

describe('scansion', () => {
  it('Shall I compare thee? — iambic', () => {
    const { result } = runPipeline("Shall I compare thee to a summer's day?");
    expect(result.meterName).toBe('iambic');
    // Honest baseline: "Shall" (MD) and "I" (PRP) floor to overt-weak 'w' (the old
    // key's leading 'n·n' was itself a clash); "to" and the article "a" read 'x'
    // (zero-provision clitics).  Clash-free.
    expect(result.scansion).toBe('ww|ws|wx|xm|ws');
    expect(result.footCount).toBeGreaterThan(0);
  });

  it('The Assyrian came down — anapestic', () => {
    const { result } = runPipeline('The Assyrian came down like the wolf on the fold');
    expect(result.meterName).toBe('anapestic');
    // The three "the" plus the prepositions "like"/"on" (IN) all read 'x'
    // (zero-provision clitics), so the anapestic upbeats "like·the·WOLF /
    // on·the·FOLD" read x·x·s — textbook anapests.
    expect(result.scansion).toBe('xws|wsm|xxs|xxs');
  });

  it('I\'ve returned to my city — anapestic', () => {
    const { result } = runPipeline("I've returned to my city, I know it to tears");
    expect(result.meterName).toBe('anapestic');
    // "to"/"it" and the possessive "my" (PRP$) all read 'x' (zero-provision);
    // "I've"/"I"/"know" floor as their classes dictate.  "to·my·CIT(y)" = x·x·s.
    expect(result.scansion).toBe('wwm|xxs|wws|xxs');
  });

  it('through Eden took — iambic', () => {
    const { result } = runPipeline('Through Eden took their solitary way');
    expect(result.meterName).toBe('iambic');
    // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics),
    // giving clean rising feet "through·E / …their·SOL".
    expect(result.scansion).toBe('xm|ws|xm|wn|ws');
  });

  it('free verse for nonsensical empty input', () => {
    const result = scoreMeters([], []);
    expect(result.meterName).toBe('free verse');
    expect(result.scansion).toBe('');
  });

  it("You've returned here — anapestic (Mandelstam, 'Leningrad')", () => {
    // Translation of Mandelstam's anapestic-tetrameter "Ты вернулся сюда…":
    // you've·re·TURNED | here·so·SWAL | low·then·FAST | as·you·MIGHT.  Resolved by
    // (1) treating the line-final modal "might" as the final beat ("endings
    // strict"), (2) the trailing-function-run rule that keeps "as you" as upbeat,
    // (3) the coarse rising-onset cue, and (4) the project's slight ternary bias.
    const { result } = runPipeline("You've returned here, so swallow then, fast as you might");
    expect(result.meterName).toBe('anapestic');
  });

  it('And onto shores — iambic', () => {
    const { result } = runPipeline('And onto shores, with scorching fishes');
    expect(result.meterName).toBe('iambic');
  });

  it('We all live — anapestic', () => {
    const { result } = runPipeline('We all live, underneath us no country we sense');
    expect(result.meterName).toBe('anapestic');
  });

  it('Woolen gray hat — dactylic', () => {
    const { result } = runPipeline('Woolen gray hat and the crimson stars shining');
    expect(result.meterName).toBe('dactylic');
  });

  it('keeps making blue holes — amphibrachic', () => {
    const { result } = runPipeline('keeps making blue holes in the waterproof gloss');
    expect(result.meterName).toBe('amphibrachic');
  });

  it('This ivy resembles — amphibrachic', () => {
    const { result } = runPipeline('This ivy resembles the eyes of the deaf.');
    expect(result.meterName).toBe('amphibrachic');
  });

  it('We charged at the foe — anapestic', () => {
    const { result } = runPipeline('We charged at the foe, and we camped on the heath,');
    expect(result.meterName).toBe('anapestic');
  });

  it('All this time — trochaic', () => {
    const { result } = runPipeline("All this time, you'd softly utter, years on end");
    expect(result.meterName).toBe('trochaic');
  });

  it('The strong wine flows down fast — bacchic', () => {
    const { result } = runPipeline('The strong wine flows down fast');
    expect(result.meterName).toBe('bacchic');
  });

  it('But he gave no one else — maximally modulated (Tarlinskaja)', () => {
    const { result } = runPipeline("But he gave no one else a laugher's license.");
    // Tarlinskaja's textbook *maximally-modulated* iambic pentameter — iambic ONLY
    // by its poem's metre.  With the function words at honest weak prominence
    // ("But"→x, "he"→w, "a"→x) the line opens "but·he·GAVE" (a bare anapest) and
    // standalone it scans anapestic; unlike "What had I given" (below) it fits
    // iambic too poorly for stanza-consensus to recover.  We assert the honest
    // standalone reading and the clash-free invariant rather than forcing iambic by
    // over-stressing the clitics (the gaming the old 'n·n'-laden key relied on).
    // FLAGGED in AGENTS.md (2026-06-16) as the one meter-call casualty of honest
    // function-word weakening — a candidate for a context/scorer follow-up.
    expect(result.meterName).toBe('anapestic');
    expect(result.scansion).toBe('xws|ns|mxm|wsw');
    expect(hasEmptyFeet(result.scansion)).toBe(false);
  });
});

describe('expert baseline', () => {
  it('If hairs be wires — iambic', () => {
    const { result } = runPipeline('If hairs be wires, black wires grow on her head');
    expect(result.meterName).toBe('iambic');
    // "If"/"on" (IN) and the possessive "her" (PRP$) read 'x'; "be" (BE) → 'w'.
    // The OLD key carried a *black·wires = m·m clash* (`mm|sw`); the clash filter
    // now resolves it to gradient "black(n)·wires(m)" → `nm|sx`.  Clash-free.
    expect(result.scansion).toBe('xm|ws|nm|sx|xs');
  });

  it('Through Eden took — iambic', () => {
    const { result } = runPipeline('Through Eden took their solitary way');
    expect(result.meterName).toBe('iambic');
    // "Through" (IN) and "their" (PRP$) read 'x' (zero-provision clitics).
    expect(result.scansion).toBe('xm|ws|xm|wn|ws');
  });

  it('There they are my fifty men — trochaic', () => {
    const { result } = runPipeline('There they are my fifty men and women');
    expect(result.meterName).toBe('trochaic');
  });

  it('What had I given — iambic (recovered by stanza context)', () => {
    // Standalone, with honest weak function words, this maximally-modulated line
    // scans amphibrachic; embedded in its iambic poem the stanza-consensus layer
    // correctly recovers iambic (Tarlinskaja: a line's iambicity can be contextual).
    // We test that real mechanism — the line among plain iambic neighbours.
    const lines = analyzeText(
      'I sat alone and watched the embers leap\n' +
      'What had I given to hear the soft sweep\n' +
      'Of wings that crossed the cold and silent deep', false);
    expect(lines[1].phonologicalScansion.meterName).toBe('iambic');
  });

  it('Lies the subject of all verse — trochaic', () => {
    const { result } = runPipeline('Lies the subject of all verse');
    expect(result.meterName).toBe('trochaic');
    // 'x' = reduced clitics "the" (DT) and "of" (IN); "all" (PDT) stays content 'n'.
    expect(result.scansion).toBe('sx|sw|xn|s');
  });

});

describe('scansion quality', () => {
  const lines = [
    'If hairs be wires, black wires grow on her head',
    'Through Eden took their solitary way',
    'What had I given to hear the soft sweep',
    "Shall I compare thee to a summer's day?",
    'Gone were but the Winter, come were but the Spring',
    'The Assyrian came down like the wolf on the fold',
  ];

  it('no metered scansion has empty feet', () => {
    for (const line of lines) {
      const { result } = runPipeline(line);
      if (result.meterName !== 'free verse') {
        expect(hasEmptyFeet(result.scansion)).toBe(false);
      }
    }
  });

  it('metered scansions contain foot breaks', () => {
    for (const line of lines) {
      const { result } = runPipeline(line);
      expect(result.scansion).toContain('|');
    }
  });
});

describe('phonological notation', () => {
  it('includes CP, PP, IU boundary brackets', () => {
    const r = scanLine('The planet orbits a star');
    expect(r.all).toMatch(/\[/);
    expect(r.all).toMatch(/\{/);
    expect(r.all).toMatch(/</);
    expect(r.keyStresses).toMatch(/\[/);
  });

  it('includes stress symbols w m s n', () => {
    const r = scanLine('The planet orbits a star');
    expect(r.all).toMatch(/[wmsn]/);
    expect(r.keyStresses).toMatch(/[wmsn]/);
  });
});

describe('multi-line input (paste-friendly)', () => {
  it('paste WITH trailing newline + one Enter submits, keeping stanza breaks', () => {
    // Two stanzas of two lines, paste ends in a newline, then a deliberate Enter.
    const evs: MLEvent[] = [
      ...pasteLine('line one', SLOW),   // first char gap is large (since reader just opened)
      ...pasteLine('line two'),
      { kind: 'return', gap: FAST },    // blank line within the burst = stanza break
      ...pasteLine('line three'),
      ...pasteLine('line four'),        // burst ends with a trailing newline
      { kind: 'return', gap: SLOW },    // the user's single deliberate Enter → submit
    ];
    const { result, lines } = runMultiline(evs);
    expect(result).toBe('submit');
    // The blank stanza break is preserved between the two couplets.
    expect(lines.filter(l => l.trim() === '').length).toBe(1);
    expect(lines.filter(l => l.trim() !== '')).toEqual(['line one', 'line two', 'line three', 'line four']);
  });

  it('paste WITHOUT trailing newline + one Enter submits, flushing the last line', () => {
    const evs: MLEvent[] = [
      ...pasteLine('first line', SLOW),
      // last line has no burst newline; the user just presses Enter once:
      ...[...'last line'].map((c, i) => ({ kind: 'char', str: c, gap: i === 0 ? FAST : FAST } as MLEvent)),
      { kind: 'return', gap: SLOW },    // deliberate Enter after the paste burst → submit + flush
    ];
    const { result, lines } = runMultiline(evs);
    expect(result).toBe('submit');
    expect(lines.filter(l => l.trim() !== '')).toEqual(['first line', 'last line']);
  });

  it('Esc cancels (returns to menu)', () => {
    const { result } = runMultiline([...pasteLine('something'), { kind: 'escape' }]);
    expect(result).toBe('cancel');
  });

  it('slow hand-typing: a blank line finishes', () => {
    const evs: MLEvent[] = [
      ...[...'typed line'].map((c) => ({ kind: 'char', str: c, gap: SLOW } as MLEvent)),
      { kind: 'return', gap: SLOW },    // end of the typed line (no burst seen) → line break
      { kind: 'return', gap: SLOW },    // blank line → submit
    ];
    const { result, lines } = runMultiline(evs);
    expect(result).toBe('submit');
    expect(lines.filter(l => l.trim() !== '')).toEqual(['typed line']);
  });
});

describe('scandroid (independent second opinion)', () => {
  it('Corral the Weird (algorithm 1) scans regular iambic', () => {
    const m = new ScansionMachine();
    m.SetLineFeet(5, true);
    const { footlist, ok } = m.DoAlgorithm(1, 'x/x/x/x/x/');
    expect(ok).toBe(true);
    expect(footlist.length).toBe(5);
    expect(footlist.every(f => f === 'iamb')).toBe(true);
  });

  it('Maximize the Normal (algorithm 2) scans regular iambic', () => {
    const m = new ScansionMachine();
    m.SetLineFeet(5, true);
    const { footlist, ok } = m.DoAlgorithm(2, 'x/x/x/x/x/');
    expect(ok).toBe(true);
    expect(footlist.length).toBe(5);
  });

  it('scanLineNatively reads a canonical iambic pentameter line as its own verdict', () => {
    const result = scanLineNatively("Shall I compare thee to a summer's day?", { metron: 'iambic', lineFeet: 5, lineFeetSet: true });
    expect(result.metron).toBe('iambic');
    expect(result.verdict?.ok).toBe(true);
    expect(result.verdict?.footlist.length).toBe(5);
  });

  it('scanLineNatively reads a canonical anapestic line as its own verdict', () => {
    // Hartman's own syllabizer (unrelated to Calliope's) scans "Assyrian" as
    // 4 syllables, so this line is 13 syllables under Scandroid's own count --
    // a legitimate divergence from Calliope's own syllabification, not a bug.
    const result = scanLineNatively('And the sheen of their spears was like stars on the sea', { metron: 'anapestic', lineFeet: 4, lineFeetSet: true });
    expect(result.metron).toBe('anapestic');
    expect(result.verdict?.ok).toBe(true);
    expect(result.verdict?.footlist).toEqual(['anapest', 'anapest', 'anapest', 'anapest']);
  });
});

describe('end‑to‑end', () => {
  const poem = `Shall I compare thee to a summer's day?
Thou art more lovely and more temperate.
Rough winds do shake the darling buds of May,
And summer's lease hath all too short a date.`;

  it('analyzeText returns results for each line', () => {
    const results = analyzeText(poem, false);
    expect(results.length).toBe(4);
    expect(results[0].phonologicalScansion.meterName).toBe('iambic');
    expect(results[1].phonologicalScansion.meterName).toBe('iambic');
    expect(results[2].phonologicalScansion.meterName).toBe('iambic');
    expect(results[3].phonologicalScansion.meterName).toBe('iambic');
  });

  it('analyzeText with Scandroid includes Scandroid results', () => {
    const results = analyzeText(poem, true);
    for (const r of results) {
      expect(r.phonologicalScansion.meterName).toBeDefined();
      expect(r.scandroidNative).toBeDefined();
      expect(r.scandroidNative?.metron).toBe('iambic');
    }
  });

  it('useScandroid=false skips the independent Scandroid pass entirely', () => {
    const results = analyzeText(poem, false);
    for (const r of results) expect(r.scandroidNative).toBeUndefined();
  });

  it('handles free verse line gracefully', () => {
    // A famously free-verse line (Williams): there is no canonical scansion, so we
    // assert graceful handling — a defined meter and a non-empty, foot-delimited
    // scansion covering every syllable — rather than a brittle exact string.
    const results = analyzeText('so much depends upon a red wheel barrow', false);
    const d = results[0].phonologicalScansion;
    expect(d.meter).toBeDefined();
    expect(d.scansion.length).toBeGreaterThan(0);
    expect(d.scansion).toContain('|');
    // syllable letters in the scansion must equal the line's syllable count
    const sylLetters = (d.scansion.match(/[xwnms]/g) || []).length;
    const totalSyls = results[0].sentence.words.reduce((a, w) => a + w.syllables.length, 0);
    expect(sylLetters).toBe(totalSyls);
  });
});

describe('structural fixes (2026-06-10 audit)', () => {
  it('quotation marks are not prosodic breaks: quoted word scans like unquoted', () => {
    // Quotes fragmented the IU hierarchy and flipped this line to "trochaic
    // hexameter"; quoted and unquoted twins must scan identically.
    const quoted = analyzeText('I wonder why we call them "wisdom" teeth.', false)[0].phonologicalScansion;
    const plain = analyzeText('I wonder why we call them wisdom teeth.', false)[0].phonologicalScansion;
    expect(quoted.meterName).toBe('iambic');
    expect(quoted.footCount).toBe(5);
    expect(quoted.meterName).toBe(plain.meterName);
    expect(quoted.footCount).toBe(plain.footCount);
  });

  it('the LINE is the scansion domain: internal stops do not fragment the meter', () => {
    // Three grammatical sentences, one verse line (Woolley) — one iambic pentameter,
    // not trimeter + monometer + monometer fragments.
    const results = analyzeText("You'll slurp potato soup. No straws! Suck gauze.", false);
    expect(results.length).toBe(1);
    expect(results[0].phonologicalScansion.meterName).toBe('iambic');
    expect(results[0].phonologicalScansion.footCount).toBe(5);
  });

  it('mid-line exclamation keeps the line whole (Stallings-aligned)', () => {
    const results = analyzeText('Oh, bloody day! Extractions take their toll;', false);
    expect(results.length).toBe(1);
    expect(results[0].phonologicalScansion.meterName).toBe('iambic');
    expect(results[0].phonologicalScansion.footCount).toBe(5);
  });

  it('single anacrusis upbeat does not inflate the foot count', () => {
    // 10 syllables with a leading extrametrical upbeat: pentameter, never hexameter.
    const d = analyzeText('The woods around it have it--it is theirs.', false)[0].phonologicalScansion;
    expect(d.footCount).toBe(5);
    expect(d.meter).not.toContain('hexameter');
  });

  it('double upbeat fills a foot slot: Hiawatha stays tetrameter', () => {
    const d = analyzeText('By the shores of Gitche Gumee,', false)[0].phonologicalScansion;
    expect(d.meterName).toBe('trochaic');
    expect(d.footCount).toBe(4);
  });
});

describe('syllable-count integrity (2026-06-10 audit)', () => {
  const sylCount = (line: string) => {
    const r = analyzeText(line, false)[0];
    return r.sentence.words.reduce((a, w) => a + w.syllables.length, 0);
  };

  it('"am" is one syllable (not the letter-name A.M. anomaly)', () => {
    expect(sylCount('I am too absent-spirited to count;')).toBe(10);
  });

  it('"us" is one syllable (not the letter-name U.S. anomaly)', () => {
    expect(sylCount('Just for a handful of silver he left us,')).toBe(11);
  });

  it("who'll / there's / she's re-merge to one syllable each", () => {
    expect(sylCount("who'll barely recognize your puffy face.")).toBe(10);
    expect(sylCount("and there's a light she's seen")).toBe(6);
  });

  it("archaic -'d preterite does not swallow the next word", () => {
    const r = analyzeText("And heav'n knows wand'ring ev'ry fix'd star", false)[0];
    const words = r.sentence.words.map(w => w.word.toLowerCase());
    expect(words).toContain("fix'd");
    expect(words).toContain('star');
    expect(words).not.toContain('would');
    expect(sylCount("And heav'n knows wand'ring ev'ry fix'd star")).toBe(9);
  });

  it("hyphenated archaic -'d (hen-peck'd) stays one 2-syllable word, not compound+would", () => {
    const r = analyzeText("Inform us truly, have they not hen-peck'd you all?", false)[0];
    const words = r.sentence.words.map(w => w.word.toLowerCase());
    expect(words).toContain("hen-peck'd");
    expect(words).not.toContain('would');
    const hp = r.sentence.words.find(w => w.word.toLowerCase() === "hen-peck'd")!;
    expect(hp.syllables.length).toBe(2);                 // hen·peckt (the -'d is silent)
  });

  it("th'-elision keeps the host's stress (th'ex-PENSE, not TH'EX-pense)", () => {
    const r = analyzeText("Th'expense of spirit in a waste of shame", false)[0];
    const w = r.sentence.words.find(x => x.word.toLowerCase().startsWith("th'"))!;
    expect(w.syllables.length).toBe(2);
    const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
    expect(rank[w.syllables[1].relativeStress!]).toBeGreaterThan(rank[w.syllables[0].relativeStress!]);
  });

  it("'tis / 'twas are weak aphaeresis clitics, not stressed proper nouns", () => {
    const r = analyzeText("'Tis true, 'twas night", false)[0];
    const tis = r.sentence.words.find(w => w.word.toLowerCase().includes('tis'))!;
    const twas = r.sentence.words.find(w => w.word.toLowerCase().includes('twas'))!;
    expect(['x', 'w']).toContain(tis.syllables[0].relativeStress);
    expect(['x', 'w']).toContain(twas.syllables[0].relativeStress);
  });
});

describe('caesura foot-alignment (2026-06-14)', () => {
  // Foot edges = cumulative syllable counts after each foot of the scansion string.
  const footEdges = (scan: string) => {
    const set = new Set<number>(); let c = 0;
    for (const foot of scan.split('|')) { for (const ch of foot) if ('xwnms'.includes(ch)) c++; set.add(c); }
    return set;
  };

  it('"But—Oh! ye lords…" breaks after the foot, never fragmenting the monosyllable "But"', () => {
    const l = analyzeStanzas('But—Oh! ye lords of ladies intellectual,').flat()[0];
    const d = l.phonologicalScansion;
    const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion);
    const edges = footEdges(d.scansion);
    expect(caes.size).toBeGreaterThan(0);
    for (const pos of caes.keys()) expect(edges.has(pos)).toBe(true); // every caesura on a foot edge
    expect(caes.has(1)).toBe(false);                                   // not after "But" alone
  });

  it('Poe gets one medial caesura at the comma (trochaic octameter midpoint)', () => {
    const l = analyzeStanzas('Once upon a midnight dreary, while I pondered weak and weary').flat()[0];
    const d = l.phonologicalScansion;
    const caes = computeCaesurae(l.sentence.words, l.phonologicalHierarchy, d.scansion);
    for (const pos of caes.keys()) expect(footEdges(d.scansion).has(pos)).toBe(true);
    expect([...caes.keys()]).toContain(8);   // after "dreary," — the line's medial break
  });
});

describe('rising function words & copula reduction (2026-06-14)', () => {
  const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
  const wordOf = (line: string, w: string) =>
    analyzeText(line, false)[0].sentence.words.find(x => x.word.toLowerCase() === w)!;

  // "because" is recorded fully-reduced ("00") in the augmented dictionary; the
  // all-zero re-stamp must rise (be·CAUSE), not take the disyllabic forestress
  // default (which mis-read it as BE·cause).
  it('"because" rises (be·CAUSE): the final syllable outranks the first', () => {
    const because = wordOf('Because at least the past were passed away,', 'because');
    expect(because.syllables.length).toBe(2);
    expect(rank[because.syllables[1].relativeStress!])
      .toBeGreaterThan(rank[because.syllables[0].relativeStress!]);
  });

  // The 1sg copula "am" reduces — it must not surface as a metrical beat ('m')
  // the way the letter-name A.M. data once forced it ("As I am BLOOD…").
  it('the copula "am" reduces — no spurious beat in "As I am blood…"', () => {
    const am = wordOf('As I am blood, bone, marrow, passion, feeling', 'am');
    expect(am.syllables.length).toBe(1);
    expect(rank[am.syllables[0].relativeStress!]).toBeLessThanOrEqual(rank['w']);
  });

  // An interjection immediately before "!" is an emphatic peak — it must stand out
  // from the flat function-word run around it ("But—Oh! ye…" was a monotone n·n·n).
  it('an exclaimed interjection (Oh!) outranks the conjunction/pronoun beside it', () => {
    const words = analyzeText('But—Oh! ye lords of ladies intellectual,', false)[0].sentence.words;
    const peak = (w: string) => {
      const word = words.find(x => x.word.toLowerCase() === w)!;
      return Math.max(...word.syllables.map(s => rank[s.relativeStress!]));
    };
    expect(peak('oh')).toBeGreaterThan(peak('but'));
    expect(peak('oh')).toBeGreaterThan(peak('ye'));
  });
});

describe('rhyme & form layer (2026-06-12)', () => {
  it('classifies canonical rhyme pairs (LYRICAL typology)', () => {
    expect(classifyRhymePair('grace', 'face')).toMatchObject({ type: 'perfect', structure: 'masculine' });
    expect(classifyRhymePair('picky', 'tricky')).toMatchObject({ type: 'perfect', structure: 'feminine' });
    expect(classifyRhymePair('belief', 'leaf')).toMatchObject({ type: 'rich' });     // homophone tails
    expect(classifyRhymePair('dame', 'grain')).toMatchObject({ type: 'family' });    // M/N nasals
    expect(classifyRhymePair('love', 'move')).toMatchObject({ type: 'consonant' });  // para-rhyme
    expect(classifyRhymePair('shaken', 'shaken')).toMatchObject({ type: 'identical' });
  });

  it('detects ABCB with unrhymed lines marked', () => {
    const rs = detectScheme(['Mariner', 'three', 'eye', 'me']);
    expect(rs.map(r => r.letter).join('')).toBe('\u00b7A\u00b7A');
  });

  it('Coleridge quatrain \u2192 ballad stanza (rhyme + 4\u00b73 gate)', () => {
    const r = analyzeStanzas('It is an ancient Mariner,\nAnd he stoppeth one of three.\nBy thy long grey beard and glittering eye,\nNow wherefore stopp\u2019st thou me?');
    expect(r[0][0].phonologicalScansion.formNote).toBe('ballad stanza (ABCB, 4\u00b73)');
  });

  it('Pope couplet \u2192 couplet', () => {
    const r = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne\u2019er so well expressed.');
    expect(r[0][0].phonologicalScansion.formNote).toBe('couplet');
  });

  it('Frost (Mending Wall opening) \u2192 blank verse', () => {
    const r = analyzeStanzas("Something there is that doesn't love a wall,\nThat sends the frozen-ground-swell under it,\nAnd spills the upper boulders in the sun;\nAnd makes gaps even two can pass abreast.");
    expect(r[0][0].phonologicalScansion.formNote).toBe('blank verse');
  });

  it('Sonnet 130 \u2192 Shakespearean Sonnet, full scheme', () => {
    const text = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\nIf snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\nI have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\nAnd in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\nI love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\nI grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\nAnd yet, by heaven, I think my love as rare\nAs any she belied with false compare.";
    const r = analyzeStanzas(text);
    expect(r[0][0].phonologicalScansion.formNote).toBe('Shakespearean Sonnet');
    expect(r[0].map(l => l.phonologicalScansion.rhyme!.letter).join('')).toBe('ABABCDCDEFEFGG');
  });

  it('Lear limerick \u2192 limerick (AABBA, ternary)', () => {
    const r = analyzeStanzas('There was an Old Man with a beard,\nWho said, It is just as I feared!\nTwo Owls and a Hen,\nFour Larks and a Wren,\nHave all built their nests in my beard!');
    expect(r[0][0].phonologicalScansion.formNote).toMatch(/^limerick/);
  });
});

describe('continuity rename (2026-06-14)', () => {
  it('near-tie line adopts the stanza-dominant meter; standalone kept as note', () => {
    // Exile opening: L2 standalone reads dactylic (spondaic "book-" anacrusis
    // seizes a beat), but the stanza is amphibrachic — continuity renames it.
    const r = analyzeStanzas(
      'He happens to be a French poet, that thin,\n' +
      'book-carrying man with a bristly gray chin;\n' +
      'you meet him whenever you go');
    const d = r[0][1].phonologicalScansion;
    expect(d.meterName).toBe('amphibrachic');
    expect(d.standaloneMeter).toMatch(/dactylic/);
    expect(d.consensusMeter).toBeUndefined();
  });

  it('accentual stanzas are exempt: Wyatt keeps 4-beat accentual, no renames', () => {
    const wyatt =
      'They fle from me that sometyme did me seke\n' +
      'With naked fote stalking in my chambre.\n' +
      'I have sene theim gentill tame and meke\n' +
      'That nowe are wyld and do not remembre\n' +
      'That sometyme they put theimself in daunger\n' +
      'To take bred at my hand; and nowe they raunge\n' +
      'Besely seking with a continuell chaunge.';
    const r = analyzeStanzas(wyatt);
    for (const l of r[0]) {
      expect(l.phonologicalScansion.rhythmNote).toBe('4-beat accentual');
      expect(l.phonologicalScansion.standaloneMeter).toBeUndefined();
    }
  });
});

describe('parse-correction layer (2026-06-14)', () => {
  it('staged pipeline repairs "I had quit the programming paradigm"', () => {
    const doc = parseDocument('I had quit the programming paradigm');
    const words = doc.sentences[0].words;
    expect(words.find(w => w.word === 'i')!.lexicalClass).toBe('PRP');
    expect(words.find(w => w.word === 'quit')!.lexicalClass).toBe('VBN');
    const deps = doc.sentences[0].dependencies;
    const prog = deps.find(d => d.dependentName === 'programming')!;
    // Re-baselined 2026-07-02 (UDPIPE_MIGRATION.md Group B): the old golden 'amod'
    // encoded the depfix repair of en-parse's broken reading ("programming ←DOBJ←
    // quit").  UDPipe parses the gerund-noun pair directly as `compound` — the
    // standard UD treatment, and the one that feeds the correct CSR forestress
    // ("PROgramming paradigm").
    expect(prog.dependentType).toBe('compound');
    expect(prog.governorName).toBe('paradigm');
    const det = deps.find(d => d.dependentName === 'the')!;
    expect(det.dependentType).toBe('det');
    expect(det.governorName).toBe('paradigm');
  });

  it('archaic forms get verse-correct tags (thy/PRP$ floors at w)', () => {
    const doc = parseDocument('By thy long grey beard and glittering eye,');
    const thy = doc.sentences[0].words.find(w => w.word === 'thy')!;
    expect(thy.lexicalClass).toBe('PRP$');
  });

  it('context-guarded gerund fix: "they bicycle through" keeps the verb', () => {
    const doc = parseDocument('young beauties, all legs, as they bicycle through');
    const bike = doc.sentences[0].words.find(w => w.word === 'bicycle')!;
    expect(bike.lexicalClass).toBe('VBP');
  });
});

describe('metricality, foot names, beats & poem-wide rhyme (2026-06-13)', () => {
  it('extends foot-count nomenclature through icosameter', () => {
    // A long run of clean "the cat" iambs names the line all the way up.
    const feet14 = analyzeText(Array.from({ length: 14 }, () => 'the cat').join(' '))[0]
      .phonologicalScansion;
    expect(feet14.footCount).toBe(14);
    expect(feet14.meter).toBe('iambic tetradecameter');
    const feet20 = analyzeText(Array.from({ length: 20 }, () => 'the cat').join(' '))[0]
      .phonologicalScansion;
    expect(feet20.meter).toBe('iambic icosameter');
  });

  it('prose-likeness hedge fires on long un-lineated prose', () => {
    const d = analyzeText(
      "Another thing I've noticed more generally is that the current engine's caesural mechanics may be disruptive",
    )[0].phonologicalScansion;
    expect(d.metricalityNote).toBeDefined();
    expect(d.metricalityNote).toContain('plausible prose');
    expect(d.metricalityNote).toContain(d.meter);          // closest-fit name echoed
    expect(d.meter).toMatch(/decameter$/);                 // long foot-name in use
  });

  it('hedge spares short verse, incl. low-certainty / ambiguous lines', () => {
    // Real verse below the 9-foot length gate is never flagged — not even
    // V6 ("Half a league", certainty ~40) or L2 (Prufrock, an iamb/troch tie).
    for (const v of [
      "Shall I compare thee to a summer's day?",
      'Half a league, half a league, half a league onward',
      'Let us go then, you and I',
      'This is the forest primeval, the murmuring pines and the hemlocks',
    ]) {
      expect(analyzeText(v)[0].phonologicalScansion.metricalityNote).toBeUndefined();
    }
  });

  it('metricalityVerdict is purely a function of the detail fields', () => {
    // No straddle (top-3 all one polarity/size) ⇒ never prose, even if long.
    const committed: PhonologicalScansionDetail = {
      all: '', keyStresses: '', meter: 'iambic dodecameter', meterName: 'iambic',
      footCount: 12, summary: '', scansion: '', certainty: 40,
      weightScore: 0, maxPossibleWeight: 0,
      ranking: [{ meter: 'iambic', score: 1.0 }, { meter: 'trochaic', score: 0.95 }],
    };
    expect(metricalityVerdict(committed)).toBeUndefined();
  });

  it('end-rhyme types are preserved; lettering is poem-wide (not per-stanza)', () => {
    // The rhyme TYPE rides on the line that completes the rhyme (dressed/expressed → rich).
    const c = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne’er so well expressed.');
    expect(c.flat()[1].phonologicalScansion.rhyme!.type).toBe('rich');
    // Two couplets of UNRELATED sounds, in separate stanzas: each stanza still
    // reads AA on its own, but the two A's must be DIFFERENT poem-wide keys —
    // lettering runs once over the whole poem, so an unrelated stanza never
    // reuses an earlier stanza's letter.
    const two = analyzeStanzas('The cat sat on the mat,\nbeside a sleeping rat.\n\nThe sun was in the sky,\nas clouds went drifting by.');
    expect(two.map(st => st.map(l => l.phonologicalScansion.rhyme!.letter).join(''))).toEqual(['AA', 'BB']);
  });

  it('a stanza-1 line rhyming ONLY with a stanza-2 line still shares one poem-wide key', () => {
    // L1 "still" and L4 "chill" bind ACROSS the stanza break; L2 "wall" and
    // L3 "light" are each unrhymed on their own.  The end-rhyme letter must
    // be identical on both lines, matchedLine must be the POEM-GLOBAL index
    // of L1 (0, not stanza-local), and the synopsis' End-Rhyme Scheme row
    // must read off exactly the same per-line letters (single source of truth).
    const text = 'The house was calm and still,\nbeyond the garden wall,\n\n'
      + 'we watched the fading light,\nuntil the coming chill.';
    const r = analyzeStanzas(text);
    const l1 = r[0][0].phonologicalScansion.rhyme!;
    const l4 = r[1][1].phonologicalScansion.rhyme!;
    expect(l1.letter).not.toBe('·');
    expect(l1.letter).toBe(l4.letter);                        // (i) same poem-wide letter
    expect(l4.matchedLine).toBe(0);                           // (iii) poem-global index of L1
    const perLine = r.flatMap(st => st.map(l => l.phonologicalScansion.rhyme!.letter)).join('');
    const row = summarizePoem(r).find(row => row.label === 'End-Rhyme Scheme')!.value;
    expect(row).toBe(perLine);                                // (ii) synopsis matches per-line letters
  });

  it('rhymeKey: unbounded bijective base-26 (A…Z, then AA, AB, …)', () => {
    expect(rhymeKey(0)).toBe('A');
    expect(rhymeKey(25)).toBe('Z');
    expect(rhymeKey(26)).toBe('AA');
    expect(rhymeKey(27)).toBe('AB');
    expect(rhymeKey(51)).toBe('AZ');
    expect(rhymeKey(52)).toBe('BA');
  });

  it('pre-caesural internal rhyme is additive (own letter + type), end rhyme intact', () => {
    // end beam/dream → A (perfect); internal night/bright form their own class → B.
    const r = analyzeStanzas('The stars at night, a silver beam,\nshine ever bright as in a dream.');
    const lines = r.flat();
    expect(lines.map(l => l.phonologicalScansion.rhyme!.notation)).toEqual(['(B)A', '(B)A']);
    expect(lines[0].phonologicalScansion.rhyme!.letter).toBe('A');               // end letter untouched
    expect(lines[0].phonologicalScansion.rhyme!.internal)
      .toEqual([{ word: 'night', letter: 'B', type: 'perfect' }]);
  });

  it('heterometric is an advisory only — never stamped on per-line rhythmNote', () => {
    const song = 'If each wire looks barbed, it actually is\nBlessed are they lounged at cemeteries\n' +
      'Not quite enough for all, but we still don’t care\nNo shit exists so noxious, we don’t binge and share\n' +
      'But it looks like, it’s for the long haul\nLooks like it is habitual\n' +
      'Looks like it’s quite a catch-all\nLooks like it’s just a lack of\nSOMEBODY MORE\nOF SOMEBODY MORE';
    const r = analyzeStanzas(song);
    for (const l of r.flat()) {
      expect(l.phonologicalScansion.rhythmNote ?? '').not.toContain('heterometric');
      expect(l.phonologicalScansion.meterName).not.toBe('free verse'); // lines keep a real meter
    }
    // The line-length-variation observation lives in the synopsis instead
    // ("heterometric" wording dropped 2026-06-16 per the maintainer).
    const note = summarizePoem(r).find(row => row.label === 'Note')?.value ?? '';
    expect(note).toContain('line lengths vary');
  });

  it('poem synopsis: accentual reported in beats; sonnet form + meter', () => {
    const wyatt = 'They fle from me that sometyme did me seke\n' +
      'With naked fote stalking in my chambre.\nI have sene theim gentill tame and meke\n' +
      'That nowe are wyld and do not remembre\nThat sometyme they put theimself in daunger\n' +
      'To take bred at my hand; and nowe they raunge\nBesely seking with a continuell chaunge.';
    // The metre make-up lives in the "Rhythm" row (relabelled 2026-06-16).
    const wMeter = summarizePoem(analyzeStanzas(wyatt)).find(r => r.label === 'Rhythm')!.value;
    expect(wMeter).toContain('accentual');         // beats, not feet
    expect(wMeter).not.toMatch(/meter\b/);         // no classical foot-length name

    const sonnet = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" +
      "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\n" +
      "I have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\n" +
      'And in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\n' +
      "I love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\n" +
      'I grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\n' +
      'And yet, by heaven, I think my love as rare\nAs any she belied with false compare.';
    const rows = summarizePoem(analyzeStanzas(sonnet));
    expect(rows.find(r => r.label === 'Form')!.value).toContain('Shakespearean Sonnet');
    // Metre make-up now in "Rhythm", abbreviated ("iamb penta") 2026-06-16.
    expect(rows.find(r => r.label === 'Rhythm')!.value).toContain('iamb penta');
  });
});

describe('phonopoetics (2026-06-16)', () => {
  it('end-rhyme pair + alliteration + head rhyme (three-phase lettering)', () => {
    const p = analyzePhonopoetics(analyzeStanzas(
      'Here were fond climates and sweet singers suddenly\n' +
      'Nap on the hill and gap in the rolling cloud\n' +
      'Come in the morning where I wandered and rhyme\n' +
      'Gap on the crest where weary wanderers time'));
    // END: rhyme/time bind to one letter
    expect(p.end.some(r => (r.fromWord === 'rhyme' && r.toWord === 'time') || (r.fromWord === 'time' && r.toWord === 'rhyme'))).toBe(true);
    // HEAD: Nap/Gap, lettered AFTER the end letters (continue the alphabet), once
    expect(p.head.length).toBe(1);
    expect(new Set([p.head[0].fromWord, p.head[0].toWord])).toEqual(new Set(['Gap', 'Nap']));
    // ALLITERATION carries no letters; "sweet singers suddenly" is one run
    expect(p.alliteration.some(a => a.words.join(' ').toLowerCase() === 'sweet singers suddenly')).toBe(true);
  });

  it('caesural rhyme reuses the exact end-pair letter it echoes', () => {
    const p = analyzePhonopoetics(analyzeStanzas(
      'In the silent night, I saw a gleaming light\n' +
      'With all of my might, I walked the path of sight'));
    const c = p.caesural.find(r => r.fromWord === 'night');
    expect(c).toBeTruthy();
    const e = p.end.find(r => r.fromWord === 'light' || r.toWord === 'light');
    expect(e).toBeTruthy();
    expect(c!.letter).toBe(e!.letter);   // night (pre-caesural) reused light's letter
  });

  it('acrostic spells a dictionary word from line initials', () => {
    const p = analyzePhonopoetics(analyzeStanzas(
      'Bright was the morning sun\nIn the cold and dawn\nNever a cloud above\nGone is the silent night\nOver the hills it came'));
    expect(p.acrostics.some(a => a.word === 'BINGO')).toBe(true);
  });
});

describe('stress-clash invariant (2026-06-16)', () => {
  // The maintainer's categorical, input-independent rule (generalising McAleese's
  // "stress clashes (ss,ms) > s-s" and Liberman & Prince's grid alternation):
  // on the STRESSED tier {n,m,s} NO two adjacent syllables may share a level — that
  // is a clash (two equal prominences with no gradation).  Gradient pairs
  // (sm/ms/sn/ns/mn/nm) are fine; the unstressed tiers {w,x} MAY repeat.  This must
  // hold for ANY input — it is enforced by the clash filter (resolveLinearClashes).
  // "It's kind of a crime it's not a test already." — so here it is.
  const STRESSED = new Set(['n', 'm', 's']);

  function clashesIn(text: string): string[] {
    const bad: string[] = [];
    for (const lr of analyzeText(text, false)) {
      // A clash is two equal stressed syllables CONTIGUOUS in pronunciation.
      // Punctuation is a prosodic break, so syllables either side of it are not
      // contiguous (a '|' sentinel, not in STRESSED, breaks the adjacency scan).
      const seq: { lvl: string; word: string }[] = [];
      for (const w of lr.sentence.words) {
        if (isPunctuation(w.lexicalClass)) { seq.push({ lvl: '|', word: '' }); continue; }
        for (const s of w.syllables) seq.push({ lvl: s.relativeStress ?? '?', word: w.word });
      }
      const line = lr.sentence.words.filter(w => !isPunctuation(w.lexicalClass)).map(w => w.word).join(' ');
      for (let i = 0; i < seq.length - 1; i++) {
        if (seq[i].lvl === seq[i + 1].lvl && STRESSED.has(seq[i].lvl)) {
          bad.push(`${seq[i].lvl}${seq[i + 1].lvl} ("${seq[i].word}"+"${seq[i + 1].word}") in "${line}"`);
        }
      }
    }
    return bad;
  }

  // The maintainer's own example battery (the regression set that exposed the
  // clashes), plus canonical verse of every metre.
  const battery: Record<string, string> = {
    'Thomas — There could I marvel': 'There could I marvel my birthday',
    'Thomas — Poem in October (st.1)':
      'My birthday began with the water-\nBirds and the birds of the winged trees flying my name\n' +
      'Above the farms and the white horses\nAnd I rose\nIn rainy autumn\n' +
      'And walked abroad in a shower of all my days.',
    'Gypsies — bachelor / gypsies':
      "O, I am a bachelor, I live by myself\n" +
      "And the only, only thing that I ever did was wrong\n" +
      "So all night long I held her in my arms\n" +
      "One night she came to my bedside\n" +
      "I fear you have had some ill sickness\n" +
      "One sang high and the other sang low\n" +
      "The ragged, ragged rags about our door\n" +
      "It was late last night when my lord came home",
    'Byron — intellectual / hen-peck\'d':
      "But—Oh! ye lords of ladies intellectual,\nInform us truly, have they not hen-peck'd you all?",
    'Byron — vaunt / Don Juan':
      'Of such as these I should not care to vaunt,\nI\'ll therefore take our ancient friend Don Juan',
    'Shakespeare — Sonnet 130 (q.1)':
      "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\n" +
      "If snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.",
    'Poe — The Raven (opening)':
      'Once upon a midnight dreary, while I pondered weak and weary,\n' +
      'Over many a quaint and curious volume of forgotten lore',
    'Milton — Paradise Lost (opening)':
      'Of Mans First Disobedience, and the Fruit\nOf that Forbidden Tree, whose mortal tast',
  };

  for (const [name, text] of Object.entries(battery)) {
    it(`no adjacent equal stress on {n,m,s} — ${name}`, () => {
      const bad = clashesIn(text);
      expect(bad, bad.join(' ; ')).toEqual([]);
    });
  }
});

// Stress-level tuning from the Sosnora-translation review (2026-06-21).
describe('stress-level tuning (Sosnora review, 2026-06-21)', () => {
  it('curly-apostrophe contractions tokenise like straight ones (won’t = 1 modal)', () => {
    // A curly ’ bypassed the contraction path and en-norm dehiscised "won’t"
    // into "will not" (2 syllables); the apostrophe is now normalised first.
    const curly = parseDocument('I won’t tell anyone').sentences[0].words.map(w => w.word);
    const straight = parseDocument("I won't tell anyone").sentences[0].words.map(w => w.word);
    expect(curly).toEqual(straight);
    expect(curly).toContain("won't");
    expect(curly).not.toContain('will');           // not expanded to "will not"
  });

  it('aren’t / Nature’s with curly apostrophe tokenise correctly', () => {
    const w = parseDocument('boats aren’t tears').sentences[0].words.map(x => x.word);
    expect(w).toContain("aren't");
    const poss = parseDocument('Nature’s first green').sentences[0].words.map(x => x.word);
    expect(poss).toContain("'s");                  // possessive clitic split off
  });

  it('N+N compound fore-stresses even when en-parse inverts the dependency arrow', () => {
    // "slate roof"/"clay jar" parse as roof→slate / jar→clay (head on the LEFT),
    // the inverse of "ice cream"; the left noun must still fore-stress (CSR).
    for (const np of ['a slate roof', 'a clay jar']) {
      const d = analyzeText(np, false)[0].phonologicalScansion;
      expect(d.scansion, np).toBe('xsn');          // a·SLATE·roof  (beat on the modifier)
    }
    // the canonical NOMD case is unchanged
    expect(analyzeText('ice cream', false)[0].phonologicalScansion.scansion).toBe('sn');
  });

  it('a reduced copula after a function word floors below it (it·is gradient)', () => {
    // "it is" was a flat w·w; the copula now reduces to x, restoring the gradient.
    // Re-derived 2026-06-29 (Wagner/Krifka rebuild): the assertion verifies the
    // copula reduction it(w)·is(x) at the head; the foot boundary after it is no
    // longer pinned, because deictic "here" now carries a light content beat
    // (it·is·GOLD·here → wxsn), a defensible differentiation that re-segments the
    // feet without touching the is→x reduction the test exists to check.
    expect(analyzeText('it is gold here', false)[0].phonologicalScansion.scansion).toMatch(/^wx/);
  });
});


// ─── UDPipe feature leverage & challenging constructions (2026-06-23) ─────────
// These exercise the PRODUCTION (Calliope) engine — `analyzeText` — not the
// legacy runPipeline path, and lean on UDPipe features en-parse never produced:
// the `compound` relation (genuine N+N fore-stress) and the model-agnostic
// UPOS+FEATS POS derivation.  See AGENTS.md log 2026-06-23.
describe('UDPipe feature leverage & challenging constructions (2026-06-23)', () => {
  const RANK: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
  const lineResult = (line: string) => analyzeText(line, false)[0];
  const peakRel = (w: any) =>
    Math.max(...w.syllables.map((s: any) => RANK[s.relativeStress ?? 'w']));
  const wordByText = (lr: any, t: string) =>
    lr.sentence.words.find((w: any) => w.word === t);
  const phiCount = (lr: any) =>
    lr.phonologicalHierarchy.reduce((n: number, iu: any) => n + iu.phonologicalPhrases.length, 0);

  it('N+N compounds fore-stress via the UD `compound` relation (ICE cream, DOOR man, STONE wall)', () => {
    for (const [phrase, a, b] of [
      ['ice cream', 'ice', 'cream'],
      ['the door man', 'door', 'man'],
      ['a stone wall', 'stone', 'wall'],
    ]) {
      const lr = lineResult(phrase);
      expect(peakRel(wordByText(lr, a)), `${phrase}: ${a} should fore-stress over ${b}`)
        .toBeGreaterThan(peakRel(wordByText(lr, b)));
    }
  });

  it('canonical iambic lines read iambic on the production engine', () => {
    // Milton, Paradise Lost 1.1 (iambic pentameter); Kilmer, Trees (iambic tetrameter).
    expect(scanLine("Of Man's first disobedience, and the fruit").meterName).toBe('iambic');
    expect(scanLine('I think that I shall never see').meterName).toBe('iambic');
  });

  it('ϕ-grouping is not over-segmented: a verb + light oblique PP stay one phrase', () => {
    // Selkirk: "{[More than fifteen][carpenters]}{[are working][in the house]}" = 2 ϕ.
    // Before the OBL fix this shattered into 5 ϕ, washing out the key stresses.
    const lr = lineResult('More than fifteen carpenters are working in the house');
    expect(phiCount(lr)).toBeLessThanOrEqual(3);
  });

  it('carpenters line: subject NP is ONE ϕ, "in the house" its own ϕ (3 ϕ total)', () => {
    // Expert/Gee&Grosjean gold (McAleese p.213):
    //   <{[More than fifteen carpenters]}{[are working]}{[in the house]}>  — 3 ϕ.
    // The subject NP must NOT shatter ("more" is not its own ϕ) and the verbal
    // oblique "in the house" IS its own ϕ.  (A prior build gave 5 ϕ — every
    // argument a phrase — which made every spurious ϕ-end a key stress.)
    const lr = lineResult('More than fifteen carpenters are working in the house');
    expect(phiCount(lr)).toBe(3);
    // "more" sits in the subject ϕ with "carpenters" (not its own singleton ϕ), so
    // it is a SECONDARY beat under the ϕ nuclear "carpenters", never an 's'.
    expect(peakRel(wordByText(lr, 'more'))).toBeLessThan(peakRel(wordByText(lr, 'carpenters')));
    expect(peakRel(wordByText(lr, 'more'))).toBeGreaterThan(RANK.w);   // a real beat (m), not flattened
    // The ϕ-initial preposition "in" carries the subtle left-edge beat the maintainer
    // hears — above the reduced article "the".
    expect(peakRel(wordByText(lr, 'in'))).toBeGreaterThan(peakRel(wordByText(lr, 'the')));
  });

  it('phrase-initial preposition outranks an interior determiner (Blake: through > each)', () => {
    // "I wander through each chartered street": the ϕ-initial "through" takes a beat,
    // the interior determiner-slot "each" reduces — so through > each, not "through EACH".
    const lr = lineResult('I wander through each chartered street');
    expect(peakRel(wordByText(lr, 'through'))).toBeGreaterThan(peakRel(wordByText(lr, 'each')));
  });
});