Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/utils/can-join.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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];
Expand Down
10 changes: 9 additions & 1 deletion src/utils/clusters.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
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([]);
});

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);
Expand Down
2 changes: 2 additions & 0 deletions src/utils/clusters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const INITIAL_CLUSTERS: ReadonlySet<string> = new Set([
'str',
Comment thread
bukinoshita marked this conversation as resolved.
]);

export const FINAL_CLUSTERS: ReadonlySet<string> = new Set(['lk', 'rk', 'st', 'nt', 'ld', 'mb']);

export const MEDIAL_CLUSTERS: ReadonlySet<string> = new Set([
'bl',
'br',
Expand Down
39 changes: 38 additions & 1 deletion src/utils/generator.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
6 changes: 3 additions & 3 deletions src/utils/generator.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Expand Down
23 changes: 23 additions & 0 deletions src/utils/is-vowel.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
1 change: 1 addition & 0 deletions src/utils/is-vowel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isVowel = (char: string): boolean => char.length === 1 && 'aeiou'.includes(char);
3 changes: 1 addition & 2 deletions src/utils/join-segments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { canJoin } from './can-join';
import { isVowel } from './is-vowel';
import { pick } from './pick';
import type { RandomGenerator } from './rng';

Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions src/utils/pad.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
40 changes: 30 additions & 10 deletions src/utils/pad.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<readonly [string, RandomGenerator]>(
([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);
38 changes: 38 additions & 0 deletions src/utils/pattern-length-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
33 changes: 33 additions & 0 deletions src/utils/pattern-length-stats.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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<Record<Pattern, LengthStats>> = Object.freeze(
Object.fromEntries(PATTERNS.map((pattern) => [pattern, statsFor(pattern)])),
) as Readonly<Record<Pattern, LengthStats>>;
6 changes: 2 additions & 4 deletions src/utils/pattern.spec.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand Down Expand Up @@ -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([]);
Expand Down
Loading
Loading