puzlink 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.
- package/LICENSE.md +21 -0
- package/README.md +35 -0
- package/dist/data/answerLengths.d.ts +10 -0
- package/dist/data/answerLengths.d.ts.map +1 -0
- package/dist/data/answerLengths.js +63 -0
- package/dist/data/answerLengths.js.map +1 -0
- package/dist/data/categories/compass.d.ts +3 -0
- package/dist/data/categories/compass.d.ts.map +1 -0
- package/dist/data/categories/compass.js +11 -0
- package/dist/data/categories/compass.js.map +1 -0
- package/dist/data/categories/countryAlpha2.d.ts +3 -0
- package/dist/data/categories/countryAlpha2.d.ts.map +1 -0
- package/dist/data/categories/countryAlpha2.js +252 -0
- package/dist/data/categories/countryAlpha2.js.map +1 -0
- package/dist/data/categories/countryAlpha3.d.ts +3 -0
- package/dist/data/categories/countryAlpha3.d.ts.map +1 -0
- package/dist/data/categories/countryAlpha3.js +252 -0
- package/dist/data/categories/countryAlpha3.js.map +1 -0
- package/dist/data/categories/daysOfTheWeek.d.ts +3 -0
- package/dist/data/categories/daysOfTheWeek.d.ts.map +1 -0
- package/dist/data/categories/daysOfTheWeek.js +10 -0
- package/dist/data/categories/daysOfTheWeek.js.map +1 -0
- package/dist/data/categories/elementSymbols.d.ts +3 -0
- package/dist/data/categories/elementSymbols.d.ts.map +1 -0
- package/dist/data/categories/elementSymbols.js +121 -0
- package/dist/data/categories/elementSymbols.js.map +1 -0
- package/dist/data/categories/greekLetters.d.ts +3 -0
- package/dist/data/categories/greekLetters.d.ts.map +1 -0
- package/dist/data/categories/greekLetters.js +27 -0
- package/dist/data/categories/greekLetters.js.map +1 -0
- package/dist/data/categories/months.d.ts +3 -0
- package/dist/data/categories/months.d.ts.map +1 -0
- package/dist/data/categories/months.js +15 -0
- package/dist/data/categories/months.js.map +1 -0
- package/dist/data/categories/natoAlphabet.d.ts +3 -0
- package/dist/data/categories/natoAlphabet.d.ts.map +1 -0
- package/dist/data/categories/natoAlphabet.js +29 -0
- package/dist/data/categories/natoAlphabet.js.map +1 -0
- package/dist/data/categories/numbers.d.ts +3 -0
- package/dist/data/categories/numbers.d.ts.map +1 -0
- package/dist/data/categories/numbers.js +16 -0
- package/dist/data/categories/numbers.js.map +1 -0
- package/dist/data/categories/romanNumerals.d.ts +3 -0
- package/dist/data/categories/romanNumerals.d.ts.map +1 -0
- package/dist/data/categories/romanNumerals.js +134 -0
- package/dist/data/categories/romanNumerals.js.map +1 -0
- package/dist/data/categories/solfege.d.ts +3 -0
- package/dist/data/categories/solfege.d.ts.map +1 -0
- package/dist/data/categories/solfege.js +11 -0
- package/dist/data/categories/solfege.js.map +1 -0
- package/dist/data/categories/usStateAbbreviations.d.ts +3 -0
- package/dist/data/categories/usStateAbbreviations.d.ts.map +1 -0
- package/dist/data/categories/usStateAbbreviations.js +53 -0
- package/dist/data/categories/usStateAbbreviations.js.map +1 -0
- package/dist/data/categories.d.ts +10 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +31 -0
- package/dist/data/categories.js.map +1 -0
- package/dist/data/knownLogProbs.d.ts +6 -0
- package/dist/data/knownLogProbs.d.ts.map +1 -0
- package/dist/data/knownLogProbs.js +2975 -0
- package/dist/data/knownLogProbs.js.map +1 -0
- package/dist/data/morse.d.ts +2 -0
- package/dist/data/morse.d.ts.map +1 -0
- package/dist/data/morse.js +29 -0
- package/dist/data/morse.js.map +1 -0
- package/dist/data/scrabble.d.ts +2 -0
- package/dist/data/scrabble.d.ts.map +1 -0
- package/dist/data/scrabble.js +29 -0
- package/dist/data/scrabble.js.map +1 -0
- package/dist/features/index.d.ts +32 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +79 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/letterCount.d.ts +7 -0
- package/dist/features/letterCount.d.ts.map +1 -0
- package/dist/features/letterCount.js +121 -0
- package/dist/features/letterCount.js.map +1 -0
- package/dist/features/letterSequence.d.ts +7 -0
- package/dist/features/letterSequence.d.ts.map +1 -0
- package/dist/features/letterSequence.js +155 -0
- package/dist/features/letterSequence.js.map +1 -0
- package/dist/features/logProbCache.d.ts +16 -0
- package/dist/features/logProbCache.d.ts.map +1 -0
- package/dist/features/logProbCache.js +36 -0
- package/dist/features/logProbCache.js.map +1 -0
- package/dist/features/other.d.ts +4 -0
- package/dist/features/other.d.ts.map +1 -0
- package/dist/features/other.js +190 -0
- package/dist/features/other.js.map +1 -0
- package/dist/features/substring.d.ts +3 -0
- package/dist/features/substring.d.ts.map +1 -0
- package/dist/features/substring.js +146 -0
- package/dist/features/substring.js.map +1 -0
- package/dist/features/wordplay.d.ts +7 -0
- package/dist/features/wordplay.d.ts.map +1 -0
- package/dist/features/wordplay.js +387 -0
- package/dist/features/wordplay.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/affixDistribution.d.ts +26 -0
- package/dist/lib/affixDistribution.d.ts.map +1 -0
- package/dist/lib/affixDistribution.js +105 -0
- package/dist/lib/affixDistribution.js.map +1 -0
- package/dist/lib/counter.d.ts +23 -0
- package/dist/lib/counter.d.ts.map +1 -0
- package/dist/lib/counter.js +55 -0
- package/dist/lib/counter.js.map +1 -0
- package/dist/lib/distribution.d.ts +40 -0
- package/dist/lib/distribution.d.ts.map +1 -0
- package/dist/lib/distribution.js +176 -0
- package/dist/lib/distribution.js.map +1 -0
- package/dist/lib/lengthDistribution.d.ts +30 -0
- package/dist/lib/lengthDistribution.d.ts.map +1 -0
- package/dist/lib/lengthDistribution.js +137 -0
- package/dist/lib/lengthDistribution.js.map +1 -0
- package/dist/lib/letterBitset.d.ts +49 -0
- package/dist/lib/letterBitset.d.ts.map +1 -0
- package/dist/lib/letterBitset.js +101 -0
- package/dist/lib/letterBitset.js.map +1 -0
- package/dist/lib/letterDistribution.d.ts +60 -0
- package/dist/lib/letterDistribution.d.ts.map +1 -0
- package/dist/lib/letterDistribution.js +230 -0
- package/dist/lib/letterDistribution.js.map +1 -0
- package/dist/lib/letterIndices.d.ts +13 -0
- package/dist/lib/letterIndices.d.ts.map +1 -0
- package/dist/lib/letterIndices.js +41 -0
- package/dist/lib/letterIndices.js.map +1 -0
- package/dist/lib/logCounter.d.ts +23 -0
- package/dist/lib/logCounter.d.ts.map +1 -0
- package/dist/lib/logCounter.js +49 -0
- package/dist/lib/logCounter.js.map +1 -0
- package/dist/lib/logNum.d.ts +36 -0
- package/dist/lib/logNum.d.ts.map +1 -0
- package/dist/lib/logNum.js +193 -0
- package/dist/lib/logNum.js.map +1 -0
- package/dist/lib/memoize.d.ts +5 -0
- package/dist/lib/memoize.d.ts.map +1 -0
- package/dist/lib/memoize.js +104 -0
- package/dist/lib/memoize.js.map +1 -0
- package/dist/lib/util.d.ts +30 -0
- package/dist/lib/util.d.ts.map +1 -0
- package/dist/lib/util.js +111 -0
- package/dist/lib/util.js.map +1 -0
- package/dist/lib/wordlist.d.ts +66 -0
- package/dist/lib/wordlist.d.ts.map +1 -0
- package/dist/lib/wordlist.js +166 -0
- package/dist/lib/wordlist.js.map +1 -0
- package/dist/linkers/index.d.ts +34 -0
- package/dist/linkers/index.d.ts.map +1 -0
- package/dist/linkers/index.js +25 -0
- package/dist/linkers/index.js.map +1 -0
- package/dist/linkers/indexing.d.ts +5 -0
- package/dist/linkers/indexing.d.ts.map +1 -0
- package/dist/linkers/indexing.js +152 -0
- package/dist/linkers/indexing.js.map +1 -0
- package/dist/linkers/length.d.ts +5 -0
- package/dist/linkers/length.d.ts.map +1 -0
- package/dist/linkers/length.js +101 -0
- package/dist/linkers/length.js.map +1 -0
- package/dist/linkers/letterDistribution.d.ts +4 -0
- package/dist/linkers/letterDistribution.d.ts.map +1 -0
- package/dist/linkers/letterDistribution.js +46 -0
- package/dist/linkers/letterDistribution.js.map +1 -0
- package/dist/linkers/other.d.ts +5 -0
- package/dist/linkers/other.d.ts.map +1 -0
- package/dist/linkers/other.js +90 -0
- package/dist/linkers/other.js.map +1 -0
- package/dist/parse.d.ts +8 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +23 -0
- package/dist/parse.js.map +1 -0
- package/dist/puzlink.d.ts +84 -0
- package/dist/puzlink.d.ts.map +1 -0
- package/dist/puzlink.js +59 -0
- package/dist/puzlink.js.map +1 -0
- package/package.json +57 -0
- package/src/data/answerLengths.ts +63 -0
- package/src/data/categories/README.md +3 -0
- package/src/data/categories/compass.ts +1 -0
- package/src/data/categories/countryAlpha2.ts +251 -0
- package/src/data/categories/countryAlpha3.ts +251 -0
- package/src/data/categories/daysOfTheWeek.ts +1 -0
- package/src/data/categories/elementSymbols.ts +120 -0
- package/src/data/categories/greekLetters.ts +26 -0
- package/src/data/categories/months.ts +14 -0
- package/src/data/categories/natoAlphabet.ts +28 -0
- package/src/data/categories/numbers.ts +15 -0
- package/src/data/categories/romanNumerals.ts +133 -0
- package/src/data/categories/solfege.ts +1 -0
- package/src/data/categories/txt/compass.txt +8 -0
- package/src/data/categories/txt/daysOfTheWeek.txt +7 -0
- package/src/data/categories/txt/elementSymbols.txt +118 -0
- package/src/data/categories/txt/greekLetters.txt +24 -0
- package/src/data/categories/txt/months.txt +12 -0
- package/src/data/categories/txt/natoAlphabet.txt +26 -0
- package/src/data/categories/txt/numbers.txt +13 -0
- package/src/data/categories/txt/solfege.txt +8 -0
- package/src/data/categories/txt/usStateAbbreviations.txt +50 -0
- package/src/data/categories/usStateAbbreviations.ts +52 -0
- package/src/data/categories.ts +42 -0
- package/src/data/knownLogProbs.ts +2992 -0
- package/src/data/morse.ts +28 -0
- package/src/data/scrabble.ts +28 -0
- package/src/features/index.ts +120 -0
- package/src/features/letterCount.ts +174 -0
- package/src/features/letterSequence.ts +222 -0
- package/src/features/logProbCache.ts +48 -0
- package/src/features/other.ts +214 -0
- package/src/features/substring.ts +173 -0
- package/src/features/wordplay.ts +428 -0
- package/src/index.ts +3 -0
- package/src/lib/affixDistribution.ts +70 -0
- package/src/lib/counter.ts +71 -0
- package/src/lib/distribution.ts +162 -0
- package/src/lib/lengthDistribution.ts +108 -0
- package/src/lib/letterBitset.ts +123 -0
- package/src/lib/letterDistribution.ts +236 -0
- package/src/lib/letterIndices.ts +51 -0
- package/src/lib/logCounter.ts +74 -0
- package/src/lib/logNum.ts +193 -0
- package/src/lib/memoize.ts +136 -0
- package/src/lib/testUtils.ts +1 -0
- package/src/lib/util.ts +150 -0
- package/src/lib/wordlist.ts +162 -0
- package/src/linkers/index.ts +56 -0
- package/src/linkers/indexing.ts +194 -0
- package/src/linkers/length.ts +122 -0
- package/src/linkers/other.ts +117 -0
- package/src/parse.ts +20 -0
- package/src/puzlink.ts +141 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Cromulence, loadWordlist, logProbToZipf } from "cromulence";
|
|
2
|
+
import { PrefixDistribution, SuffixDistribution } from "./affixDistribution.js";
|
|
3
|
+
import { LetterBitsets } from "./letterBitset.js";
|
|
4
|
+
import { LetterDistribution } from "./letterDistribution.js";
|
|
5
|
+
import { LogNum } from "./logNum.js";
|
|
6
|
+
import { memoize } from "./memoize.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Info about the words in a wordlist.
|
|
10
|
+
*
|
|
11
|
+
* We assume (as in `cromulence`) that words appearing in puzzles are
|
|
12
|
+
* distributed via Zipf frequency.
|
|
13
|
+
*/
|
|
14
|
+
export class Wordlist {
|
|
15
|
+
private cromulence: Cromulence;
|
|
16
|
+
/** A map from letter bitsets to words with that bitset. */
|
|
17
|
+
private bitsets: LetterBitsets;
|
|
18
|
+
/** The letter distribution of the wordlist. */
|
|
19
|
+
letters: LetterDistribution;
|
|
20
|
+
/** The prefix distribution of the wordlist. */
|
|
21
|
+
prefixes: PrefixDistribution;
|
|
22
|
+
/** The suffix distribution of the wordlist. */
|
|
23
|
+
suffixes: SuffixDistribution;
|
|
24
|
+
|
|
25
|
+
constructor(wordlist: Record<string, number>) {
|
|
26
|
+
this.cromulence = new Cromulence(wordlist);
|
|
27
|
+
this.bitsets = new LetterBitsets(Object.keys(wordlist));
|
|
28
|
+
this.letters = new LetterDistribution(Object.keys(wordlist));
|
|
29
|
+
this.prefixes = new PrefixDistribution(Object.keys(wordlist));
|
|
30
|
+
this.suffixes = new SuffixDistribution(Object.keys(wordlist));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async download(): Promise<Wordlist> {
|
|
34
|
+
return new Wordlist(await loadWordlist());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* For testing purposes: create a wordlist from an array of words, each
|
|
39
|
+
* equiprobable.
|
|
40
|
+
*/
|
|
41
|
+
static from(words: string[]): Wordlist {
|
|
42
|
+
const logFrac = LogNum.fromFraction(1, words.length);
|
|
43
|
+
const wordlist: Record<string, LogNum> = {};
|
|
44
|
+
for (const word of words) {
|
|
45
|
+
wordlist[word] = wordlist[word] ? wordlist[word].add(logFrac) : logFrac;
|
|
46
|
+
}
|
|
47
|
+
return new Wordlist(
|
|
48
|
+
Object.fromEntries(
|
|
49
|
+
Object.entries(wordlist).map(([word, logProb]) => [
|
|
50
|
+
word,
|
|
51
|
+
logProbToZipf(logProb.toLog()),
|
|
52
|
+
]),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Apply a reducer to each word in the wordlist. */
|
|
58
|
+
reduce<T>(initial: T, reducer: (acc: T, slug: string, zipf: number) => T): T {
|
|
59
|
+
let result = initial;
|
|
60
|
+
for (const slug in this.cromulence.wordlist) {
|
|
61
|
+
const zipf = this.cromulence.wordlist[slug]!;
|
|
62
|
+
result = reducer(result, slug, zipf);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the log prob that a wordlist item, drawn uniformly at random,
|
|
69
|
+
* satisfies the given property. This is NOT weighted by zipf!
|
|
70
|
+
*/
|
|
71
|
+
logProb(property: (slug: string) => boolean): LogNum {
|
|
72
|
+
// TODO: maybe tweak the weights here to get better results?
|
|
73
|
+
// if we do so, do the same thing in letterDistribution
|
|
74
|
+
const count = this.reduce(0, (acc, slug) => acc + (property(slug) ? 1 : 0));
|
|
75
|
+
const total = Object.keys(this.cromulence.wordlist).length;
|
|
76
|
+
return LogNum.fromFraction(count, total);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returns true if the given slug is in the wordlist. */
|
|
80
|
+
isWord(
|
|
81
|
+
slug: string,
|
|
82
|
+
{ threshold = 0 }: { threshold?: number } = {},
|
|
83
|
+
): boolean {
|
|
84
|
+
return (this.cromulence.wordlist[slug] ?? -1000) > threshold;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns true if any of the given slugs are in the wordlist. */
|
|
88
|
+
hasWord(
|
|
89
|
+
slugs: string[],
|
|
90
|
+
{ threshold = 0 }: { threshold?: number } = {},
|
|
91
|
+
): boolean {
|
|
92
|
+
return slugs.some((slug) => this.isWord(slug, { threshold }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Filters for slugs in the wordlist, sorted from most common to least. */
|
|
96
|
+
filterWords(
|
|
97
|
+
slugs: string[],
|
|
98
|
+
{ threshold = 0 }: { threshold?: number } = {},
|
|
99
|
+
): string[] {
|
|
100
|
+
return slugs
|
|
101
|
+
.map((slug) => [slug, this.cromulence.wordlist[slug]] as const)
|
|
102
|
+
.filter(
|
|
103
|
+
(t): t is [string, number] => t[1] !== undefined && t[1] > threshold,
|
|
104
|
+
)
|
|
105
|
+
.sort((a, b) => b[1] - a[1])
|
|
106
|
+
.map((t) => t[0]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Filters for slugs in the wordlist under the given getter, sorted from most
|
|
111
|
+
* common to least.
|
|
112
|
+
*/
|
|
113
|
+
filterWordsUnder<T>(
|
|
114
|
+
items: T[],
|
|
115
|
+
getSlug: (item: T) => string,
|
|
116
|
+
{ threshold = 0 }: { threshold?: number } = {},
|
|
117
|
+
): T[] {
|
|
118
|
+
return items
|
|
119
|
+
.map((item) => [item, this.cromulence.wordlist[getSlug(item)]] as const)
|
|
120
|
+
.filter((t): t is [T, number] => t[1] !== undefined && t[1] > threshold)
|
|
121
|
+
.sort((a, b) => b[1] - a[1])
|
|
122
|
+
.map((t) => t[0]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Returns true if the given phrase is in the wordlist. */
|
|
126
|
+
isPhrase(phrase: string): boolean {
|
|
127
|
+
return this.cromulence.cromulence(phrase) > 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Returns the anagrams of a given slug, sorted from most common to least. */
|
|
131
|
+
anagrams(
|
|
132
|
+
slug: string,
|
|
133
|
+
{
|
|
134
|
+
loose = false,
|
|
135
|
+
threshold = 0,
|
|
136
|
+
}: { loose?: boolean; threshold?: number } = {},
|
|
137
|
+
): string[] {
|
|
138
|
+
return this.bitsets
|
|
139
|
+
.get(slug)
|
|
140
|
+
.filter((word) => loose || word !== slug)
|
|
141
|
+
.map((word) => [word, this.cromulence.wordlist[word]!] as const)
|
|
142
|
+
.filter((t) => t[1] > threshold)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.map((t) => t[0]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Prob that, for two words, the first has a suffix equal to the second's
|
|
149
|
+
* prefix, of the given length.
|
|
150
|
+
*/
|
|
151
|
+
@memoize()
|
|
152
|
+
probSharedAffix(length: number) {
|
|
153
|
+
const prefixes = this.prefixes.get(length);
|
|
154
|
+
const suffixes = this.suffixes.get(length);
|
|
155
|
+
|
|
156
|
+
return LogNum.sum(
|
|
157
|
+
Array.from(prefixes.entries(), ([prefix, prefixProb]) => {
|
|
158
|
+
return prefixProb.mul(suffixes.get(prefix));
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { answerLengthLogProbs } from "../data/answerLengths.js";
|
|
2
|
+
import { featureLinkers } from "../features/index.js";
|
|
3
|
+
import type { LinkOptions } from "../index.js";
|
|
4
|
+
import { LengthDistribution } from "../lib/lengthDistribution.js";
|
|
5
|
+
import { LogNum } from "../lib/logNum.js";
|
|
6
|
+
import type { Wordlist } from "../lib/wordlist.js";
|
|
7
|
+
import { indexingLinker } from "./indexing.js";
|
|
8
|
+
import { lengthLinker } from "./length.js";
|
|
9
|
+
import { otherLinker } from "./other.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A PartialLink is the subset of Link that a Linker needs to return. We do
|
|
13
|
+
* some processing before it ends up being a Link. See Link for full details.
|
|
14
|
+
*/
|
|
15
|
+
export type PartialLink = {
|
|
16
|
+
/** A human-readable link name; defaults to the name of the linker. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Log prob we'd expect to see this link.
|
|
20
|
+
*
|
|
21
|
+
* Note that this should describe the log prob of the *name* of the link.
|
|
22
|
+
* A link with name "two distinct length values" should have the log prob
|
|
23
|
+
* that the words have *any two* distinct lengths, and the description
|
|
24
|
+
* should report more specifically what those lengths are.
|
|
25
|
+
*/
|
|
26
|
+
logProb: LogNum;
|
|
27
|
+
/** Any extra info to include in the link. Can be blank. */
|
|
28
|
+
description: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A Linker is a function that takes a list of slugs and returns PartialLinks.
|
|
33
|
+
*/
|
|
34
|
+
export type Linker = {
|
|
35
|
+
name: string;
|
|
36
|
+
eval: (slugs: string[], options: Required<LinkOptions>) => PartialLink[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** All linkers. */
|
|
40
|
+
export function allLinkers(wordlist: Wordlist): Linker[] {
|
|
41
|
+
const lengthDist = LengthDistribution.from(answerLengthLogProbs);
|
|
42
|
+
return [
|
|
43
|
+
...featureLinkers(wordlist),
|
|
44
|
+
indexingLinker(wordlist),
|
|
45
|
+
lengthLinker(lengthDist),
|
|
46
|
+
otherLinker(wordlist),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** For testing purposes. */
|
|
51
|
+
export const testLinkOptions: Required<LinkOptions> = {
|
|
52
|
+
lazy: false,
|
|
53
|
+
limit: Infinity,
|
|
54
|
+
minFeatureRatio: 0,
|
|
55
|
+
ordered: true,
|
|
56
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { LetterIndices } from "../lib/letterIndices.js";
|
|
2
|
+
import { getArithmeticSequenceInfo, interval, ordinal } from "../lib/util.js";
|
|
3
|
+
import type { Wordlist } from "../lib/wordlist.js";
|
|
4
|
+
import type { Linker, PartialLink } from "./index.js";
|
|
5
|
+
|
|
6
|
+
type Indexed = {
|
|
7
|
+
/** "the 2nd letters", "the diagonal", "the antidiagonal" */
|
|
8
|
+
indexText: string;
|
|
9
|
+
/** Result of indexing the kind of thing in `indexText`. */
|
|
10
|
+
indexed: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function* indicesFor(slugs: string[], ordered?: boolean): Generator<Indexed> {
|
|
14
|
+
for (const i of interval(-10, 9)) {
|
|
15
|
+
const indexText =
|
|
16
|
+
i >= 0 ? `${ordinal(i + 1)} letters` : `${ordinal(i)} letters`;
|
|
17
|
+
const indexed = slugs.map((slug) => slug.at(i) ?? null);
|
|
18
|
+
if (indexed.every((c) => c !== null)) {
|
|
19
|
+
yield { indexText, indexed };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (ordered) {
|
|
23
|
+
const indexed = slugs.map((slug, i) => slug.at(i) ?? null);
|
|
24
|
+
if (indexed.every((c) => c !== null)) {
|
|
25
|
+
yield { indexText: "diagonal", indexed };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (ordered && slugs.every((slug) => slug.length >= slugs.length)) {
|
|
29
|
+
yield {
|
|
30
|
+
indexText: "antidiagonal",
|
|
31
|
+
indexed: slugs.map((slug, i) => slug.at(slugs.length - i - 1)!),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Props = Indexed & {
|
|
37
|
+
slugs: string[];
|
|
38
|
+
wordlist: Wordlist;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function allEqual({ indexText, indexed, wordlist }: Props): PartialLink | null {
|
|
42
|
+
if (new Set(indexed).size !== 1) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
name: `${indexText} are equal`,
|
|
47
|
+
logProb: wordlist.letters.probEqual(indexed.length),
|
|
48
|
+
description: [`${indexText} are ${indexed[0]!}`],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function almostEqual({
|
|
53
|
+
indexText,
|
|
54
|
+
indexed,
|
|
55
|
+
slugs,
|
|
56
|
+
wordlist,
|
|
57
|
+
}: Props): PartialLink | null {
|
|
58
|
+
const indexedSet = new Set(indexed);
|
|
59
|
+
if (indexedSet.size !== 2) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
let [a, b] = indexedSet;
|
|
63
|
+
let aSlugIndices = indexed.flatMap((w, i) => (w === a ? [i] : []));
|
|
64
|
+
let bSlugIndices = indexed.flatMap((w, i) => (w === b ? [i] : []));
|
|
65
|
+
if (aSlugIndices.length > bSlugIndices.length) {
|
|
66
|
+
[aSlugIndices, bSlugIndices] = [bSlugIndices, aSlugIndices];
|
|
67
|
+
[a, b] = [b, a];
|
|
68
|
+
}
|
|
69
|
+
if (aSlugIndices.length !== 1) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const [aSlugIndex] = aSlugIndices as [number];
|
|
73
|
+
return {
|
|
74
|
+
name: `${indexText} are almost equal`,
|
|
75
|
+
logProb: wordlist.letters.probAlmostEqual(indexed.length),
|
|
76
|
+
description: [
|
|
77
|
+
`'${slugs[aSlugIndex]!}' ${indexText} is '${a!}'`,
|
|
78
|
+
`others ${indexText} are '${b!}'`,
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onlyTwo({
|
|
84
|
+
indexText,
|
|
85
|
+
indexed,
|
|
86
|
+
slugs,
|
|
87
|
+
wordlist,
|
|
88
|
+
}: Props): PartialLink | null {
|
|
89
|
+
const indexedSet = new Set(indexed);
|
|
90
|
+
if (indexedSet.size !== 2) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const [a, b] = indexedSet;
|
|
94
|
+
const aSlugIndices = indexed.flatMap((w, i) => (w === a ? [i] : []));
|
|
95
|
+
const bSlugIndices = indexed.flatMap((w, i) => (w === b ? [i] : []));
|
|
96
|
+
return {
|
|
97
|
+
name: `${indexText} have only two values`,
|
|
98
|
+
logProb: wordlist.letters.probTwoDistinct(indexed.length),
|
|
99
|
+
description: [
|
|
100
|
+
`${aSlugIndices.map((i) => `'${slugs[i]!}'`).join(", ")} ${indexText} are '${a!}'`,
|
|
101
|
+
`${bSlugIndices.map((i) => `'${slugs[i]!}'`).join(", ")} ${indexText} are '${b!}'`,
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function word({ indexText, indexed, wordlist }: Props): PartialLink | null {
|
|
107
|
+
// TODO: isPhrase?
|
|
108
|
+
if (!wordlist.isWord(indexed.join(""))) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name: `${indexText} are a word`,
|
|
113
|
+
logProb: wordlist.letters.probWord(indexed.length),
|
|
114
|
+
description: [`${indexText} are '${indexed.join("")}'`],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function anagram({ indexText, indexed, wordlist }: Props): PartialLink | null {
|
|
119
|
+
const anagrams = wordlist.anagrams(indexed.join(""));
|
|
120
|
+
if (anagrams.length === 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
name: `${indexText} anagram to a word`,
|
|
125
|
+
logProb: wordlist.letters.probAnagram(indexed.length),
|
|
126
|
+
description: [`${indexText} anagram to ${anagrams.join(", ")}`],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function consecutive({
|
|
131
|
+
indexText,
|
|
132
|
+
indexed,
|
|
133
|
+
wordlist,
|
|
134
|
+
}: Props): PartialLink | null {
|
|
135
|
+
const sorted = indexed
|
|
136
|
+
.slice()
|
|
137
|
+
.map((w) => w.charCodeAt(0))
|
|
138
|
+
.sort((a, b) => a - b);
|
|
139
|
+
if (getArithmeticSequenceInfo(sorted)?.step !== 1) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
name: `${indexText} are consecutive`,
|
|
144
|
+
logProb: wordlist.letters.probConsecutive(indexed.length),
|
|
145
|
+
description: [`${indexText} are ${indexed.join(", ")}`],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function paired({
|
|
150
|
+
indexText,
|
|
151
|
+
indexed,
|
|
152
|
+
slugs,
|
|
153
|
+
wordlist,
|
|
154
|
+
}: Props): PartialLink | null {
|
|
155
|
+
const byIndexed = LetterIndices.from(indexed.join(""));
|
|
156
|
+
const countSet = byIndexed.countSet();
|
|
157
|
+
if (countSet.size !== 1 || Array.from(countSet)[0] !== 2) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
name: `${indexText} can be paired`,
|
|
162
|
+
logProb: wordlist.letters.probPaired(indexed.length),
|
|
163
|
+
description: Array.from(byIndexed.entries(), ([letter, slugIndices]) => {
|
|
164
|
+
return `${slugIndices.map((i) => `'${slugs[i]!}'`).join(", ")} ${indexText} are '${letter}'`;
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Links of the form "the nth indices are..." */
|
|
170
|
+
export function indexingLinker(wordlist: Wordlist): Linker {
|
|
171
|
+
return {
|
|
172
|
+
name: "indexing linker",
|
|
173
|
+
eval: (slugs, { ordered }) => {
|
|
174
|
+
const indices = Array.from(indicesFor(slugs, ordered));
|
|
175
|
+
return indices.flatMap(({ indexText, indexed }) => {
|
|
176
|
+
const props = {
|
|
177
|
+
indexText,
|
|
178
|
+
indexed,
|
|
179
|
+
slugs,
|
|
180
|
+
wordlist,
|
|
181
|
+
};
|
|
182
|
+
return [
|
|
183
|
+
allEqual(props),
|
|
184
|
+
almostEqual(props),
|
|
185
|
+
onlyTwo(props),
|
|
186
|
+
ordered ? word(props) : null,
|
|
187
|
+
anagram(props),
|
|
188
|
+
consecutive(props),
|
|
189
|
+
paired(props),
|
|
190
|
+
].filter((l): l is PartialLink => l !== null);
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { LengthDistribution } from "../lib/lengthDistribution.js";
|
|
2
|
+
import { getArithmeticSequenceInfo } from "../lib/util.js";
|
|
3
|
+
import type { Linker, PartialLink } from "./index.js";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
distribution: LengthDistribution;
|
|
7
|
+
lengthSet: Set<number>;
|
|
8
|
+
lengths: number[];
|
|
9
|
+
slugs: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function allEqual({ distribution, lengthSet }: Props): PartialLink | null {
|
|
13
|
+
if (lengthSet.size !== 1) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const [length] = Array.from(lengthSet) as [number];
|
|
17
|
+
return {
|
|
18
|
+
name: "all lengths equal",
|
|
19
|
+
logProb: distribution.probEqual(lengthSet.size),
|
|
20
|
+
description: [`all lengths are ${length.toString()}`],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function onlyTwo({
|
|
25
|
+
distribution,
|
|
26
|
+
lengthSet,
|
|
27
|
+
slugs,
|
|
28
|
+
}: Props): PartialLink | null {
|
|
29
|
+
if (lengthSet.size !== 2) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const [a, b] = Array.from(lengthSet) as [number, number];
|
|
33
|
+
const aLength = slugs.filter((w) => w.length === a);
|
|
34
|
+
const bLength = slugs.filter((w) => w.length === b);
|
|
35
|
+
const aLogProb = distribution.probEqual(aLength.length);
|
|
36
|
+
const bLogProb = distribution.probEqual(bLength.length);
|
|
37
|
+
return {
|
|
38
|
+
name: "only two lengths",
|
|
39
|
+
logProb: aLogProb.mul(bLogProb),
|
|
40
|
+
description: [
|
|
41
|
+
`length ${a.toString()}: ${aLength.join(", ")}`,
|
|
42
|
+
`length ${b.toString()}: ${bLength.join(", ")}`,
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function equalMod2({ distribution, lengths }: Props): PartialLink | null {
|
|
48
|
+
if (new Set(lengths.map((l) => l % 2)).size !== 1) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const parity = lengths[0]! % 2;
|
|
52
|
+
return {
|
|
53
|
+
name: `all lengths are ${parity === 0 ? "even" : "odd"}`,
|
|
54
|
+
logProb: distribution.probEqualMod2(lengths.length),
|
|
55
|
+
description: [`all lengths are ${parity === 0 ? "even" : "odd"}`],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function equalMod3({ distribution, lengths }: Props): PartialLink | null {
|
|
60
|
+
if (new Set(lengths.map((l) => l % 3)).size !== 1) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
name: "all lengths are equal mod 3",
|
|
65
|
+
logProb: distribution.probEqualMod3(lengths.length),
|
|
66
|
+
description: [`all lengths are equal mod 3`],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function consecutive({ distribution, lengths }: Props): PartialLink | null {
|
|
71
|
+
if (getArithmeticSequenceInfo(lengths)?.step !== 1) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
name: "lengths are consecutive",
|
|
76
|
+
logProb: distribution.probConsecutive(lengths.length),
|
|
77
|
+
description: [`lengths are ${lengths.join(", ")}`],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function paired({ distribution, slugs }: Props): PartialLink | null {
|
|
82
|
+
const byLength = new Map<number, string[]>();
|
|
83
|
+
for (const slug of slugs) {
|
|
84
|
+
if (!byLength.has(slug.length)) {
|
|
85
|
+
byLength.set(slug.length, []);
|
|
86
|
+
}
|
|
87
|
+
byLength.get(slug.length)!.push(slug);
|
|
88
|
+
}
|
|
89
|
+
const lengthCounter = new Set(
|
|
90
|
+
Array.from(byLength.values(), (slugs) => slugs.length),
|
|
91
|
+
);
|
|
92
|
+
if (lengthCounter.size === 1 && Array.from(lengthCounter)[0] === 2) {
|
|
93
|
+
return {
|
|
94
|
+
name: "lengths can be paired",
|
|
95
|
+
logProb: distribution.probPaired(slugs.length),
|
|
96
|
+
description: Array.from(byLength.entries(), ([, slugs]) => {
|
|
97
|
+
return slugs.join(" and ");
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Length-based linker. */
|
|
105
|
+
export function lengthLinker(distribution: LengthDistribution): Linker {
|
|
106
|
+
return {
|
|
107
|
+
name: "slug lengths",
|
|
108
|
+
eval: (slugs) => {
|
|
109
|
+
const lengths = slugs.map((w) => w.length).sort();
|
|
110
|
+
const lengthSet = new Set(lengths);
|
|
111
|
+
const props = { distribution, lengthSet, lengths, slugs };
|
|
112
|
+
return [
|
|
113
|
+
allEqual(props),
|
|
114
|
+
onlyTwo(props),
|
|
115
|
+
equalMod2(props),
|
|
116
|
+
equalMod3(props),
|
|
117
|
+
consecutive(props),
|
|
118
|
+
paired(props),
|
|
119
|
+
].filter((l): l is PartialLink => l !== null);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { VOWELS } from "../lib/letterDistribution.js";
|
|
2
|
+
import { LogNum } from "../lib/logNum.js";
|
|
3
|
+
import { enumerate } from "../lib/util.js";
|
|
4
|
+
import type { Wordlist } from "../lib/wordlist.js";
|
|
5
|
+
import type { Linker, PartialLink } from "./index.js";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
slugs: string[];
|
|
9
|
+
wordlist: Wordlist;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function unusualLetters({ slugs, wordlist }: Props): PartialLink | null {
|
|
13
|
+
const all = Array.from(slugs.join(""));
|
|
14
|
+
const { high, low } = wordlist.letters.outliers(all);
|
|
15
|
+
if (high.length > 0 || low.length > 0) {
|
|
16
|
+
return {
|
|
17
|
+
name: "unusual letter distribution",
|
|
18
|
+
logProb: wordlist.letters.probUnordered(all),
|
|
19
|
+
description: [
|
|
20
|
+
...(high.length > 0 ? [`over-represented: ${high.join(", ")}`] : []),
|
|
21
|
+
...(low.length > 0 ? [`under-represented: ${low.join(", ")}`] : []),
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function equalVowelPattern(
|
|
29
|
+
start: boolean,
|
|
30
|
+
{ slugs, wordlist }: Props,
|
|
31
|
+
): PartialLink | null {
|
|
32
|
+
const minLength = Math.min(...slugs.map((s) => s.length));
|
|
33
|
+
const shortest = slugs.find((s) => s.length === minLength)!;
|
|
34
|
+
const pattern = Array.from(shortest, (letter) =>
|
|
35
|
+
VOWELS.includes(letter) ? "V" : "C",
|
|
36
|
+
).join("");
|
|
37
|
+
const matchesVowelPattern = (other: string) => {
|
|
38
|
+
const offset = start ? 0 : other.length - minLength;
|
|
39
|
+
for (const [i, vc] of enumerate(pattern)) {
|
|
40
|
+
if (VOWELS.includes(other[offset + i]!) !== (vc === "V")) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
if (!slugs.every(matchesVowelPattern)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name: `${start ? "start" : "end"} with the same vowel-consonant pattern`,
|
|
51
|
+
logProb: wordlist[start ? "prefixes" : "suffixes"].probEqualVowelPattern(
|
|
52
|
+
slugs.length,
|
|
53
|
+
minLength,
|
|
54
|
+
),
|
|
55
|
+
description: [`all ${start ? "start" : "end"} with ${pattern}`],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sharedAffixes({ slugs, wordlist }: Props): PartialLink | null {
|
|
60
|
+
const shared = new Map<string, { prefixOf: string; length: number }>();
|
|
61
|
+
for (const suffixOf of slugs) {
|
|
62
|
+
for (const prefixOf of slugs) {
|
|
63
|
+
if (prefixOf === suffixOf) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let length = Math.min(prefixOf.length, suffixOf.length);
|
|
67
|
+
for (; length > 1; length--) {
|
|
68
|
+
if (prefixOf.slice(0, length) !== suffixOf.slice(-length)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const currentBest = shared.get(suffixOf)?.length ?? 0;
|
|
72
|
+
if (length > currentBest) {
|
|
73
|
+
shared.set(suffixOf, { prefixOf, length });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (shared.size <= 1) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: "multiple shared suffixes and prefixes",
|
|
85
|
+
// This is an underestimate because it assumes independence.
|
|
86
|
+
logProb: LogNum.prod(
|
|
87
|
+
Array.from(shared.values(), ({ length }) => {
|
|
88
|
+
return wordlist.probSharedAffix(length);
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
description: Array.from(
|
|
92
|
+
shared.entries(),
|
|
93
|
+
([suffixOf, { prefixOf, length }]) =>
|
|
94
|
+
`${suffixOf.slice(0, -length)}${suffixOf.slice(-length).toUpperCase()} ${prefixOf
|
|
95
|
+
.slice(0, length)
|
|
96
|
+
.toUpperCase()}${prefixOf.slice(length)}`,
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// TODO at each index, there's exactly one repeated letter
|
|
102
|
+
|
|
103
|
+
/** Other links. */
|
|
104
|
+
export function otherLinker(wordlist: Wordlist): Linker {
|
|
105
|
+
return {
|
|
106
|
+
name: "other links",
|
|
107
|
+
eval: (slugs) => {
|
|
108
|
+
const props = { slugs, wordlist };
|
|
109
|
+
return [
|
|
110
|
+
unusualLetters(props),
|
|
111
|
+
equalVowelPattern(true, props),
|
|
112
|
+
equalVowelPattern(false, props),
|
|
113
|
+
sharedAffixes(props),
|
|
114
|
+
].filter((l): l is PartialLink => l !== null);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an input to a list of slugs.
|
|
3
|
+
*
|
|
4
|
+
* If the input has newlines, we split by newlines. Otherwise, if commas
|
|
5
|
+
* exist, we split by commas. Otherwise, we split by spaces.
|
|
6
|
+
*/
|
|
7
|
+
export function parse(words: string | readonly string[]): string[] {
|
|
8
|
+
if (typeof words === "string") {
|
|
9
|
+
if (words.includes("\n")) {
|
|
10
|
+
words = words.split("\n");
|
|
11
|
+
} else if (words.includes(",")) {
|
|
12
|
+
words = words.split(",");
|
|
13
|
+
} else {
|
|
14
|
+
words = words.split(" ");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return words
|
|
18
|
+
.map((w) => w.toLowerCase().replace(/[^a-z]/g, ""))
|
|
19
|
+
.filter((w) => w.length > 0);
|
|
20
|
+
}
|