Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { assert } from "chai";

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.isTrue(finalClusterPaths.find(seq => seq.indexOf(spur) > -1)));
Comment thread
jahorton marked this conversation as resolved.
Outdated
allDists.forEach((dist) => allPaths.find(path => quotientPathHasInputs(path, [dist])));
Comment thread
jahorton marked this conversation as resolved.
Outdated
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* 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.

@ermshiperete ermshiperete Mar 13, 2026

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.

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'));

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.05 },
{ sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
{ 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.05 },
{ sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
Comment thread
jahorton marked this conversation as resolved.
Outdated
{ 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,27 @@
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,41 @@
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'));

// Needs legacy vs 'new' style implementation!

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;
}));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-03-10
*
* This file adds helper functions useful for developing test assertions against
* different types of SearchQuotientNodes and their properties.
*/

import {
SearchQuotientCluster,
SearchQuotientNode,
SearchQuotientRoot,
SearchQuotientSpur
} from "@keymanapp/lm-worker/test-index";

/**
* Enumerates the different potential SearchQuotientSpur sequences that lead
* to a SearchQuotientNode.
*
* Intended only for use during unit testing. Does not include the root node.
*/
export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] {
if(node instanceof SearchQuotientRoot) {
return [];
} else if(node instanceof SearchQuotientCluster) {
return node.parents.flatMap((p) => constituentPaths(p));
} else if(node instanceof SearchQuotientSpur) {
const parentPaths = constituentPaths(node.parents[0]);
let pathsToExtend = parentPaths;

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.

From devin.ai:

Unnecessary intermediate variable pathsToExtend in new constituentPaths

The new version introduces let pathsToExtend = parentPaths; then uses pathsToExtend.map(...). Since pathsToExtend is just an alias for parentPaths (same reference, never reassigned before use), this variable serves no purpose. The old code directly used parentPaths. This is not a bug — it's functionally identical — but it's a minor style inconsistency that could be cleaned up.

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.

It's here in part because a later PR does leverage the variable; such code appears in #15721.


if(parentPaths.length > 0) {
return pathsToExtend.map(p => {
p.push(node);
return p;
});
Comment on lines +32 to +36

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.

From devin.ai:

constituentPaths() mutates its returned arrays via push()

The constituentPaths function at web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts:33-35 uses p.push(node) to mutate the arrays returned from recursive calls. This is a pre-existing pattern (carried over from the old searchQuotientUtils.ts), but it means calling constituentPaths() twice on the same node will produce incorrect results on the second call if the first call's arrays were mutated in-place by a caller. This is visible in constituentPaths.tests.ts:40-42 where the test calls constituentPaths(fixture.clusters.cluster_k3c4).map((p) => { p.push(finalPath); ... }) — this mutates the inner arrays. Since constituentPaths(finalPath) is evaluated first (as the first argument to assert.sameDeepMembers), it works, but it's fragile. The same pattern existed before, so this is not a regression.

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.

Recursive root = new array each time / each call. There is no reference aliasing across multiple calls of the method.

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.

Ah, I think we discussed this before. Didn't remember that, sorry.

} else {
return [[node]];
}
} else {
throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type");
}
}
Loading