experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(cmd): fix frecency/adaptive sorting in command palette

Three bugs caused frequently-used commands to rank below rarely-used ones:

1. Threshold-based sort cascade: The sort used adaptive score with a > 0.01
threshold, falling back to matchCounts only when scores tied. A command
selected once for query "ta" (score 0.25) would beat a command selected
100x total but never for "ta" specifically (score 0). Fixed by combining
adaptive score and frecency into a unified weighted ranking.

2. Missing cross-query adaptive feedback: Selecting "open tags" after typing
"open" only recorded feedback for "open" and its prefixes, not for "ta"
or "tags" which also match the command via substring search. Fixed by also
recording quarter-weight feedback for prefixes of each word in the command
name (excluding words already covered by the typed text).

3. Settings save race condition: saveAdaptiveData used a read-modify-write
pattern (get all settings, merge, set all) which could race with concurrent
saves. Fixed by using api.settings.setKey for independent key writes.

Also added getFrecencyScore() using asymptotic formula (count / (count + k))
with k=10 so frecency grows more slowly than adaptive score, giving query-
specific adaptive data 3x more influence while still letting heavily-used
commands rank well for any matching query.

+68 -21
+68 -21
extensions/cmd/panel.js
··· 41 41 42 42 /** 43 43 * Save adaptive data to extension settings 44 + * Uses setKey to write each key independently, avoiding read-modify-write races 44 45 */ 45 46 const saveAdaptiveData = async (feedback, counts) => { 46 47 adaptiveDataCache = { feedback, counts }; 47 48 48 - // Get current settings and merge 49 - const result = await api.settings.get(); 50 - const currentData = result.success && result.data ? result.data : {}; 51 - 52 - await api.settings.set({ 53 - ...currentData, 54 - [STORAGE_KEY_FEEDBACK]: feedback, 55 - [STORAGE_KEY_COUNTS]: counts 56 - }); 49 + // Write each key independently to avoid overwriting other settings 50 + await Promise.all([ 51 + api.settings.setKey(STORAGE_KEY_FEEDBACK, feedback), 52 + api.settings.setKey(STORAGE_KEY_COUNTS, counts) 53 + ]); 57 54 }; 58 55 59 56 // Initialize with empty data - will be loaded asynchronously ··· 1373 1370 } 1374 1371 } 1375 1372 1376 - // Sort by: 1373 + // Sort by combined ranking score: 1377 1374 // 1. Exact match with parameters (highest priority) 1378 - // 2. Adaptive score 1379 - // 3. Match count (frecency) 1375 + // 2. Combined score: adaptive score (query-specific) + frecency (global usage) 1376 + // This ensures frequently-used commands rank high even when the specific 1377 + // query has no adaptive data (e.g., user types "ta" but usually types "tags") 1380 1378 matches.sort(function(a, b) { 1381 1379 // If we have parameters, prioritize exact command match 1382 1380 if (hasParameters) { ··· 1386 1384 if (bExact && !aExact) return 1; 1387 1385 } 1388 1386 1389 - // Then compare adaptive scores for this typed string 1387 + // Combined ranking: adaptive score (0-1) weighted heavily + frecency score (0-1) 1388 + // Adaptive score is query-specific (e.g., "ta" -> "open tags") 1389 + // Frecency score is global (total selections of this command) 1390 1390 const aAdaptive = getAdaptiveScore(commandPart, a); 1391 1391 const bAdaptive = getAdaptiveScore(commandPart, b); 1392 + const aFrecency = getFrecencyScore(a); 1393 + const bFrecency = getFrecencyScore(b); 1392 1394 1393 - // If there's a significant difference in adaptive scores, use that 1394 - if (Math.abs(aAdaptive - bAdaptive) > 0.01) { 1395 - return bAdaptive - aAdaptive; 1396 - } 1395 + // Weight adaptive 3x more than frecency when adaptive data exists, 1396 + // but frecency still contributes so heavily-used commands rank well 1397 + // even without adaptive data for this specific query 1398 + const aScore = (aAdaptive * 3) + aFrecency; 1399 + const bScore = (bAdaptive * 3) + bFrecency; 1397 1400 1398 - // Otherwise fall back to match count (frecency) 1399 - const aCount = state.matchCounts[a] || 0; 1400 - const bCount = state.matchCounts[b] || 0; 1401 - return bCount - aCount; 1401 + return bScore - aScore; 1402 1402 }); 1403 1403 1404 1404 return matches; ··· 1408 1408 * Updates the adaptive feedback for a typed string -> command selection 1409 1409 * Uses asymptotic scoring: score = count / (count + k) 1410 1410 * This creates ever-strengthening reinforcement based on user decisions 1411 + * 1412 + * Records feedback for: 1413 + * 1. The exact typed string (full weight) 1414 + * 2. Prefixes of the typed string (half weight) - helps with partial typing 1415 + * 3. Substrings of the command name that could be search queries (quarter weight) 1416 + * - e.g., selecting "open tags" also creates entries for "ta", "tag", "tags" 1417 + * - This ensures the command ranks well when searched by any matching substring 1411 1418 */ 1412 1419 function updateAdaptiveFeedback(typed, name) { 1413 1420 // Initialize feedback for this typed string if needed ··· 1435 1442 state.adaptiveFeedback[prefix][name] += 0.5; 1436 1443 } 1437 1444 1445 + // Record feedback for substrings of the command name that could be typed 1446 + // as search queries. This covers the case where a user always types "open" 1447 + // to reach "open tags" but sometimes types "ta" instead - the command 1448 + // should rank high for both queries. 1449 + const lowerName = name.toLowerCase(); 1450 + const lowerTyped = typed.toLowerCase(); 1451 + const words = lowerName.split(/[\s-]+/); // Split on spaces and hyphens 1452 + 1453 + for (const word of words) { 1454 + // Skip words that are the same as or prefix of the typed text (already covered above) 1455 + if (lowerTyped.startsWith(word) || word.startsWith(lowerTyped)) { 1456 + continue; 1457 + } 1458 + 1459 + // Generate substrings of this word (min length 2 to avoid single chars) 1460 + for (let len = 2; len <= word.length; len++) { 1461 + const sub = word.substring(0, len); 1462 + if (!state.adaptiveFeedback[sub]) { 1463 + state.adaptiveFeedback[sub] = {}; 1464 + } 1465 + if (!state.adaptiveFeedback[sub][name]) { 1466 + state.adaptiveFeedback[sub][name] = 0; 1467 + } 1468 + // Quarter weight for command name substrings 1469 + state.adaptiveFeedback[sub][name] += 0.25; 1470 + } 1471 + } 1472 + 1438 1473 // Persist to storage 1439 1474 saveAdaptiveData(state.adaptiveFeedback, state.matchCounts); 1440 1475 } ··· 1451 1486 return 0; 1452 1487 } 1453 1488 const count = feedback[name]; 1489 + return count / (count + k); 1490 + } 1491 + 1492 + /** 1493 + * Gets the frecency score for a command based on total selection count 1494 + * Uses the same asymptotic formula as adaptive scoring 1495 + * Returns 0-1 where higher is better 1496 + */ 1497 + function getFrecencyScore(name) { 1498 + const k = 10; // Higher k so frecency grows more slowly (needs more uses to saturate) 1499 + const count = state.matchCounts[name] || 0; 1500 + if (count === 0) return 0; 1454 1501 return count / (count + k); 1455 1502 } 1456 1503