diff --git a/common/models/templates/src/priority-queue.ts b/common/models/templates/src/priority-queue.ts index 79328096f6..bdfa32f0eb 100644 --- a/common/models/templates/src/priority-queue.ts +++ b/common/models/templates/src/priority-queue.ts @@ -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); + } } } \ No newline at end of file diff --git a/common/models/templates/src/trie-model.ts b/common/models/templates/src/trie-model.ts index 2ca1a3d4d2..d187b3414e 100644 --- a/common/models/templates/src/trie-model.ts +++ b/common/models/templates/src/trie-model.ts @@ -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 { diff --git a/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js b/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js index fd82ffde77..6fe04f5e0a 100644 --- a/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js +++ b/common/predictive-text/unit_tests/headless/edit-distance/distance-modeler.js @@ -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: , done: } + // assert.isFalse(firstSet.done); + + // firstSet = firstSet.value; // Retrieves + // 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]); + } + }); + }); }); \ No newline at end of file diff --git a/common/predictive-text/worker/correction/distance-modeler.ts b/common/predictive-text/worker/correction/distance-modeler.ts index 6dec58a97d..28de14e585 100644 --- a/common/predictive-text/worker/correction/distance-modeler.ts +++ b/common/predictive-text/worker/correction/distance-modeler.ts @@ -1,12 +1,6 @@ /// namespace correction { - enum SearchOperation { - addInput, - addMatch, - expandDiagonal - } - type RealizedInput = ProbabilityMass[]; type TraversableToken = { @@ -18,6 +12,10 @@ namespace correction { return arg1.currentCost - arg2.currentCost; } + export const QUEUE_NODE_COMPARATOR: models.Comparator = function(arg1, arg2) { + return arg1.currentCost - arg2.currentCost; + } + export const QUEUE_SPACE_COMPARATOR: models.Comparator = 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; - operation: SearchOperation; - processed: SearchNode[] = []; + index: number; - constructor(operation: SearchOperation) { - this.operation = operation; - this.correctionQueue = new models.PriorityQueue(QUEUE_EDGE_COMPARATOR); + constructor(index: number, initialEdges?: SearchEdge[]) { + this.index = index; + this.correctionQueue = new models.PriorityQueue(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(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; + private inputSequence: ProbabilityMass[][] = []; + 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(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) { + addInput(inputDistribution: ProbabilityMass[]) { + 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(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[][], number]> { + // might should also include a 'base cost' parameter of sorts? + + class BatchingAssistant { + currentCost = Number.MIN_SAFE_INTEGER; + entries: TraversableToken[][] = []; + + checkAndAdd(entry: SearchNode): [TraversableToken[][], number] | null { + var result: [TraversableToken[][], number] = null; + + if(entry.currentCost > this.currentCost) { + result = this.tryFinalize(); + + this.currentCost = entry.currentCost; + } + + this.entries.push(entry.calculation.matchSequence); + return result; + } + + tryFinalize(): [TraversableToken[][], number] | null { + var result: [TraversableToken[][], number] = null; + if(this.entries.length > 0) { + result = [this.entries, this.currentCost]; + this.entries = []; + } + + return result; + } + } + + let batcher = new BatchingAssistant(); + let batch: [TraversableToken[][], 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(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; - } } } \ No newline at end of file