From 8b374f3449bd5603d0c24fa612fd631bdb7639b8 Mon Sep 17 00:00:00 2001 From: Kip Robinson Date: Thu, 8 Sep 2022 22:14:40 -0400 Subject: [PATCH] refactor app to use word list masks to filter the list much more quickly --- app/cli/wordle-cheater.ts | 16 ++++---- app/lib/big-bit-mask.ts | 30 ++++++++++---- app/lib/word-list.ts | 58 ++++++++++++++++++++++++++- test/lib/word-list.spec.ts | 82 +++++++++++++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 18 deletions(-) diff --git a/app/cli/wordle-cheater.ts b/app/cli/wordle-cheater.ts index 5ab5fcd..78d4265 100644 --- a/app/cli/wordle-cheater.ts +++ b/app/cli/wordle-cheater.ts @@ -1,5 +1,6 @@ import CliUtils from "../lib/cli-utils"; -import { getWordListStats, getSortedWordList, RateWordCriteria, WordListStats, getEmptyRateWordCriteria, DEFAULT_WORD_LIST } from "../lib/word-list"; +import { getWordListStats, getSortedWordList, RateWordCriteria, getEmptyRateWordCriteria, DEFAULT_WORD_LIST } from "../lib/word-list"; +import WordListIndex from "../lib/word-list-index"; import { formatGuessPerResult, getResultForGuess, updateCriteriaPerResult } from "../lib/wordle-engine"; const NUM_WORDS_TO_SHOW = 40; @@ -11,13 +12,14 @@ type GuessHistory = Array<{guess:string, result:string}>; */ const cheatAtWordle = async ():Promise => { let wordList = DEFAULT_WORD_LIST; + const wordListIndex = new WordListIndex(wordList); const criteria: Required = getEmptyRateWordCriteria(); const correctAnswer = await askForAnswer(); let guesses:GuessHistory = []; while(true) { - const {abort, newWordList} = showBestGuesses(wordList, criteria); + const {abort, newWordList} = showBestGuesses(wordList, wordListIndex, criteria); if(abort) break; @@ -54,18 +56,18 @@ const askForAnswer = async ():Promise => { /** * Show the best guesses at this point in the game. */ -const showBestGuesses = (wordList:string[], criteria:RateWordCriteria): {abort:boolean, newWordList: string[]} => { +const showBestGuesses = (wordList:string[], wordListIndex:WordListIndex, criteria:RateWordCriteria): {abort:boolean, newWordList: string[]} => { const stats = getWordListStats(wordList); - const sortedWordList = getSortedWordList(stats, criteria); - const newWordList = sortedWordList.filter(e => e.score > 0).map(e => e.word); + const sortedWordList = getSortedWordList(stats, criteria, undefined, wordListIndex); + const newWordList = sortedWordList.map(e => e.word); - if(sortedWordList[0].score <= 0) { + if(newWordList.length <= 0) { console.log(); console.log("I'm sorry, but I couldn't find any words matching this criteria. Wordle must have a larger vocabulary than me!"); return {abort: true, newWordList}; } - const numPossibleWords = sortedWordList.reduce((count, entry) => count + (entry.score > 0 ? 1 : 0), 0); + const numPossibleWords = sortedWordList.length; console.log(); console.log(`I found ${numPossibleWords} possible words. Here are my top ${Math.min(numPossibleWords, NUM_WORDS_TO_SHOW)} suggestions for you to try:`); diff --git a/app/lib/big-bit-mask.ts b/app/lib/big-bit-mask.ts index 2f64c15..bcd2f82 100644 --- a/app/lib/big-bit-mask.ts +++ b/app/lib/big-bit-mask.ts @@ -183,32 +183,46 @@ class BigBitMask { * Performs union (bitwise or) of this mask and one or more other masks. */ union(...masks:BigBitMask[]):BigBitMask { - return this.bitwiseOpImplementation(masks, (a, b) => a | b); + return BigBitMask.bitwiseOpImplementation([this, ...masks], (a, b) => a | b); + } + + /** + * Performs union (bitwise or) of two or more masks. + */ + static union(...masks:BigBitMask[]):BigBitMask { + return BigBitMask.bitwiseOpImplementation(masks, (a, b) => a | b); } /** * Performs intersect (bitwise and) of this mask and one or more other masks. */ intersect(...masks:BigBitMask[]):BigBitMask { - return this.bitwiseOpImplementation(masks, (a, b) => a & b); + return BigBitMask.bitwiseOpImplementation([this, ...masks], (a, b) => a & b); + } + + /** + * Performs intersect (bitwise and) of this mask and one or more other masks. + */ + static intersect(...masks:BigBitMask[]):BigBitMask { + return BigBitMask.bitwiseOpImplementation(masks, (a, b) => a & b); } /** * Subtracts the other mask from this mask (bitwise `this & ~other`). */ subtract(other:BigBitMask):BigBitMask { - return this.bitwiseOpImplementation([other], (a, b) => a & ~b); + return BigBitMask.bitwiseOpImplementation([this, other], (a, b) => a & ~b); } - private bitwiseOpImplementation(masks:BigBitMask[], op:{(a:number, b:number):number}):BigBitMask { - if (masks.length === 0) + private static bitwiseOpImplementation(masks:BigBitMask[], op:{(a:number, b:number):number}):BigBitMask { + if (masks.length < 2) throw new Error('Must provide at least one other mask'); - if (masks.some(mask => mask.length !== this.length)) + if (masks.some(mask => mask.length !== masks[0].length)) throw new Error('Masks must all be the same length'); - const result = new BigBitMask(this); - for(let i = 0; i < masks.length; i++) { + const result = new BigBitMask(masks[0]); + for(let i = 1; i < masks.length; i++) { for(let j = 0; j < result.data.length; j++) { result.data[j] = op(result.data[j], masks[i].data[j]); } diff --git a/app/lib/word-list.ts b/app/lib/word-list.ts index 11683da..2e478c4 100644 --- a/app/lib/word-list.ts +++ b/app/lib/word-list.ts @@ -1,6 +1,8 @@ import * as fs from 'fs'; +import BigBitMask from './big-bit-mask'; import { FrequencyTable } from './frequency-table'; import { arrayCount, arrayRemoveValue } from './util'; +import WordListIndex from './word-list-index'; export type WordListStats = { overallFrequencies: FrequencyTable; @@ -24,7 +26,7 @@ export type RateWordCriteria = { /** The letters which we know are correct. */ correctLetters?: Array; - /** + /** * Letters which we know have to be in the answer at least a certain number * of times, but we don't know exactly how many or in which positions. */ @@ -170,10 +172,62 @@ export const wordMatchesCriteria = (word:string, criteria:RateWordCriteria={}):b return true; } +export const applyCriteriaToWordList = (criteria:RateWordCriteria, wordList:string[], wordListIndex:WordListIndex):string[] => { + // masks where a `1` means to include the word + const includeMasks:BigBitMask[] = []; + + // masks where a `1` means to exclude the word + const excludeMasks:BigBitMask[] = []; + + criteria.invalidLetters?.forEach(letter => includeMasks.push(wordListIndex.getMaskForWordsWithExactLetterCount(0, letter))); + + criteria.invalidLettersByPosition?.forEach((letters, position) => { + letters?.forEach(letter => excludeMasks.push(wordListIndex.getMaskForWordsWithLetterInPosition(position, letter))); + }); + + criteria.correctLetters?.forEach((letter, position) => { + if (letter) + includeMasks.push(wordListIndex.getMaskForWordsWithLetterInPosition(position, letter)); + }) + + if(criteria.minimumLetterCounts) { + Object.entries(criteria.minimumLetterCounts).forEach(([letter, count]) => { + includeMasks.push(wordListIndex.getMaskForWordsWithMinimumLetterCount(count, letter)); + }) + } + + if (criteria.knownLetterCounts) { + Object.entries(criteria.knownLetterCounts).forEach(([letter, count]) => { + includeMasks.push(wordListIndex.getMaskForWordsWithExactLetterCount(count, letter)); + }); + } + + if(excludeMasks.length && !includeMasks.length) + includeMasks.push(new BigBitMask(wordList.length, true)); + + if(includeMasks.length) { + const includeMask = includeMasks.length > 1 ? BigBitMask.intersect(...includeMasks) : includeMasks[0]; + + let finalMask = includeMask; + if(excludeMasks.length) { + const excludeMask = excludeMasks.length > 1 ? BigBitMask.union(...excludeMasks) : excludeMasks[0]; + finalMask = includeMask.subtract(excludeMask); + } + + wordList = finalMask.apply(wordList); + } + + return wordList; +} + /** * Gets a sorted word list, evaluating the score for each word. */ -export const getSortedWordList = (stats:WordListStats, criteria:RateWordCriteria={}, wordList:string[]=DEFAULT_WORD_LIST):SortedWordList => { +export const getSortedWordList = (stats:WordListStats, criteria:RateWordCriteria={}, wordList:string[]=DEFAULT_WORD_LIST, wordListIndex?:WordListIndex):SortedWordList => { + if (wordListIndex) { + wordList = applyCriteriaToWordList(criteria, wordList, wordListIndex); + } + const list:SortedWordList = wordList.map(word => ({ word, score: rateWord(word, stats, criteria), diff --git a/test/lib/word-list.spec.ts b/test/lib/word-list.spec.ts index c01b0f1..39183ee 100644 --- a/test/lib/word-list.spec.ts +++ b/test/lib/word-list.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; -import { getWordListStats, wordMatchesCriteria, rateWord } from "../../app/lib/word-list"; +import { getWordListStats, wordMatchesCriteria, rateWord, applyCriteriaToWordList } from "../../app/lib/word-list"; +import WordListIndex from "../../app/lib/word-list-index"; describe('test word-list.ts methods', () => { @@ -169,4 +170,83 @@ describe('test word-list.ts methods', () => { expect(wordMatchesCriteria('sassy', {knownLetterCounts: {s:2}})).to.be.false; expect(wordMatchesCriteria('sassy', {knownLetterCounts: {s:3}})).to.be.true; }); + + it('applyCriteriaToWordList', () => { + const wordList = ['myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog']; + const wordListIndex = new WordListIndex(wordList); + expect(applyCriteriaToWordList({}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + + expect(applyCriteriaToWordList({invalidLetters: new Set([])}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({invalidLetters: new Set(['m'])}, wordList, wordListIndex)).to.deep.equal([ + 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({invalidLetters: new Set(['x'])}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({invalidLetters: new Set(['x', 'h', 'p'])}, wordList, wordListIndex)).to.deep.equal([ + 'truss', 'tessa', 'sassy', 'ickbr', 'ownfo', 'sover', 'zydog', + ]); + + expect(applyCriteriaToWordList({invalidLettersByPosition: [ new Set([]), new Set([]), new Set([]), new Set([]), new Set([]) ]}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({invalidLettersByPosition: [ new Set(['m']), new Set(['a']), new Set(['b']), new Set(['c']), new Set(['d']) ]}, wordList, wordListIndex)).to.deep.equal([ + 'truss', 'tessa', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({invalidLettersByPosition: [ new Set(['s']), new Set(['m']), new Set(['y']), new Set(['t']), new Set(['h']) ]}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'thequ', 'ickbr', 'ownfo', 'xjump', 'thela', 'zydog', + ]); + + expect(applyCriteriaToWordList({correctLetters: ['m', null, null, null, null]}, wordList, wordListIndex)).to.deep.equal([ + 'myths', + ]); + expect(applyCriteriaToWordList({correctLetters: [null, null, null, null, 's']}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', + ]); + expect(applyCriteriaToWordList({correctLetters: [null, null, 'y', null, null]}, wordList, wordListIndex)).to.deep.equal([ + + ]); + expect(applyCriteriaToWordList({correctLetters: [null, null, null, null, null]}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + + expect(applyCriteriaToWordList({minimumLetterCounts: {}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {e:1}}, wordList, wordListIndex)).to.deep.equal([ + 'tessa', 'thequ', 'sover', 'thela', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {t:1}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'thequ', 'thela', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {s:1}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'sover', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {s:1, t:1}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {s:2, t:1}}, wordList, wordListIndex)).to.deep.equal([ + 'truss', 'tessa', + ]); + expect(applyCriteriaToWordList({minimumLetterCounts: {s:3}}, wordList, wordListIndex)).to.deep.equal([ + 'sassy', + ]); + + expect(applyCriteriaToWordList({knownLetterCounts: {}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'truss', 'tessa', 'sassy', 'thequ', 'ickbr', 'ownfo', 'xjump', 'sover', 'thela', 'zydog', + ]); + expect(applyCriteriaToWordList({knownLetterCounts: {s:1}}, wordList, wordListIndex)).to.deep.equal([ + 'myths', 'sover', + ]); + expect(applyCriteriaToWordList({knownLetterCounts: {s:2}}, wordList, wordListIndex)).to.deep.equal([ + 'truss', 'tessa', + ]); + expect(applyCriteriaToWordList({knownLetterCounts: {s:3}}, wordList, wordListIndex)).to.deep.equal([ + 'sassy', + ]); + }); });