@@ -257,35 +257,49 @@ export async function computeGuessRank(
257257 if ( rows . length ) return rows [ 0 ] ! . rank ;
258258
259259 // Slow path: word outside top 5k.
260- // Compute cosine and estimate rank from the boundary cosine.
261- // The precomputed table stores ranks 1-5000; words beyond that
262- // are mapped using a linear interpolation between the 5K boundary
263- // and cosine=0 → rank=totalVocab. Instant, no full-vocab scan.
260+ // Estimate rank by Monte Carlo sampling: compute cosine of the
261+ // target against ~500 pre-cached random vocab embeddings, count
262+ // what fraction are closer than the guess, extrapolate to full vocab.
264263 const [ gVec , tVec ] = await Promise . all ( [
265264 guessVec ? Promise . resolve ( guessVec ) : getEmbedding ( lang , guess ) ,
266265 targetVec ? Promise . resolve ( targetVec ) : getEmbedding ( lang , target ) ,
267266 ] ) ;
268267 if ( ! gVec || ! tVec ) return null ;
269268
270269 const guessCos = cosineSimilarity ( gVec , tVec ) ;
271-
272- // Get the boundary cosine (lowest cosine in top 5K)
273- const boundaryRows = await prisma . $queryRaw < Array < { cosine : number } > > `
274- SELECT cosine FROM wordle.target_neighbors
275- WHERE lang = ${ lang } AND target_word = ${ target }
276- ORDER BY rank DESC LIMIT 1
277- ` ;
278- const boundaryCos = boundaryRows [ 0 ] ?. cosine ?? 0.3 ;
279270 const total = await getTotalRanked ( lang ) ;
271+ const sample = await getRankSample ( lang ) ;
272+ if ( ! sample ) return null ;
273+
274+ // Compute cosines of all sample words to the target
275+ const D = tVec . length ;
276+ const cosines = new Float32Array ( sample . count ) ;
277+ for ( let s = 0 ; s < sample . count ; s ++ ) {
278+ let dot = 0 ;
279+ const offset = s * D ;
280+ for ( let i = 0 ; i < D ; i ++ ) dot += tVec [ i ] ! * sample . vectors [ offset + i ] ! ;
281+ cosines [ s ] = dot ;
282+ }
280283
281- if ( guessCos >= boundaryCos ) {
282- // Should have been in top 5K but wasn't found — edge case, rank ~5000
283- return 5000 ;
284+ // Sort descending to find where guessCos falls
285+ const sorted = Array . from ( cosines ) . sort ( ( a , b ) => b - a ) ;
286+ // Find the two adjacent sample cosines that bracket guessCos
287+ let lo = 0 ;
288+ while ( lo < sorted . length && sorted [ lo ] ! > guessCos ) lo ++ ;
289+ // lo = number of samples with higher cosine
290+
291+ // Interpolate within the bucket using exact cosine position
292+ const bucketSize = total / sample . count ;
293+ let fractional = 0 ;
294+ if ( lo > 0 && lo < sorted . length ) {
295+ const hiCos = sorted [ lo - 1 ] ! ; // nearest sample above
296+ const loCos = sorted [ lo ] ! ; // nearest sample below
297+ if ( hiCos !== loCos ) {
298+ fractional = ( hiCos - guessCos ) / ( hiCos - loCos ) ;
299+ }
284300 }
285301
286- // Linear interpolation: boundaryCos → rank 5001, cos=0 → rank=total
287- const fraction = 1 - guessCos / boundaryCos ;
288- return Math . round ( 5001 + fraction * ( total - 5001 ) ) ;
302+ return Math . max ( 1 , Math . round ( ( lo + fractional ) * bucketSize ) ) ;
289303 } catch ( e ) {
290304 console . warn ( '[semantic-db] computeGuessRank failed:' , e ) ;
291305 return null ;
@@ -503,9 +517,35 @@ export async function fetchOnDemandEmbedding(
503517}
504518
505519// ═══════════════════════════════════════════════════════════════════════════
506- // Helpers
520+ // Rank estimation sample (loaded once, ~1MB, for Monte Carlo slow-path)
507521// ═══════════════════════════════════════════════════════════════════════════
508522
523+ const RANK_SAMPLE_SIZE = 500 ;
524+ let _rankSample : { lang : string ; count : number ; vectors : Float32Array } | null = null ;
525+
526+ /** Load a random sample of vocab embeddings for rank estimation. */
527+ async function getRankSample (
528+ lang : string
529+ ) : Promise < { count : number ; vectors : Float32Array } | null > {
530+ if ( _rankSample ?. lang === lang ) return _rankSample ;
531+ try {
532+ const rows = await prisma . $queryRaw < Array < { vector : string } > > `
533+ SELECT embedding::text as vector FROM wordle.word_embeddings
534+ WHERE lang = ${ lang } AND is_vocab = true
535+ ORDER BY random() LIMIT ${ RANK_SAMPLE_SIZE }
536+ ` ;
537+ const dims = EMBEDDING_DIMS ;
538+ const vectors = new Float32Array ( rows . length * dims ) ;
539+ for ( let i = 0 ; i < rows . length ; i ++ ) {
540+ vectors . set ( parseVector ( rows [ i ] ! . vector ) , i * dims ) ;
541+ }
542+ _rankSample = { lang, count : rows . length , vectors } ;
543+ return _rankSample ;
544+ } catch {
545+ return null ;
546+ }
547+ }
548+
509549function parseVector ( pgvectorStr : string ) : Float32Array {
510550 const nums = pgvectorStr . replace ( / ^ \[ / , '' ) . replace ( / \] $ / , '' ) . split ( ',' ) . map ( Number ) ;
511551 return new Float32Array ( nums ) ;
0 commit comments