calliope-ts 0.0.1

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 (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
@@ -0,0 +1,434 @@
1
+ // scandroid.ts — Optional Scandroid integration: provides classic iambic and
2
+ // anapestic scansion algorithms from Hartman’s Scandroid, adapted to TypeScript.
3
+ // This module is purely functional; it does not modify the main pipeline and
4
+ // can be omitted without affecting the phonological scansion.
5
+
6
+ import { StressLevel, MetreName, ScansionResult } from './types.js';
7
+
8
+ // ─── Constants from scanstrings.py ─────────────────────────────────
9
+
10
+ const STRESS = '/';
11
+ const SLACK = 'x';
12
+ const PROMOTED = '%';
13
+ const FOOTDIV = '|';
14
+
15
+ /** Foot dictionary for iambic lines (Scandroid’s footDict). */
16
+ const IAMBIC_FOOT_DICT: Record<string, string> = {
17
+ 'x/': 'iamb',
18
+ 'xx': 'pyrrhic',
19
+ '//': 'spondee',
20
+ '/x': 'trochee',
21
+ 'x/x': 'amphibrach',
22
+ '//x': 'palimbacchius',
23
+ 'xx/': 'anapest',
24
+ '/': 'defective',
25
+ '/xx': 'dactyl',
26
+ '/x/': 'cretic',
27
+ 'x//': 'bacchius',
28
+ 'x%': '(iamb)',
29
+ 'xx%': '(anapest)',
30
+ '%x': '(trochee)',
31
+ 'x/xx': '2nd paeon',
32
+ 'xx/x': '3rd paeon',
33
+ };
34
+
35
+ /** Foot dictionary for anapestic lines (Scandroid’s AnapSubs). */
36
+ const ANAPESTIC_FOOT_DICT: Record<string, string> = {
37
+ 'xx/': 'anapest',
38
+ '/x/': 'cretic',
39
+ 'x//': 'bacchius',
40
+ 'x/': 'iamb',
41
+ 'x%': '(iamb)',
42
+ 'xx%': '(anapest)',
43
+ '//': 'spondee',
44
+ 'xx/x': '3rd paeon',
45
+ 'x/x': 'amphibrach',
46
+ '///': 'molossus',
47
+ '/x%': '(cretic)',
48
+ '//x': 'palimbacchius',
49
+ };
50
+
51
+ // ─── Utility functions (adapted from scanutilities.py) ────────────
52
+
53
+ /** Generator-like function to walk through a string in chunks, matching a dictionary. */
54
+ function footFinder(
55
+ fDict: Record<string, string>,
56
+ str: string,
57
+ chunkSize: number,
58
+ start: number,
59
+ end: number
60
+ ): Array<{ foot: string; index: number }> {
61
+ const result: Array<{ foot: string; index: number }> = [];
62
+ let pos = start;
63
+ while (pos < end) {
64
+ const chunk = str.slice(pos, pos + chunkSize);
65
+ if (chunk in fDict) {
66
+ pos += chunkSize;
67
+ result.push({ foot: fDict[chunk], index: pos });
68
+ } else {
69
+ // signal failure by returning empty array
70
+ return [];
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /** Find the longest match of a RegExp in a string (last occurrence of longest length). */
77
+ function longestMatch(rx: RegExp, s: string): { start: number; length: number } | null {
78
+ let start = -1, length = 0;
79
+ let current = 0;
80
+ let m: RegExpExecArray | null;
81
+ while ((m = rx.exec(s.slice(current))) !== null) {
82
+ const mStart = current + m.index;
83
+ const mEnd = mStart + m[0].length;
84
+ if (mEnd - mStart >= length) {
85
+ start = mStart;
86
+ length = mEnd - mStart;
87
+ }
88
+ current = mStart + 1;
89
+ }
90
+ return start >= 0 ? { start, length } : null;
91
+ }
92
+
93
+ /** Compute line length in feet by counting non-adjacent stresses (for anapestic estimation). */
94
+ function altLineLenCalc(marks: string): number {
95
+ const arr = marks.split('');
96
+ for (let i = 0; i < arr.length; i++) {
97
+ if (i === 0 || arr[i - 1] === '/') {
98
+ if (arr[i] === '/') arr[i] = 'x';
99
+ }
100
+ }
101
+ return arr.filter(ch => ch === '/').length;
102
+ }
103
+
104
+ // ─── Complexity measurement (from Scandroid’s _measureComplexity) ──
105
+
106
+ function iambicComplexity(footlist: string[], numFeet: number): number {
107
+ if (footlist.length !== numFeet) return 100;
108
+ let prevIsTrochee = false;
109
+ let points = 0;
110
+ for (let i = 0; i < footlist.length; i++) {
111
+ let f = footlist[i];
112
+ if (f.startsWith('(') && f.endsWith(')')) f = f.slice(1, -1);
113
+ if (['spondee', 'pyrrhic', 'trochee'].includes(f)) points += 2;
114
+ if (['anapest', 'defective', '3rd paeon', 'amphibrach', 'palimbacchius', '2nd paeon'].includes(f)) points += 4;
115
+ if (['dactyl', 'cretic', 'bacchius'].includes(f)) points += 10;
116
+ if (f === 'trochee') {
117
+ if (i === footlist.length - 1) points += 6;
118
+ if (prevIsTrochee) points += 8;
119
+ prevIsTrochee = true;
120
+ } else prevIsTrochee = false;
121
+ if ((f === 'trochee' || f === 'defective') /* bounds test omitted for simplicity */) points += 4;
122
+ }
123
+ return points;
124
+ }
125
+
126
+ function anapesticComplexity(footlist: string[]): number {
127
+ if (footlist.length === 0) return 100;
128
+ let points = 0;
129
+ for (const f of footlist) {
130
+ if (f === 'bacchius') points += 2;
131
+ else if (f === '(anapest)') points += 1;
132
+ else if (f === 'iamb' || f === '(iamb)') points += 2;
133
+ else if (f === 'cretic') points += 4;
134
+ else if (['spondee', 'pyrrhic'].includes(f)) points += 4;
135
+ else if (['amphibrach', '3rd paeon'].includes(f)) points += 4;
136
+ else if (['2nd paeon', 'molossus', 'palimbacchius'].includes(f)) points += 5;
137
+ }
138
+ return points;
139
+ }
140
+
141
+ // ─── Iambic Algorithm 1: Corral the Weird ─────────────────────────
142
+
143
+ export function scandroidCorralWeird(
144
+ marks: string,
145
+ numFeet: number
146
+ ): { footlist: string[]; scansionMarks: string } {
147
+ const footlist: string[] = [];
148
+ let remaining = marks;
149
+ let lastFoot = '';
150
+
151
+ // Step 1: handle terminal slack (extra syllables at end)
152
+ const normLen = numFeet * 2;
153
+ if (remaining.length > normLen + 1 && ['x/xx', 'xx/x'].includes(remaining.slice(-4))) {
154
+ lastFoot = IAMBIC_FOOT_DICT[remaining.slice(-4)];
155
+ remaining = remaining.slice(0, -4);
156
+ } else if (remaining.length >= normLen && ['x/x', '//x'].includes(remaining.slice(-3))) {
157
+ lastFoot = IAMBIC_FOOT_DICT[remaining.slice(-3)];
158
+ remaining = remaining.slice(0, -3);
159
+ }
160
+
161
+ // Step 2: handle acephalous (headless) line
162
+ if (remaining.length <= normLen - 2 && (remaining.startsWith('/x/x') || remaining.startsWith('/xxx'))) {
163
+ footlist.push('defective');
164
+ remaining = remaining.slice(1);
165
+ }
166
+
167
+ const currLen = remaining.length;
168
+ const needFeet = numFeet - footlist.length - (lastFoot ? 1 : 0);
169
+ const targetLen = needFeet * 2;
170
+
171
+ if (currLen === targetLen) {
172
+ const feet = footFinder(IAMBIC_FOOT_DICT, remaining, 2, 0, currLen);
173
+ if (feet.length === 0) return { footlist: [], scansionMarks: '' };
174
+ footlist.push(...feet.map(f => f.foot));
175
+ } else if (currLen < targetLen) {
176
+ // seek a defective foot (single stress)
177
+ const candidate = remaining.indexOf('x//');
178
+ if (candidate === -1 || candidate % 2 !== 0) return { footlist: [], scansionMarks: '' };
179
+ const defectivePos = candidate + 2;
180
+ const before = footFinder(IAMBIC_FOOT_DICT, remaining, 2, 0, defectivePos);
181
+ if (before.length === 0) return { footlist: [], scansionMarks: '' };
182
+ footlist.push(...before.map(f => f.foot));
183
+ footlist.push('defective');
184
+ const after = footFinder(IAMBIC_FOOT_DICT, remaining, 2, defectivePos + 1, currLen);
185
+ if (after.length === 0) return { footlist: [], scansionMarks: '' };
186
+ footlist.push(...after.map(f => f.foot));
187
+ } else {
188
+ // need anapests to fill extra syllables
189
+ const need = currLen - targetLen;
190
+ // collect candidate positions for anapest insertion
191
+ const candidates: number[] = [];
192
+ for (let i = 0; i < remaining.length; i++) {
193
+ if (remaining.slice(i, i + 4) === '/xx/') candidates.push(i + 1);
194
+ }
195
+ if (candidates.length < need) {
196
+ for (let i = 0; i < remaining.length; i++) {
197
+ if (remaining.slice(i, i + 3) === 'xx/') candidates.push(i);
198
+ }
199
+ }
200
+ let pos = 0;
201
+ let anapestsUsed = 0;
202
+ while (pos < currLen) {
203
+ if (anapestsUsed < need && candidates.includes(pos)) {
204
+ const chunk = remaining.slice(pos, pos + 3);
205
+ if (!(chunk in IAMBIC_FOOT_DICT)) return { footlist: [], scansionMarks: '' };
206
+ footlist.push(IAMBIC_FOOT_DICT[chunk]);
207
+ pos += 3;
208
+ anapestsUsed++;
209
+ } else {
210
+ const chunk = remaining.slice(pos, pos + 2);
211
+ if (!(chunk in IAMBIC_FOOT_DICT)) return { footlist: [], scansionMarks: '' };
212
+ footlist.push(IAMBIC_FOOT_DICT[chunk]);
213
+ pos += 2;
214
+ }
215
+ }
216
+ }
217
+
218
+ if (lastFoot) footlist.push(lastFoot);
219
+
220
+ // Generate scansion string with foot divisions
221
+ const scansion = footlist.map(f => f.startsWith('(') ? f : f).join('|'); // simplistic
222
+ return { footlist, scansionMarks: scansion };
223
+ }
224
+
225
+ // ─── Iambic Algorithm 2: Maximize the Normal ─────────────────────
226
+
227
+ export function scandroidMaximizeNormal(
228
+ marks: string,
229
+ numFeet: number
230
+ ): { footlist: string[]; scansionMarks: string } {
231
+ const possIambRE = /(x[x/])+/;
232
+ const match = longestMatch(possIambRE, marks);
233
+ if (!match) return { footlist: [], scansionMarks: '' };
234
+ const { start, length } = match;
235
+ const runEnd = start + length;
236
+ const headMarks = marks.slice(0, start);
237
+ const tailMarks = marks.slice(runEnd);
238
+ const mainMarks = marks.slice(start, runEnd);
239
+ const footlist: string[] = [];
240
+ const headFeet: string[] = [];
241
+ const tailFeet: string[] = [];
242
+
243
+ // Scan the regular middle stretch
244
+ const mainFeet = footFinder(IAMBIC_FOOT_DICT, mainMarks, 2, 0, mainMarks.length);
245
+ if (mainFeet.length === 0) return { footlist: [], scansionMarks: '' };
246
+ footlist.push(...mainFeet.map(f => f.foot));
247
+
248
+ // Scan head
249
+ if (headMarks.length > 0) {
250
+ if (headMarks.length % 2 === 0) {
251
+ const hf = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, 0, headMarks.length);
252
+ if (hf.length === 0) return { footlist: [], scansionMarks: '' };
253
+ headFeet.push(...hf.map(f => f.foot));
254
+ } else {
255
+ if (headMarks.startsWith('/x')) {
256
+ headFeet.push('defective');
257
+ const rest = headMarks.slice(1);
258
+ if (rest.length > 0) {
259
+ const hf = footFinder(IAMBIC_FOOT_DICT, rest, 2, 0, rest.length);
260
+ if (hf.length === 0) return { footlist: [], scansionMarks: '' };
261
+ headFeet.push(...hf.map(f => f.foot));
262
+ }
263
+ } else {
264
+ // try to find an anapest in the head
265
+ const anap = headMarks.indexOf('xx/');
266
+ if (anap === -1) return { footlist: [], scansionMarks: '' };
267
+ const before = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, 0, anap);
268
+ if (before.length === 0) return { footlist: [], scansionMarks: '' };
269
+ headFeet.push(...before.map(f => f.foot));
270
+ headFeet.push('anapest');
271
+ const after = footFinder(IAMBIC_FOOT_DICT, headMarks, 2, anap + 3, headMarks.length);
272
+ if (after.length === 0) return { footlist: [], scansionMarks: '' };
273
+ headFeet.push(...after.map(f => f.foot));
274
+ }
275
+ }
276
+ }
277
+
278
+ // Scan tail
279
+ if (tailMarks.length > 0) {
280
+ let lastFootStr = '';
281
+ let tailPart = tailMarks;
282
+ if (tailPart.slice(-1) === 'x' && tailPart.length > 2 && tailPart.slice(-3) in IAMBIC_FOOT_DICT) {
283
+ lastFootStr = IAMBIC_FOOT_DICT[tailPart.slice(-3)];
284
+ tailPart = tailPart.slice(0, -3);
285
+ }
286
+ const tf = footFinder(IAMBIC_FOOT_DICT, tailPart, 2, 0, tailPart.length);
287
+ if (tf.length === 0) return { footlist: [], scansionMarks: '' };
288
+ tailFeet.push(...tf.map(f => f.foot));
289
+ if (lastFootStr) tailFeet.push(lastFootStr);
290
+ }
291
+
292
+ const completeList = [...headFeet, ...footlist, ...tailFeet];
293
+ // Promote pyrrhics as in Scandroid’s PromotePyrrhics
294
+ for (let i = 0; i < completeList.length; i++) {
295
+ if (completeList[i] === 'pyrrhic') {
296
+ if (i < completeList.length - 1 && completeList[i + 1] === 'spondee') {
297
+ // nothing
298
+ } else {
299
+ completeList[i] = '(iamb)';
300
+ }
301
+ }
302
+ }
303
+
304
+ const scansion = completeList.join('|');
305
+ return { footlist: completeList, scansionMarks: scansion };
306
+ }
307
+
308
+ // ─── Anapestic scanning ──────────────────────────────────────────
309
+
310
+ export function scandroidAnapestic(
311
+ marks: string,
312
+ numFeet?: number
313
+ ): { footlist: string[]; scansionMarks: string } {
314
+ let remaining = marks;
315
+ if (!numFeet) {
316
+ const [q, r] = [Math.floor(remaining.length / 3), remaining.length % 3];
317
+ let need = q;
318
+ if (r > 0) need++;
319
+ need = Math.max(need, altLineLenCalc(remaining));
320
+ numFeet = need;
321
+ }
322
+
323
+ // Handle terminal slack (promotions etc.)
324
+ if (remaining.slice(-2) === 'xx') remaining = remaining.slice(0, -1) + '%';
325
+ let lastFootStr = '';
326
+ if (remaining && remaining.slice(-1) === 'x') {
327
+ let tailStart = remaining.lastIndexOf('/');
328
+ tailStart = remaining.lastIndexOf('/', tailStart - 1);
329
+ if (tailStart === -1) return { footlist: [], scansionMarks: '' };
330
+ const tail = remaining.slice(tailStart);
331
+ if (tail in ANAPESTIC_FOOT_DICT) {
332
+ lastFootStr = ANAPESTIC_FOOT_DICT[tail];
333
+ remaining = remaining.slice(0, tailStart);
334
+ } else if (tail.length > 1 && tail.slice(1) in ANAPESTIC_FOOT_DICT) {
335
+ lastFootStr = ANAPESTIC_FOOT_DICT[tail.slice(1)];
336
+ remaining = remaining.slice(0, tailStart + 1);
337
+ } else return { footlist: [], scansionMarks: '' };
338
+ }
339
+
340
+ // Promote slack runs (long sequences of unstressed)
341
+ const slackRun = remaining.indexOf('xxxx');
342
+ if (slackRun !== -1) {
343
+ remaining = remaining.slice(0, slackRun + 2) + '%' + remaining.slice(slackRun + 3);
344
+ }
345
+
346
+ const len = remaining.length;
347
+ const footlist: string[] = [];
348
+ if (len === numFeet! * 3) {
349
+ const feet = footFinder(ANAPESTIC_FOOT_DICT, remaining, 3, 0, len);
350
+ if (feet.length === 0) return { footlist: [], scansionMarks: '' };
351
+ footlist.push(...feet.map(f => f.foot));
352
+ } else {
353
+ const needDisyls = (numFeet! * 3) - len;
354
+ if (needDisyls > numFeet!) return { footlist: [], scansionMarks: '' };
355
+ const pattern = '2'.repeat(needDisyls) + '3'.repeat(numFeet! - needDisyls);
356
+ const allPerms = uniquePermutations(pattern);
357
+ let validPattern: string | null = null;
358
+ for (const pat of allPerms) {
359
+ let okay = true;
360
+ let idx = 0;
361
+ for (const d of pat) {
362
+ const stride = parseInt(d);
363
+ idx += stride;
364
+ if (!'/%'.includes(remaining[idx - 1])) {
365
+ okay = false;
366
+ break;
367
+ }
368
+ }
369
+ if (okay) {
370
+ validPattern = pat;
371
+ break;
372
+ }
373
+ }
374
+ if (!validPattern) return { footlist: [], scansionMarks: '' };
375
+ let pos = 0;
376
+ for (const d of validPattern) {
377
+ const stride = parseInt(d);
378
+ const chunk = remaining.slice(pos, pos + stride);
379
+ if (chunk in ANAPESTIC_FOOT_DICT) {
380
+ footlist.push(ANAPESTIC_FOOT_DICT[chunk]);
381
+ pos += stride;
382
+ } else return { footlist: [], scansionMarks: '' };
383
+ }
384
+ }
385
+
386
+ if (lastFootStr) footlist.push(lastFootStr);
387
+ const scansion = footlist.join('|');
388
+ return { footlist, scansionMarks: scansion };
389
+ }
390
+
391
+ // ─── Helper: unique permutations of a string ────────────────────
392
+
393
+ function uniquePermutations(s: string): string[] {
394
+ if (s.length <= 1 || s.length > 9) return [s];
395
+ const results: string[] = [];
396
+ function permute(prefix: string, rest: string) {
397
+ if (rest.length === 0) results.push(prefix);
398
+ const seen = new Set<string>();
399
+ for (let i = 0; i < rest.length; i++) {
400
+ if (seen.has(rest[i])) continue;
401
+ seen.add(rest[i]);
402
+ permute(prefix + rest[i], rest.slice(0, i) + rest.slice(i + 1));
403
+ }
404
+ }
405
+ permute('', s);
406
+ return results;
407
+ }
408
+
409
+ // ─── Public API: convert our relative stress to Scandroid marks ──
410
+
411
+ export function stressToMarks(stressArray: StressLevel[]): string {
412
+ return stressArray.map(s => (s === 's' ? STRESS : SLACK)).join('');
413
+ }
414
+
415
+ export function marksToFeetString(footlist: string[]): string {
416
+ return footlist.join(' | ');
417
+ }
418
+
419
+ // ─── Convenience: produce a ScansionResult from footlist ─────────
420
+
421
+ export function scansionResultFromFootlist(
422
+ footlist: string[],
423
+ meter: MetreName,
424
+ complexity?: number
425
+ ): ScansionResult {
426
+ return {
427
+ meter,
428
+ scansion: marksToFeetString(footlist),
429
+ certainty: 0, // not computed
430
+ weightScore: 0,
431
+ maxPossibleWeight: 0,
432
+ algorithm: 'Scandroid',
433
+ };
434
+ }