Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-09
*
* This file tests the construction of the helper test-fixture function in
* buildAlphabeticClusteredFixture.ts.
*/

import { assert } from "chai";
Comment thread
jahorton marked this conversation as resolved.

import { SearchQuotientNode } from "@keymanapp/lm-worker/test-index";

import { constituentPaths } from "./constituentPaths.js";
import { quotientPathHasInputs } from "./quotientPathHasInputs.js";
import { buildAlphabeticClusterFixtures } from "./buildAlphabeticClusteredFixture.js";

describe('buildAlphabeticClusteredFixture() fixture', () => {
it('constructs paths properly', () => {
const { clusters, paths, distributions } = buildAlphabeticClusterFixtures();
assert.equal(clusters.cluster_k5c6.inputCount, 5);

const allPaths = Object.values(paths).map(set => Object.values(set)).flat() as SearchQuotientNode[];
const allDists = Object.values(distributions).map(set => Object.values(set)).flat();
const finalClusterPaths = constituentPaths(clusters.cluster_k5c6) as SearchQuotientNode[][];

allPaths.forEach((spur) => assert.isOk(finalClusterPaths.find(seq => seq.indexOf(spur) > -1)));
allDists.forEach((dist) => assert.isOk(allPaths.find(path => quotientPathHasInputs(path, [dist]))));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-09
*
* This file defines a unit-text fixture designed for testing
* behaviors related to the clustering of search quotient spurs
* based on just substitution/match edges.
Comment on lines +6 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment doesn't seem to match the content of the file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any suggestions for improving this, then? That comment was intended for this file's header comment; it's not a copy-paste artifact.

Copy link
Copy Markdown
Contributor

@ermshiperete ermshiperete Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this file contains helper methods, not the test fixture. As it is the comment would fit on buildAlphabeticClusterFixture.tests.ts IMO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that method is the test fixture - or rather, it constructs it anew on each call. It requires live construction, like the buildCantLinearFixture method.

*/

import { LexicalModelTypes } from '@keymanapp/common-types';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';

import {
models,
LegacyQuotientSpur,
SearchQuotientCluster,
LegacyQuotientRoot
} from '@keymanapp/lm-worker/test-index';

import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;

const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));

/**
* Builds a fixture for use in unit-testing against diverging and re-converging
* routes through the modeled correction-search graph and its SearchQuotientNode
* representation.
* @returns
*/
export const buildAlphabeticClusterFixtures = () => {
const rootPath = new LegacyQuotientRoot(testModel);

// consonant-cluster 1, insert 1, delete 0
const distrib_c1_i1d0: Distribution<Transform> = [
{ sample: { insert: 'b', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.3 }, // most likely for id 11
{ sample: { insert: 'c', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.2 },
{ sample: { insert: 'd', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 },
];

// consonant-cluster 1, insert 2, delete 0
const distrib_c1_i2d0: Distribution<Transform> = [
{ sample: { insert: 'fg', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.16 },
{ sample: { insert: 'hj', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.14 },
{ sample: { insert: 'kl', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 },
];

// keystrokes 1, codepoints 1, total inserts 1, delete 0
const path_k1c1_i1d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i1d0, distrib_c1_i1d0[0]);
// keystrokes 1, codepoints 2, total inserts 2, delete 0
const path_k1c2_i2d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i2d0, distrib_c1_i1d0[0]);

// Second input

const distrib_v1_i1d0: Distribution<Transform> = [
{ sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.4 }, // most likely for id 12
{ sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.3 },
{ sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
{ sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
{ sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
];

const path_k2c2_i2d0 = new LegacyQuotientSpur(path_k1c1_i1d0, distrib_v1_i1d0, distrib_v1_i1d0[0]);
const path_k2c3_i3d0 = new LegacyQuotientSpur(path_k1c2_i2d0, distrib_v1_i1d0, distrib_v1_i1d0[0]);

// Third input
const distrib_v2_i1d0: Distribution<Transform> = [
{ sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.15 }, // most likely for id 13
{ sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.13 },
{ sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.12 },
{ sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.11 },
{ sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.09 },
]; // 0.60 total

const distrib_v2_i1d1: Distribution<Transform> = [
{ sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
{ sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 },
{ sample: { insert: 'í', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 },
{ sample: { insert: 'ó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 },
{ sample: { insert: 'ú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 },
]; // 0.2 total

const distrib_v2_i2d1: Distribution<Transform> = [
{ sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
{ sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 },
{ sample: { insert: 'íí', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 },
{ sample: { insert: 'óó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 },
{ sample: { insert: 'úú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 },
]; // 0.2 total

const path_k3c2_i3d1 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d1, distrib_v2_i1d0[0]);

const path_k3c3_i3d0 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d0, distrib_v2_i1d0[0]);
const path_k3c3_i4d1a = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i2d1, distrib_v2_i1d0[0]);
const path_k3c3_i4d1b = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d1, distrib_v2_i1d0[0]);

// both are built on path k1c2 (splits at index 1)
const path_k3c4_i4d0 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d0, distrib_v2_i1d0[0]);
const path_k3c4_i5d1 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i2d1, distrib_v2_i1d0[0]);

const cluster_k3c3 = new SearchQuotientCluster([path_k3c3_i3d0, path_k3c3_i4d1a, path_k3c3_i4d1b]);
// both are built on path k1c2.
const cluster_k3c4 = new SearchQuotientCluster([path_k3c4_i4d0, path_k3c4_i5d1]);

// Input 4
const distrib_c2_i1d0: Distribution<Transform> = [
{ sample: { insert: 'n', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.12 },
{ sample: { insert: 'p', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.08 },
];

const distrib_c2_i2d0: Distribution<Transform> = [
{ sample: { insert: 'qr', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.3 }, // most likely for id 14
{ sample: { insert: 'st', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.2 },
{ sample: { insert: 'vw', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.1 }
];

const path_k4c4_i2 = new LegacyQuotientSpur(path_k3c2_i3d1, distrib_c2_i2d0, distrib_c2_i2d0[0]);
const path_k4c4_i1 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i1d0, distrib_c2_i2d0[0]);

const path_k4c5_i2 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i2d0, distrib_c2_i2d0[0]);
const path_k4c5_i1 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i1d0, distrib_c2_i2d0[0]);

const path_k4c6 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i2d0, distrib_c2_i2d0[0]);

const cluster_k4c4 = new SearchQuotientCluster([path_k4c4_i2, path_k4c4_i1]);
const cluster_k4c5 = new SearchQuotientCluster([path_k4c5_i2, path_k4c5_i1]);

// Input 5 (currently used only for merge tests)
const distrib_c3_i2d0: Distribution<Transform> = [
{ sample: { insert: 'xy', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.6 } // most likely for id 15
];
const distrib_c3_i1d0: Distribution<Transform> = [
{ sample: { insert: 'z', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.4 }
];

const path_k5c6_a = new LegacyQuotientSpur(cluster_k4c4, distrib_c3_i2d0, distrib_c3_i2d0[0]);
const path_k5c6_b = new LegacyQuotientSpur(cluster_k4c5, distrib_c3_i1d0, distrib_c3_i2d0[0]);

const cluster_k5c6 = new SearchQuotientCluster([path_k5c6_a, path_k5c6_b]);

return {
distributions: {
1: {
distrib_c1_i1d0,
distrib_c1_i2d0
},
2: {
distrib_v1_i1d0
},
3: {
distrib_v2_i1d0,
distrib_v2_i1d1,
distrib_v2_i2d1
},
4: {
distrib_c2_i1d0,
distrib_c2_i2d0
},
5: {
distrib_c3_i1d0,
distrib_c3_i2d0
}
},
paths: {
0: {
rootPath
},
1: {
path_k1c1_i1d0,
path_k1c2_i2d0,
},
2: {
path_k2c2_i2d0,
path_k2c3_i3d0,
},
3: {
path_k3c2_i3d1,
path_k3c3_i3d0,
path_k3c3_i4d1a,
path_k3c3_i4d1b,
path_k3c4_i4d0,
path_k3c4_i5d1,
},
4: {
path_k4c4_i2,
path_k4c4_i1,
path_k4c5_i2,
path_k4c5_i1,
path_k4c6
},
5: {
path_k5c6_a,
path_k5c6_b
}
},
clusters: {
cluster_k3c3,
cluster_k3c4,
cluster_k4c4,
cluster_k4c5,
cluster_k5c6
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-09
*
* This file defines unit tests for validating a unit-text fixture for behaviors
* related to a simple linear sequence of SearchQuotientSpurs, with no
* convergence or divergence occurring within the fixture.
*/

import { assert } from "chai";
Comment thread
jahorton marked this conversation as resolved.

import { constituentPaths } from "./constituentPaths.js";
import { quotientPathHasInputs } from "./quotientPathHasInputs.js";
import { buildCantLinearFixture } from "./buildCantLinearFixture.js";

describe('buildCantLinearFixture() fixture', () => {
it('constructs paths properly', () => {
const { paths, distributions } = buildCantLinearFixture();
const pathToSplit = paths[4];

assert.equal(pathToSplit.inputCount, 4);
assert.equal(distributions.length, pathToSplit.inputCount);
assert.equal(pathToSplit.codepointLength, 4); // one char per input, no deletions anywhere
// Per assertions documented in the setup above.
assert.deepEqual(pathToSplit.bestExample, distributions.reduce(
(constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}),
{text: '', p: 1})
);
assert.deepEqual(pathToSplit.parents[0].bestExample, distributions.slice(0, pathToSplit.inputCount-1).reduce(
(constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}),
{text: '', p: 1})
);
assert.isTrue(quotientPathHasInputs(pathToSplit, distributions));
assert.equal(constituentPaths(pathToSplit).length, 1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-09
*
* This file defines a unit-text fixture for testing behaviors related to a
* simple linear sequence of SearchQuotientSpurs, with no convergence or
* divergence occurring within the fixture.
*/

import { LegacyQuotientRoot, LegacyQuotientSpur, models } from "@keymanapp/lm-worker/test-index";
Comment thread
jahorton marked this conversation as resolved.
import { jsonFixture } from "@keymanapp/common-test-resources/model-helpers.mjs";

import TrieModel = models.TrieModel;

const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));

/**
* Build a linear fixture that models the word 'cant' and words close to that.
*/
export function buildCantLinearFixture() {
Comment thread
jahorton marked this conversation as resolved.
const rootPath = new LegacyQuotientRoot(testModel);

const distrib1 = [
{ sample: {insert: 'c', deleteLeft: 0, id: 11}, p: 0.5 },
{ sample: {insert: 'r', deleteLeft: 0, id: 11}, p: 0.4 },
{ sample: {insert: 't', deleteLeft: 0, id: 11}, p: 0.1 }
];
const path1 = new LegacyQuotientSpur(rootPath, distrib1, distrib1[0]);

const distrib2 = [
{ sample: {insert: 'a', deleteLeft: 0, id: 12}, p: 0.7 },
{ sample: {insert: 'e', deleteLeft: 0, id: 12}, p: 0.3 }
];
const path2 = new LegacyQuotientSpur(path1, distrib2, distrib2[0]);

const distrib3 = [
{ sample: {insert: 'n', deleteLeft: 0, id: 13}, p: 0.8 },
{ sample: {insert: 'r', deleteLeft: 0, id: 13}, p: 0.2 }
];
const path3 = new LegacyQuotientSpur(path2, distrib3, distrib3[0]);

const distrib4 = [
{ sample: {insert: 't', deleteLeft: 0, id: 14}, p: 1 }
];
const path4 = new LegacyQuotientSpur(path3, distrib4, distrib4[0]);

return {
paths: [null, path1, path2, path3, path4],
distributions: [distrib1, distrib2, distrib3, distrib4]
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-10
*
* This file adds unit tests for unit-test helper functions validating
* the correction-search modules of the Keyman predictive-text engine.
*/

import { assert } from 'chai';

import { constituentPaths } from "./constituentPaths.js";
import { buildCantLinearFixture } from './buildCantLinearFixture.js';
import { buildAlphabeticClusterFixtures } from './buildAlphabeticClusteredFixture.js';

describe('constituentPaths', () => {
it('includes a single entry array when all parents are SearchQuotientSpurs', () => {
const { paths } = buildCantLinearFixture();
const finalPath = paths[4];

assert.equal(constituentPaths(finalPath).length, 1);

const pathSequence = constituentPaths(finalPath)[0];
assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node

assert.sameOrderedMembers(pathSequence, paths.slice(1));
});

it('properly enumerates child paths when encountering SearchCluster ancestors', () => {
const fixture = buildAlphabeticClusterFixtures();
const finalPath = fixture.paths[4].path_k4c6;

// The longest SearchPath at the end of that fixture's set is based on a
// lead-in cluster; all variants of that should be included.
assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length);

// That cluster holds the different potential penultimate paths;
// finalPath's inputs are added directly after any variation that may be
// output from the cluster.
assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => {
p.push(finalPath);
return p;
}));
});
});
Loading
Loading