mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-28 02:27:41 +00:00
feat(common/models): core correction-search
This commit is contained in:
parent
87dff74e5d
commit
4380265466
4 changed files with 289 additions and 58 deletions
|
|
@ -137,6 +137,10 @@ namespace models {
|
|||
* @param elements A group of elements to enqueue simultaneously.
|
||||
*/
|
||||
enqueueAll(elements: Type[]) {
|
||||
if(elements.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let firstIndex = this.count
|
||||
this.heap = this.heap.concat(elements);
|
||||
let firstParent = PriorityQueue.parentIndex(firstIndex);
|
||||
|
|
@ -199,5 +203,17 @@ namespace models {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array containing all entries of the priority queue.
|
||||
* Altering the returned array will not affect the queue, but mutating
|
||||
* the array's elements can cause unintended side effects.
|
||||
*
|
||||
* This function makes no guarantees on the ordering of the returned elements;
|
||||
* they will almost certainly be unsorted.
|
||||
*/
|
||||
toArray(): Type[] {
|
||||
return Array.from(this.heap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +220,9 @@
|
|||
}
|
||||
} else if(models.isSentinel(entry)) {
|
||||
continue;
|
||||
} else if(!entry) {
|
||||
// Prevent any accidental 'null' or 'undefined' entries from having an effect.
|
||||
continue;
|
||||
} else {
|
||||
let prefix = this.prefix + entry;
|
||||
yield {
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ describe.only('Correction Distance Modeler', function() {
|
|||
let tEdge = layer1Queue.dequeue();
|
||||
assertEdgeChars(tEdge, 't', 't');
|
||||
|
||||
let layer2Edges = new correction.SearchNode(tEdge).buildSubstitutionEdges(synthDistribution2);
|
||||
let layer2Edges = new correction.SearchNode(rootTraversal, tEdge).buildSubstitutionEdges(synthDistribution2);
|
||||
let layer2Queue = new models.PriorityQueue(correction.QUEUE_EDGE_COMPARATOR, layer2Edges);
|
||||
|
||||
let eEdge = layer2Queue.dequeue();
|
||||
|
|
@ -186,9 +186,9 @@ describe.only('Correction Distance Modeler', function() {
|
|||
assert.isOk(ehEdge);
|
||||
|
||||
// Final round: we'll use three nodes and throw all of their results into the same priority queue.
|
||||
let layer3eEdges = new correction.SearchNode(eEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3hEdges = new correction.SearchNode(hEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3ehEdges = new correction.SearchNode(ehEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3eEdges = new correction.SearchNode(rootTraversal, eEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3hEdges = new correction.SearchNode(rootTraversal, hEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3ehEdges = new correction.SearchNode(rootTraversal, ehEdge).buildSubstitutionEdges(synthDistribution3);
|
||||
let layer3Queue = new models.PriorityQueue(correction.QUEUE_EDGE_COMPARATOR, layer3eEdges.concat(layer3hEdges).concat(layer3ehEdges));
|
||||
|
||||
// Find the first result with an actual word directly represented.
|
||||
|
|
@ -222,4 +222,68 @@ describe.only('Correction Distance Modeler', function() {
|
|||
assert.equal(edge.currentCost, 1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchSpaceTier + SearchSpace', function() {
|
||||
var testModel;
|
||||
|
||||
before(function() {
|
||||
testModel = new models.TrieModel(jsonFixture('tries/english-1000'));
|
||||
});
|
||||
|
||||
it('Simple Search: "teh"', function() {
|
||||
// The combinatorial effect here is a bit much to fully test.
|
||||
let rootTraversal = testModel.traverseFromRoot();
|
||||
assert.isNotEmpty(rootTraversal);
|
||||
|
||||
let searchSpace = new correction.SearchSpace(rootTraversal);
|
||||
|
||||
// VERY artificial distributions.
|
||||
let synthDistribution1 = [
|
||||
{sample: {insert: 't', deleteLeft: 0}, p: 1} // Transform, probability
|
||||
];
|
||||
|
||||
let synthDistribution2 = [
|
||||
{sample: {insert: 'e', deleteLeft: 0}, p: 0.75}, // Transform, probability
|
||||
{sample: {insert: 'h', deleteLeft: 0}, p: 0.25}
|
||||
];
|
||||
|
||||
let synthDistribution3 = [
|
||||
{sample: {insert: 'h', deleteLeft: 0}, p: 0.75}, // Transform, probability
|
||||
{sample: {insert: 'n', deleteLeft: 0}, p: 0.25}
|
||||
];
|
||||
|
||||
searchSpace.addInput(synthDistribution1);
|
||||
searchSpace.addInput(synthDistribution2);
|
||||
searchSpace.addInput(synthDistribution3);
|
||||
|
||||
let iter = searchSpace.getBestMatches();
|
||||
// let firstSet = iter.next(); // {value: <actual value>, done: <iteration complete?>}
|
||||
// assert.isFalse(firstSet.done);
|
||||
|
||||
// firstSet = firstSet.value; // Retrieves <actual value>
|
||||
// assert.equal(firstSet[1], 1);
|
||||
// assert.equal(firstSet[0].length, 1); // A single sequence ("ten") should be the best match.
|
||||
|
||||
// let chars = firstSet[0][0].map(value => value.key);
|
||||
// assert.equal(chars.join(''), "ten");
|
||||
|
||||
for(let i = 1; i < 4; i++) {
|
||||
console.log();
|
||||
console.log("Batch " + i);
|
||||
|
||||
let set = iter.next();
|
||||
assert.isFalse(set.done);
|
||||
|
||||
set = set.value;
|
||||
|
||||
let entries = set[0].map(function(sequence) {
|
||||
return sequence.map(value => value.key).join('');
|
||||
});
|
||||
|
||||
console.log("Expected entry count: " + set[0].length);
|
||||
console.log(entries);
|
||||
console.log(set[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
/// <reference path="classical-calculation.ts" />
|
||||
|
||||
namespace correction {
|
||||
enum SearchOperation {
|
||||
addInput,
|
||||
addMatch,
|
||||
expandDiagonal
|
||||
}
|
||||
|
||||
type RealizedInput = ProbabilityMass<Transform>[];
|
||||
|
||||
type TraversableToken<TUnit> = {
|
||||
|
|
@ -18,6 +12,10 @@ namespace correction {
|
|||
return arg1.currentCost - arg2.currentCost;
|
||||
}
|
||||
|
||||
export const QUEUE_NODE_COMPARATOR: models.Comparator<SearchNode> = function(arg1, arg2) {
|
||||
return arg1.currentCost - arg2.currentCost;
|
||||
}
|
||||
|
||||
export const QUEUE_SPACE_COMPARATOR: models.Comparator<SearchSpaceTier> = function(space1, space2) {
|
||||
let node1 = space1.correctionQueue.peek();
|
||||
let node2 = space2.correctionQueue.peek();
|
||||
|
|
@ -61,7 +59,7 @@ namespace correction {
|
|||
return this.calculation.getHeuristicFinalCost();
|
||||
}
|
||||
|
||||
get heuristicCost(): number {
|
||||
get inputSamplingCost(): number {
|
||||
// TODO: Optimize so that we're not frequently recomputing this?
|
||||
// TODO: We might should generalize this so that the probability-to-cost function isn't directly hard-coded.
|
||||
// Seems like a decent first conversion function though, at least.
|
||||
|
|
@ -70,7 +68,7 @@ namespace correction {
|
|||
|
||||
// The part used to prioritize our search.
|
||||
get currentCost(): number {
|
||||
return this.knownCost + this.heuristicCost;
|
||||
return this.knownCost + this.inputSamplingCost;
|
||||
}
|
||||
|
||||
get mapKey(): string {
|
||||
|
|
@ -94,36 +92,39 @@ namespace correction {
|
|||
currentTraversal: LexiconTraversal;
|
||||
priorInput: RealizedInput;
|
||||
|
||||
// TODO: Initializing from 'root' / just a traversal.
|
||||
/**
|
||||
* Instantiates the initial SearchNode used for corrective edit-distance based search.
|
||||
*
|
||||
* @param traversal The root LexiconTraversal of a LexicalModel
|
||||
*/
|
||||
constructor(traversal: LexiconTraversal);
|
||||
/**
|
||||
* Transforms an edge on the search graph into a node, utilizing its information
|
||||
* to determine the graph edges that may come afterward.
|
||||
* @param edge An existing SearchEdge used to to reach this SourceNode on the search algorithm's
|
||||
* 'graph'.
|
||||
*/
|
||||
constructor(edge: SearchEdge);
|
||||
constructor(obj: SearchEdge|LexiconTraversal) {
|
||||
if(obj instanceof SearchEdge) {
|
||||
let edge = obj as SearchEdge;
|
||||
|
||||
constructor(rootTraversal: LexiconTraversal, edge?: SearchEdge) {
|
||||
if(edge) {
|
||||
this.calculation = edge.calculation;
|
||||
this.currentTraversal = edge.calculation.matchSequence[edge.calculation.matchSequence.length-1].traversal;
|
||||
|
||||
if(edge.calculation.lastMatchEntry) {
|
||||
this.currentTraversal = edge.calculation.lastMatchEntry.traversal;
|
||||
} else {
|
||||
this.currentTraversal = rootTraversal;
|
||||
}
|
||||
this.priorInput = edge.optimalInput;
|
||||
} else {
|
||||
// Assume it's a LexiconTraversal instead.
|
||||
let traversal = obj as LexiconTraversal;
|
||||
this.calculation = new ClassicalDistanceCalculation();
|
||||
this.currentTraversal = traversal;
|
||||
this.currentTraversal = rootTraversal;
|
||||
this.priorInput = [];
|
||||
}
|
||||
}
|
||||
|
||||
get knownCost(): number {
|
||||
return this.calculation.getHeuristicFinalCost();
|
||||
}
|
||||
|
||||
get inputSamplingCost(): number {
|
||||
// TODO: Optimize so that we're not frequently recomputing this?
|
||||
// TODO: We might should generalize this so that the probability-to-cost function isn't directly hard-coded.
|
||||
// Seems like a decent first conversion function though, at least.
|
||||
return this.priorInput.map(mass => mass.p).reduce((previous, current) => previous + (1 - current), 0);
|
||||
}
|
||||
|
||||
// The part used to prioritize our search.
|
||||
get currentCost(): number {
|
||||
return this.knownCost + this.inputSamplingCost;
|
||||
}
|
||||
|
||||
buildInsertionEdges(): SearchEdge[] {
|
||||
let edges: SearchEdge[] = [];
|
||||
|
||||
|
|
@ -217,42 +218,196 @@ namespace correction {
|
|||
|
||||
class SearchSpaceTier {
|
||||
correctionQueue: models.PriorityQueue<SearchEdge>;
|
||||
operation: SearchOperation;
|
||||
|
||||
processed: SearchNode[] = [];
|
||||
index: number;
|
||||
|
||||
constructor(operation: SearchOperation) {
|
||||
this.operation = operation;
|
||||
this.correctionQueue = new models.PriorityQueue<SearchEdge>(QUEUE_EDGE_COMPARATOR);
|
||||
constructor(index: number, initialEdges?: SearchEdge[]) {
|
||||
this.index = index;
|
||||
this.correctionQueue = new models.PriorityQueue<SearchEdge>(QUEUE_EDGE_COMPARATOR, initialEdges);
|
||||
}
|
||||
|
||||
increaseMaxEditDistance() {
|
||||
// By extracting the entries from the priority queue and increasing distance outside of it as a batch job,
|
||||
// we get an O(N) implementation, rather than the O(N log N) that would result from maintaining the original queue.
|
||||
let entries = this.correctionQueue.toArray();
|
||||
|
||||
entries.forEach(function(edge) { edge.calculation = edge.calculation.increaseMaxDistance(); });
|
||||
|
||||
// Since we just modified the stored instances, and the costs may have shifted, we need to re-heapify.
|
||||
this.correctionQueue = new models.PriorityQueue<SearchEdge>(QUEUE_EDGE_COMPARATOR, entries);
|
||||
}
|
||||
}
|
||||
|
||||
// The set of search spaces corresponding to the same 'context' for search.
|
||||
// Whenever a wordbreak boundary is crossed, a new instance should be made.
|
||||
export class SearchSpace {
|
||||
private cachedSpaces: {[id: string]: SearchSpaceTier} = {};
|
||||
private tierOrdering: SearchSpaceTier[] = [];
|
||||
private selectionQueue: models.PriorityQueue<SearchSpaceTier>;
|
||||
private inputSequence: ProbabilityMass<Transform>[][] = [];
|
||||
private rootNode: SearchNode;
|
||||
|
||||
// TODO: Fix; is not quite right. We want the results corresponding to the node
|
||||
// that will let us build the next tier's SearchNodes when new input arrives.
|
||||
//
|
||||
// We use an array and not a PriorityQueue b/c batch-heapifying at a single point in time
|
||||
// is cheaper than iteratively building a priority queue.
|
||||
private extractedResults: SearchNode[] = [];
|
||||
private extractedResults: SearchNode[];
|
||||
|
||||
constructor() {
|
||||
constructor(traversalRoot: LexiconTraversal) {
|
||||
this.selectionQueue = new models.PriorityQueue<SearchSpaceTier>(QUEUE_SPACE_COMPARATOR);
|
||||
this.rootNode = new SearchNode(traversalRoot);
|
||||
|
||||
this.extractedResults = [this.rootNode];
|
||||
}
|
||||
|
||||
processNode(node: SearchEdge): SearchNode[] {
|
||||
let sourceCalc = node.calculation;
|
||||
|
||||
// TODO: Lots of things.
|
||||
return [];
|
||||
increaseMaxEditDistance() {
|
||||
this.tierOrdering.forEach(function(tier) { tier.increaseMaxEditDistance() });
|
||||
}
|
||||
|
||||
addInput(input: Distribution<Transform>) {
|
||||
addInput(inputDistribution: ProbabilityMass<Transform>[]) {
|
||||
this.inputSequence.push(inputDistribution);
|
||||
|
||||
// With a newly-available input, we can extend new input-dependent paths from
|
||||
// our previously-reached 'extractedResults' nodes.
|
||||
let newlyAvailableEdges: SearchEdge[] = [];
|
||||
let batches = this.extractedResults.map(function(node) {
|
||||
let deletions = node.buildDeletionEdges(inputDistribution);
|
||||
let substitutions = node.buildSubstitutionEdges(inputDistribution);
|
||||
|
||||
return deletions.concat(substitutions);
|
||||
});
|
||||
|
||||
// Don't forget to reset the array; the contained nodes no longer reach the search's end.
|
||||
this.extractedResults = [];
|
||||
|
||||
batches.forEach(function(batch) {
|
||||
newlyAvailableEdges = newlyAvailableEdges.concat(batch);
|
||||
});
|
||||
|
||||
// Now that we've built the new edges, we can efficiently construct the new search tier.
|
||||
let tier = new SearchSpaceTier(this.tierOrdering.length, newlyAvailableEdges);
|
||||
this.tierOrdering.push(tier);
|
||||
this.selectionQueue.enqueue(tier);
|
||||
}
|
||||
|
||||
// TODO: will want eventually for reversions and/or backspaces
|
||||
removeLastInput() {
|
||||
// 1. truncate all entries from that search tier; we need to 'restore' extractedResults to match
|
||||
// the state that would have existed without the last search tier.
|
||||
// 2. remove the last search tier. Which may necessitate reconstructing the tier queue, but oh well.
|
||||
}
|
||||
|
||||
hasNextMatch(): boolean {
|
||||
return this.selectionQueue.peek().correctionQueue.count > 0;
|
||||
}
|
||||
|
||||
findNextMatch(): SearchNode {
|
||||
while(this.hasNextMatch()) {
|
||||
let bestTier = this.selectionQueue.dequeue();
|
||||
|
||||
let incomingEdge = bestTier.correctionQueue.dequeue();
|
||||
let currentNode = new SearchNode(this.rootNode.currentTraversal, incomingEdge);
|
||||
|
||||
// Always possible, as this does not require any new input.
|
||||
let insertionEdges = currentNode.buildInsertionEdges();
|
||||
bestTier.correctionQueue.enqueueAll(insertionEdges);
|
||||
|
||||
if(bestTier.index == this.tierOrdering.length - 1) {
|
||||
// It was the final tier - store the node for future reference.
|
||||
this.extractedResults.push(currentNode);
|
||||
|
||||
// Since we don't modify any other tier, we may simply reinsert the removed tier.
|
||||
this.selectionQueue.enqueue(bestTier);
|
||||
|
||||
return currentNode;
|
||||
} else {
|
||||
// Time to construct new edges for the next tier!
|
||||
let nextTier = this.tierOrdering[bestTier.index+1];
|
||||
// TODO: make sure we get this part right.
|
||||
let inputIndex = nextTier.index;
|
||||
|
||||
let deletionEdges = currentNode.buildDeletionEdges(this.inputSequence[inputIndex]);
|
||||
let substitutionEdges = currentNode.buildSubstitutionEdges(this.inputSequence[inputIndex]);
|
||||
|
||||
// Note: we're live-modifying the tier's cost here! The priority queue loses its guarantees as a result.
|
||||
nextTier.correctionQueue.enqueueAll(deletionEdges.concat(substitutionEdges));
|
||||
|
||||
// So, we simply rebuild the selection queue.
|
||||
this.selectionQueue = new models.PriorityQueue<SearchSpaceTier>(QUEUE_SPACE_COMPARATOR, this.tierOrdering);
|
||||
|
||||
// We didn't reach an end-node, so we just end the iteration and continue the search.
|
||||
}
|
||||
}
|
||||
|
||||
// If we've somehow fully exhausted all search options, indicate that none remain.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Current best guesstimate of how compositor will retrieve ideal corrections.
|
||||
*getBestMatches(): Generator<[TraversableToken<string>[][], number]> {
|
||||
// might should also include a 'base cost' parameter of sorts?
|
||||
|
||||
class BatchingAssistant {
|
||||
currentCost = Number.MIN_SAFE_INTEGER;
|
||||
entries: TraversableToken<string>[][] = [];
|
||||
|
||||
checkAndAdd(entry: SearchNode): [TraversableToken<string>[][], number] | null {
|
||||
var result: [TraversableToken<string>[][], number] = null;
|
||||
|
||||
if(entry.currentCost > this.currentCost) {
|
||||
result = this.tryFinalize();
|
||||
|
||||
this.currentCost = entry.currentCost;
|
||||
}
|
||||
|
||||
this.entries.push(entry.calculation.matchSequence);
|
||||
return result;
|
||||
}
|
||||
|
||||
tryFinalize(): [TraversableToken<string>[][], number] | null {
|
||||
var result: [TraversableToken<string>[][], number] = null;
|
||||
if(this.entries.length > 0) {
|
||||
result = [this.entries, this.currentCost];
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
let batcher = new BatchingAssistant();
|
||||
let batch: [TraversableToken<string>[][], number];
|
||||
|
||||
// Stage 1 - if we already have extracted results, build a queue just for them and iterate over it first.
|
||||
if(this.extractedResults.length > 0) {
|
||||
let preprocessedQueue = new models.PriorityQueue<SearchNode>(QUEUE_NODE_COMPARATOR, this.extractedResults);
|
||||
|
||||
// Build batches of same-cost entries.
|
||||
while(preprocessedQueue.count > 0) {
|
||||
let entry = preprocessedQueue.dequeue();
|
||||
batch = batcher.checkAndAdd(entry);
|
||||
|
||||
if(batch) {
|
||||
yield batch;
|
||||
}
|
||||
}
|
||||
|
||||
// As we only return a batch once all entries of the same cost have been processed, we can safely
|
||||
// finalize the last preprocessed group without issue.
|
||||
batch = batcher.tryFinalize();
|
||||
if(batch) {
|
||||
yield batch;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: the fun part; actually searching!
|
||||
do {
|
||||
let newResult = this.findNextMatch();
|
||||
batch = batcher.checkAndAdd(newResult);
|
||||
|
||||
if(batch) {
|
||||
yield batch;
|
||||
}
|
||||
} while(this.hasNextMatch());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,12 +436,5 @@ namespace correction {
|
|||
// TODO: add 'addInput' operation
|
||||
// do search space things
|
||||
}
|
||||
|
||||
// Current best guesstimate of how compositor will retrieve ideal corrections.
|
||||
getBestMatches(): Generator<[string, number][]> { // might should also include a 'base cost' parameter of sorts?
|
||||
// Duplicates underlying Priority Queue, iterates progressively through sets of evenly-costed
|
||||
// corrections until satisfied.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue