diff --git a/src/utils/can-join.ts b/src/utils/can-join.ts index d1bfe44..d45be01 100644 --- a/src/utils/can-join.ts +++ b/src/utils/can-join.ts @@ -1,17 +1,15 @@ import { INITIAL_CLUSTERS, MEDIAL_CLUSTERS } from './clusters'; +import { isVowel } from './is-vowel'; +import { trailingConsonantRun } from './trailing-consonant-run'; const BANNED_VOWEL_PAIRS: ReadonlySet = new Set(['aa', 'ii', 'uu']); -const isVowel = (char: string): boolean => 'aeiou'.includes(char); - const leadingVowelRun = (s: string): string => s.match(/^[aeiou]+/)?.[0] ?? ''; const trailingVowelRun = (s: string): string => s.match(/[aeiou]+$/)?.[0] ?? ''; const leadingConsonantRun = (s: string): string => s.match(/^[^aeiou]+/)?.[0] ?? ''; -const trailingConsonantRun = (s: string): string => s.match(/[^aeiou]+$/)?.[0] ?? ''; - export const canJoin = (left: string, right: string): boolean => { if (left === '' || right === '') return true; const lastChar = left[left.length - 1]; diff --git a/src/utils/clusters.spec.ts b/src/utils/clusters.spec.ts index 3a29ab7..b46243d 100644 --- a/src/utils/clusters.spec.ts +++ b/src/utils/clusters.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { INITIAL_CLUSTERS, MEDIAL_CLUSTERS } from './clusters'; +import { FINAL_CLUSTERS, INITIAL_CLUSTERS, MEDIAL_CLUSTERS } from './clusters'; describe('cluster whitelists', () => { it.each([ ['INITIAL_CLUSTERS', INITIAL_CLUSTERS], + ['FINAL_CLUSTERS', FINAL_CLUSTERS], ['MEDIAL_CLUSTERS', MEDIAL_CLUSTERS], ])('%s contains only lowercase consonant sequences', (_, clusters) => { expect([...clusters].filter((cluster) => !/^[b-df-hj-np-tv-z]+$/.test(cluster))).toEqual([]); @@ -11,11 +12,18 @@ describe('cluster whitelists', () => { it.each([ ['INITIAL_CLUSTERS', INITIAL_CLUSTERS], + ['FINAL_CLUSTERS', FINAL_CLUSTERS], ['MEDIAL_CLUSTERS', MEDIAL_CLUSTERS], ])('%s contains only clusters of two or three consonants', (_, clusters) => { expect([...clusters].filter((cluster) => cluster.length < 2 || cluster.length > 3)).toEqual([]); }); + it('FINAL_CLUSTERS excludes clusters English never ends words with', () => { + expect(FINAL_CLUSTERS.has('dr')).toBe(false); + expect(FINAL_CLUSTERS.has('gt')).toBe(false); + expect(FINAL_CLUSTERS.has('zgr')).toBe(false); + }); + it('INITIAL_CLUSTERS excludes clusters English never starts words with', () => { expect(INITIAL_CLUSTERS.has('tk')).toBe(false); expect(INITIAL_CLUSTERS.has('zf')).toBe(false); diff --git a/src/utils/clusters.ts b/src/utils/clusters.ts index 80adf01..44751a8 100644 --- a/src/utils/clusters.ts +++ b/src/utils/clusters.ts @@ -39,6 +39,8 @@ export const INITIAL_CLUSTERS: ReadonlySet = new Set([ 'str', ]); +export const FINAL_CLUSTERS: ReadonlySet = new Set(['lk', 'rk', 'st', 'nt', 'ld', 'mb']); + export const MEDIAL_CLUSTERS: ReadonlySet = new Set([ 'bl', 'br', diff --git a/src/utils/generator.spec.ts b/src/utils/generator.spec.ts index 4d80613..6181341 100644 --- a/src/utils/generator.spec.ts +++ b/src/utils/generator.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { INITIAL_CLUSTERS } from './clusters'; +import { FINAL_CLUSTERS, INITIAL_CLUSTERS } from './clusters'; import { generateName } from './generator'; +import { buildPattern } from './pattern'; import { createRng } from './rng'; +import { selectPattern } from './select-pattern'; +import { normalize } from './string'; describe('generator', () => { describe('generateName', () => { @@ -168,5 +171,39 @@ describe('generator', () => { expect(names.size).toBeGreaterThan(9500); }); + + it('never ends a name with an illegal cluster or naked q', () => { + const config = { minLength: 6, maxLength: 10 }; + const offenders = Array.from({ length: 20000 }, (_, i) => { + const [name] = generateName(createRng(i), config); + return name; + }).filter((name) => { + const run = name.match(/[^aeiou]+$/)?.[0] ?? ''; + return (run.length >= 2 && !FINAL_CLUSTERS.has(run)) || /[jqwhv]$/.test(name); + }); + + expect(offenders).toEqual([]); + }); + + it('rarely picks patterns that overshoot maxLength', () => { + const config = { minLength: 6, maxLength: 10 }; + const overshoots = Array.from({ length: 20000 }, (_, i) => { + const [pattern, rng1] = selectPattern(config.minLength, config.maxLength, createRng(i)); + const [base] = buildPattern(pattern, rng1); + return normalize(base).length; + }).filter((length) => length > config.maxLength); + + expect(overshoots.length / 20000).toBeLessThan(0.1); + }); + + it('does not pile words up at exactly maxLength', () => { + const config = { minLength: 6, maxLength: 10 }; + const atMax = Array.from({ length: 20000 }, (_, i) => { + const [name] = generateName(createRng(i), config); + return name; + }).filter((name) => name.length === config.maxLength); + + expect(atMax.length / 20000).toBeLessThan(0.3); + }); }); }); diff --git a/src/utils/generator.ts b/src/utils/generator.ts index 3d51167..5a74755 100644 --- a/src/utils/generator.ts +++ b/src/utils/generator.ts @@ -1,8 +1,8 @@ import { padToLength } from './pad'; -import { buildPattern, PATTERNS, WEIGHTS } from './pattern'; +import { buildPattern } from './pattern'; import type { RandomGenerator } from './rng'; +import { selectPattern } from './select-pattern'; import { normalize } from './string'; -import { weightedPick } from './weighted-pick'; interface Config { readonly minLength: number; @@ -13,7 +13,7 @@ export function generateName( rng: RandomGenerator, cfg: Config, ): readonly [string, RandomGenerator] { - const [pattern, rng1] = weightedPick(PATTERNS, WEIGHTS, rng); + const [pattern, rng1] = selectPattern(cfg.minLength, cfg.maxLength, rng); const [base, rng2] = buildPattern(pattern, rng1); const baseWord = padToLength(normalize(base), cfg.minLength, cfg.maxLength, rng2); diff --git a/src/utils/is-vowel.spec.ts b/src/utils/is-vowel.spec.ts new file mode 100644 index 0000000..215c442 --- /dev/null +++ b/src/utils/is-vowel.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { isVowel } from './is-vowel'; + +describe('is-vowel', () => { + describe('isVowel', () => { + it('returns true for every vowel', () => { + expect(['a', 'e', 'i', 'o', 'u'].filter(isVowel)).toEqual(['a', 'e', 'i', 'o', 'u']); + }); + + it('returns false for consonants', () => { + expect(['b', 'q', 'w', 'y', 'z'].filter(isVowel)).toEqual([]); + }); + + it('returns false for the empty string', () => { + expect(isVowel('')).toBe(false); + }); + + it('returns false for multi-character strings', () => { + expect(isVowel('ae')).toBe(false); + expect(isVowel('eio')).toBe(false); + }); + }); +}); diff --git a/src/utils/is-vowel.ts b/src/utils/is-vowel.ts new file mode 100644 index 0000000..4677474 --- /dev/null +++ b/src/utils/is-vowel.ts @@ -0,0 +1 @@ +export const isVowel = (char: string): boolean => char.length === 1 && 'aeiou'.includes(char); diff --git a/src/utils/join-segments.ts b/src/utils/join-segments.ts index 6198da9..24cb691 100644 --- a/src/utils/join-segments.ts +++ b/src/utils/join-segments.ts @@ -1,4 +1,5 @@ import { canJoin } from './can-join'; +import { isVowel } from './is-vowel'; import { pick } from './pick'; import type { RandomGenerator } from './rng'; @@ -23,8 +24,6 @@ const QU_LINKING_BRIDGES = Object.freeze([ 'or', ]); -const isVowel = (char: string): boolean => 'aeiou'.includes(char); - const linkPool = (word: string, segment: string): readonly string[] => { const firstChar = segment[0]; if (word.endsWith('qu')) return isVowel(firstChar) ? QU_LINKING_BRIDGES : QU_LINKING_VOWELS; diff --git a/src/utils/pad.spec.ts b/src/utils/pad.spec.ts index 8531f62..01bd5dc 100644 --- a/src/utils/pad.spec.ts +++ b/src/utils/pad.spec.ts @@ -23,13 +23,23 @@ describe('pad', () => { expect(result.length).toBe(8); }); - it('truncates strings longer than max length', () => { + it('trims strings longer than max length at a legal boundary', () => { const input = 'verylongstring'; const rng = createRng(777); const result = padToLength(input, 5, 10, rng); + expect(result.length).toBeLessThanOrEqual(10); + expect(result).toBe('verylon'); + }); + + it('pads back up when boundary trimming drops below min', () => { + const input = 'velaquendrio'; + const rng = createRng(777); + const result = padToLength(input, 10, 10, rng); + expect(result.length).toBe(10); - expect(result).toBe('verylongst'); + expect(result.startsWith('velaquen')).toBe(true); + expect(result.endsWith('q')).toBe(false); }); it('respects exact minimum length', () => { diff --git a/src/utils/pad.ts b/src/utils/pad.ts index bf6c5a2..2a054be 100644 --- a/src/utils/pad.ts +++ b/src/utils/pad.ts @@ -1,5 +1,8 @@ +import { canJoin } from './can-join'; +import { isVowel } from './is-vowel'; import { pick } from './pick'; import type { RandomGenerator } from './rng'; +import { trimToFit } from './trim-to-fit'; const PADDING_SYLLABLES = Object.freeze([ 'a', @@ -53,13 +56,30 @@ const PADDING_SYLLABLES = Object.freeze([ 'xe', ]); -export function padToLength(s: string, min: number, max: number, rng: RandomGenerator): string { - let current = s; - let state = rng; - while (current.length < min) { - const [syll, next] = pick(PADDING_SYLLABLES)(state); - current = current + syll; - state = next; - } - return current.length > max ? current.slice(0, max) : current; -} +const PADDING_CONSONANTS = Object.freeze(['n', 'r', 'l', 's']); + +const paddingPool = (word: string, budget: number): readonly string[] => { + const candidates = isVowel(word[word.length - 1] ?? '') + ? [...PADDING_SYLLABLES, ...PADDING_CONSONANTS] + : PADDING_SYLLABLES; + return candidates.filter((entry) => entry.length <= budget && canJoin(word, entry)); +}; + +const PADDING_STEP_LIMIT = 1024; + +const appendPadding = (word: string, min: number, max: number, rng: RandomGenerator): string => { + if (word.length >= min) return word; + const steps = Math.min(PADDING_STEP_LIMIT, min - word.length); + const [padded, next] = Array.from({ length: steps }).reduce( + ([current, state]) => { + if (current.length >= min) return [current, state] as const; + const [syllable, nextState] = pick(paddingPool(current, max - current.length))(state); + return [current + syllable, nextState] as const; + }, + [word, rng] as const, + ); + return appendPadding(padded, min, max, next); +}; + +export const padToLength = (s: string, min: number, max: number, rng: RandomGenerator): string => + appendPadding(trimToFit(s, max), min, max, rng); diff --git a/src/utils/pattern-length-stats.spec.ts b/src/utils/pattern-length-stats.spec.ts new file mode 100644 index 0000000..d45ac80 --- /dev/null +++ b/src/utils/pattern-length-stats.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { PATTERNS } from './pattern'; +import { PATTERN_LENGTH_STATS } from './pattern-length-stats'; + +describe('pattern-length-stats', () => { + describe('PATTERN_LENGTH_STATS', () => { + it('has stats for every pattern', () => { + expect(Object.keys(PATTERN_LENGTH_STATS).toSorted()).toEqual([...PATTERNS].toSorted()); + }); + + it('has positive means for every pattern', () => { + const offenders = PATTERNS.filter((pattern) => PATTERN_LENGTH_STATS[pattern].mean <= 0); + expect(offenders).toEqual([]); + }); + + it('orders means by pattern complexity', () => { + expect(PATTERN_LENGTH_STATS.CV.mean).toBeLessThan(PATTERN_LENGTH_STATS.CVCV.mean); + expect(PATTERN_LENGTH_STATS.CVCV.mean).toBeLessThan(PATTERN_LENGTH_STATS.CVCVCV.mean); + }); + + it('has non-decreasing cumulative distributions from 0 to 1', () => { + const offenders = PATTERNS.filter((pattern) => { + const { cumulative } = PATTERN_LENGTH_STATS[pattern]; + const monotone = cumulative.every( + (value, index) => index === 0 || value >= cumulative[index - 1], + ); + return !monotone || cumulative[0] !== 0 || cumulative[cumulative.length - 1] !== 1; + }); + + expect(offenders).toEqual([]); + }); + + it('reports short patterns as fitting under small bounds', () => { + expect(PATTERN_LENGTH_STATS.CV.cumulative[8]).toBe(1); + expect(PATTERN_LENGTH_STATS.CVCVCV.cumulative[8]).toBeLessThan(0.5); + }); + }); +}); diff --git a/src/utils/pattern-length-stats.ts b/src/utils/pattern-length-stats.ts new file mode 100644 index 0000000..98ce92a --- /dev/null +++ b/src/utils/pattern-length-stats.ts @@ -0,0 +1,33 @@ +import { buildPattern, PATTERNS, type Pattern } from './pattern'; +import { createRng } from './rng'; + +const SAMPLE_COUNT = 512; +const MAX_TRACKED_LENGTH = 40; + +interface LengthStats { + readonly mean: number; + readonly cumulative: readonly number[]; +} + +const sampleLengths = (pattern: Pattern): readonly number[] => + Array.from({ length: SAMPLE_COUNT }, (_, index) => { + const [word] = buildPattern(pattern, createRng(index + 1)); + return word.length; + }); + +const statsFor = (pattern: Pattern): LengthStats => { + const lengths = sampleLengths(pattern); + return Object.freeze({ + mean: lengths.reduce((sum, length) => sum + length, 0) / lengths.length, + cumulative: Object.freeze( + Array.from( + { length: MAX_TRACKED_LENGTH + 1 }, + (_, bound) => lengths.filter((length) => length <= bound).length / lengths.length, + ), + ), + }); +}; + +export const PATTERN_LENGTH_STATS: Readonly> = Object.freeze( + Object.fromEntries(PATTERNS.map((pattern) => [pattern, statsFor(pattern)])), +) as Readonly>; diff --git a/src/utils/pattern.spec.ts b/src/utils/pattern.spec.ts index beb6b17..aa817bc 100644 --- a/src/utils/pattern.spec.ts +++ b/src/utils/pattern.spec.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { INITIAL_CLUSTERS } from './clusters'; +import { FINAL_CLUSTERS, INITIAL_CLUSTERS } from './clusters'; import { buildPattern, PATTERNS, type Pattern, WEIGHTS } from './pattern'; import { createRng } from './rng'; -const LEGAL_FINAL_CLUSTERS = new Set(['lk', 'rk', 'st', 'nt', 'ld', 'mb']); - const sampleWords = (pattern: Pattern, count: number): readonly string[] => Array.from({ length: count }, (_, i) => { const [word] = buildPattern(pattern, createRng(i)); @@ -283,7 +281,7 @@ describe('pattern', () => { it('never ends a word with an illegal consonant cluster', () => { const offenders = allSampledWords(500).filter((word) => { const run = word.match(/[^aeiou]+$/)?.[0] ?? ''; - return run.length >= 2 && !LEGAL_FINAL_CLUSTERS.has(run); + return run.length >= 2 && !FINAL_CLUSTERS.has(run); }); expect(offenders).toEqual([]); diff --git a/src/utils/select-pattern.spec.ts b/src/utils/select-pattern.spec.ts new file mode 100644 index 0000000..1a8b9a0 --- /dev/null +++ b/src/utils/select-pattern.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { PATTERNS, type Pattern } from './pattern'; +import { PATTERN_LENGTH_STATS } from './pattern-length-stats'; +import { createRng } from './rng'; +import { selectPattern } from './select-pattern'; + +const samplePatterns = (min: number, max: number, count: number): readonly Pattern[] => + Array.from({ length: count }, (_, index) => { + const [pattern] = selectPattern(min, max, createRng(index + 1)); + return pattern; + }); + +describe('select-pattern', () => { + describe('selectPattern', () => { + it('returns a known pattern', () => { + const [pattern] = selectPattern(6, 10, createRng(42)); + expect(PATTERNS).toContain(pattern); + }); + + it('returns a new RNG state', () => { + const rng = createRng(100); + const [, nextRng] = selectPattern(6, 10, rng); + + expect(nextRng).toBeDefined(); + expect(nextRng).not.toBe(rng); + }); + + it('is deterministic with same RNG seed', () => { + const [pattern1] = selectPattern(6, 10, createRng(777)); + const [pattern2] = selectPattern(6, 10, createRng(777)); + + expect(pattern1).toBe(pattern2); + }); + + it('never picks patterns that overshoot the range on average', () => { + const offenders = samplePatterns(6, 10, 2000).filter( + (pattern) => PATTERN_LENGTH_STATS[pattern].mean > 11, + ); + + expect(offenders).toEqual([]); + }); + + it('never picks long patterns for short ranges', () => { + const offenders = samplePatterns(4, 6, 2000).filter( + (pattern) => PATTERN_LENGTH_STATS[pattern].mean > 7, + ); + + expect(offenders).toEqual([]); + }); + + it('picks long patterns for long ranges', () => { + const offenders = samplePatterns(10, 15, 2000).filter( + (pattern) => PATTERN_LENGTH_STATS[pattern].mean < 9, + ); + + expect(offenders).toEqual([]); + }); + + it('offers variety within the default range', () => { + const distinct = new Set(samplePatterns(6, 10, 2000)); + expect(distinct.size).toBeGreaterThanOrEqual(5); + }); + + it('falls back to all patterns when no expected length can fit', () => { + const [pattern] = selectPattern(1, 1, createRng(42)); + expect(PATTERNS).toContain(pattern); + }); + + it('stays deterministic after the candidate cache evicts entries', () => { + const [before] = selectPattern(6, 10, createRng(42)); + const flood = Array.from({ length: 300 }, (_, index) => { + const [pattern] = selectPattern(index + 1, index + 2, createRng(index)); + return pattern; + }); + const [after] = selectPattern(6, 10, createRng(42)); + + expect(flood.every((pattern) => PATTERNS.includes(pattern))).toBe(true); + expect(after).toBe(before); + }); + }); +}); diff --git a/src/utils/select-pattern.ts b/src/utils/select-pattern.ts new file mode 100644 index 0000000..e43a88d --- /dev/null +++ b/src/utils/select-pattern.ts @@ -0,0 +1,70 @@ +import { PATTERNS, type Pattern, WEIGHTS } from './pattern'; +import { PATTERN_LENGTH_STATS } from './pattern-length-stats'; +import type { RandomGenerator } from './rng'; +import { weightedPick } from './weighted-pick'; + +const NEARNESS_TOLERANCE = 1; +const RICHNESS_EXPONENT = 2.5; +const OVERSHOOT_PENALTY_EXPONENT = 10; + +interface Candidates { + readonly patterns: readonly Pattern[]; + readonly weights: readonly number[]; +} + +const distanceToRange = (value: number, lower: number, upper: number): number => + value < lower ? lower - value : value > upper ? value - upper : 0; + +const probabilityAtMost = (pattern: Pattern, bound: number): number => { + const { cumulative } = PATTERN_LENGTH_STATS[pattern]; + return bound < 0 ? 0 : cumulative[Math.min(bound, cumulative.length - 1)]; +}; + +const fitWeight = (pattern: Pattern, index: number, minLength: number, maxLength: number): number => + WEIGHTS[index] * + Math.max(1, PATTERN_LENGTH_STATS[pattern].mean - minLength + 1) ** RICHNESS_EXPONENT * + probabilityAtMost(pattern, maxLength) ** OVERSHOOT_PENALTY_EXPONENT; + +const computeCandidates = (minLength: number, maxLength: number): Candidates => { + const distances = PATTERNS.map((pattern) => + distanceToRange(PATTERN_LENGTH_STATS[pattern].mean, minLength, maxLength), + ); + const nearest = Math.min(...distances); + const eligible = PATTERNS.map((pattern, index) => ({ + pattern, + weight: fitWeight(pattern, index, minLength, maxLength), + distance: distances[index], + })).filter(({ distance, weight }) => distance <= nearest + NEARNESS_TOLERANCE && weight > 0); + return eligible.length > 0 + ? { + patterns: eligible.map(({ pattern }) => pattern), + weights: eligible.map(({ weight }) => weight), + } + : { patterns: PATTERNS, weights: WEIGHTS }; +}; + +const CACHE_LIMIT = 128; + +const candidateCache = new Map(); + +const candidatesFor = (minLength: number, maxLength: number): Candidates => { + const key = `${minLength}:${maxLength}`; + const cached = candidateCache.get(key); + if (cached) return cached; + const computed = computeCandidates(minLength, maxLength); + const oldestKey = candidateCache.keys().next().value; + if (candidateCache.size >= CACHE_LIMIT && oldestKey !== undefined) { + candidateCache.delete(oldestKey); + } + candidateCache.set(key, computed); + return computed; +}; + +export const selectPattern = ( + minLength: number, + maxLength: number, + rng: RandomGenerator, +): readonly [Pattern, RandomGenerator] => { + const { patterns, weights } = candidatesFor(minLength, maxLength); + return weightedPick(patterns, weights, rng); +}; diff --git a/src/utils/trailing-consonant-run.ts b/src/utils/trailing-consonant-run.ts new file mode 100644 index 0000000..192779f --- /dev/null +++ b/src/utils/trailing-consonant-run.ts @@ -0,0 +1 @@ +export const trailingConsonantRun = (s: string): string => s.match(/[^aeiou]+$/)?.[0] ?? ''; diff --git a/src/utils/trim-to-fit.spec.ts b/src/utils/trim-to-fit.spec.ts new file mode 100644 index 0000000..3d697cd --- /dev/null +++ b/src/utils/trim-to-fit.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { FINAL_CLUSTERS } from './clusters'; +import { trimToFit } from './trim-to-fit'; + +const hasIllegalEnding = (word: string): boolean => { + if (word.endsWith('qu')) return true; + if (/[jqwhv]$/.test(word)) return true; + const run = word.match(/[^aeiou]+$/)?.[0] ?? ''; + return run.length >= 2 && !FINAL_CLUSTERS.has(run); +}; + +describe('trim-to-fit', () => { + describe('trimToFit', () => { + it('returns the word unchanged when within max length', () => { + expect(trimToFit('velora', 10)).toBe('velora'); + }); + + it('returns the word unchanged when exactly at max length', () => { + expect(trimToFit('velorakine', 10)).toBe('velorakine'); + }); + + it('keeps a blunt cut when the prefix already ends legally', () => { + expect(trimToFit('kalimentosa', 10)).toBe('kalimentos'); + }); + + it('backs off to the last legal boundary instead of cutting blindly', () => { + expect(trimToFit('verylongstring', 10)).toBe('verylon'); + }); + + it('backs off past a consonant cluster the cut would split', () => { + expect(trimToFit('eukronoitrenier', 10)).toBe('eukronoit'); + }); + + it('backs off past an illegal final letter', () => { + expect(trimToFit('caloontuevro', 10)).toBe('caloontue'); + }); + + it('never leaves a naked q when the cut lands inside qu', () => { + expect(trimToFit('lunaipixaquene', 10)).toBe('lunaipixa'); + }); + + it('never ends the result with qu', () => { + expect(trimToFit('velaquene', 6)).toBe('vela'); + }); + + it('produces only legal endings across trimmed lengths', () => { + const word = 'garaihondrelakine'; + const offenders = Array.from({ length: word.length - 3 }, (_, index) => + trimToFit(word, index + 3), + ).filter(hasIllegalEnding); + + expect(offenders).toEqual([]); + }); + + it('returns an empty string when no prefix ends legally', () => { + expect(trimToFit('whanere', 2)).toBe(''); + }); + + it('never returns an illegal ending for any max length', () => { + const words = ['whanere', 'valonteska', 'quenarivo', 'jorvexulda']; + const offenders = words.flatMap((word) => + Array.from({ length: word.length - 1 }, (_, index) => trimToFit(word, index + 1)).filter( + (trimmed) => trimmed !== '' && hasIllegalEnding(trimmed), + ), + ); + + expect(offenders).toEqual([]); + }); + }); +}); diff --git a/src/utils/trim-to-fit.ts b/src/utils/trim-to-fit.ts new file mode 100644 index 0000000..89c46b6 --- /dev/null +++ b/src/utils/trim-to-fit.ts @@ -0,0 +1,19 @@ +import { FINAL_CLUSTERS } from './clusters'; +import { trailingConsonantRun } from './trailing-consonant-run'; + +const ILLEGAL_FINAL_LETTERS: ReadonlySet = new Set(['j', 'q', 'w', 'h', 'v']); + +const hasLegalEnding = (word: string): boolean => { + if (word.endsWith('qu')) return false; + if (ILLEGAL_FINAL_LETTERS.has(word[word.length - 1])) return false; + const run = trailingConsonantRun(word); + return run.length <= 1 || FINAL_CLUSTERS.has(run); +}; + +export const trimToFit = (word: string, maxLength: number): string => { + if (word.length <= maxLength) return word; + const prefixes = Array.from({ length: maxLength }, (_, index) => + word.slice(0, maxLength - index), + ); + return prefixes.find(hasLegalEnding) ?? ''; +};