Search apps by their macOS alternate names (#153)

* Search apps by their macOS alternate names

Reads Spotlight's kMDItemAlternateNames during the existing background
scan, so an app renamed by its vendor stays findable by the old name:
Codex -> ChatGPT, iBooks -> Books, iCal -> Calendar, System Preferences
-> System Settings, browser -> Safari.

Searchable fields stay separate rather than being flattened into one
string, because which field matched is what picks the ranking band:

    5/3  display name (plus a snippet keyword)  literal / subsequence
    4/2  Spotlight alternate names              literal / subsequence
    1    bundle identifier                      literal only
    0    executable name                        literal only

A literal hit on a weaker field outranks a subsequence hit on a stronger
one, so a declared alias beats incidental letter soup, while a real
prefix hit on a display name still wins outright. Bands sit one stride
apart, 10x FuzzyMatch's range and 200x the frecency cap, so a learned
boost still reorders within a tier and can never cross one.
LauncherRankingStore is untouched.

FuzzyMatch moves to a Foundation-only Core/SearchRelevance.swift that
Tools/fuzz-test.swift now compiles for real, retiring the hand-mirrored
copy invariant.

Two things the real metadata forced:

- Spotlight ships junk. Every bundle lists its own "<Name>.app", and
  Home/Journal/Maps/Passwords/Weather ship untranslated ALTERNATE_NAME_1
  placeholders. Unfiltered, "app" matched most of the index.
- Identifier fields take literal matches only, against the bundle id
  with its leading reverse-DNS component stripped. Measured, matching
  the full string made "com" return 90 of 97 apps.

A Spotlight round trip costs ~0.8 ms per bundle and the scan reruns on
every launcher open, so SpotlightNames.Cache re-reads only bundles whose
modification date moved: 76 ms cold, 0.2 ms after.

Closes #141

* CI: compile the real scorer into the fuzz harness

fuzz-test.swift no longer runs as a single-file script — it compiles
Core/SearchRelevance.swift instead of carrying a copy of FuzzyMatch, so
`swift Tools/fuzz-test.swift` can no longer resolve SearchFields or
SearchRelevance. docs/development.md was updated for this; the workflow
was not.
This commit is contained in:
Abue Ammar
2026-08-03 22:16:19 +06:00
committed by GitHub
parent fddbed4f10
commit 248cdfed6c
10 changed files with 819 additions and 195 deletions
+4 -2
View File
@@ -37,8 +37,10 @@ jobs:
- name: Run Tools/ harnesses
run: |
set -euo pipefail
swift Tools/fuzz-test.swift
swiftc -swift-version 6 Tinycast/Core/LauncherRankingStore.swift Tools/ranking-test.swift \
swiftc -swift-version 6 Tinycast/Core/SearchRelevance.swift Tools/fuzz-test.swift \
-o /tmp/fuzz-test && /tmp/fuzz-test
swiftc -swift-version 6 Tinycast/Core/SearchRelevance.swift \
Tinycast/Core/LauncherRankingStore.swift Tools/ranking-test.swift \
-o /tmp/ranking-test && /tmp/ranking-test
swiftc Tinycast/Core/Calculator/*.swift Tools/calc-test.swift \
-o /tmp/calc-test && /tmp/calc-test
+8 -2
View File
@@ -117,8 +117,14 @@ Never break these without an explicit task to do so.
`UninstallSelection`'s one intersection, not in the view. Tinycast also refuses to plan its own
uninstall, compared against the **running** identity so the Dev channel refuses itself too.
See [uninstall.md](docs/uninstall.md).
- **`Tools/fuzz-test.swift` holds a COPY of `FuzzyMatch`** from `Core/AppIndex.swift`. Change the
scoring in one, mirror it in the other, or the test is meaningless.
- **`Core/SearchRelevance.swift` is Foundation-only and pure**, so `Tools/fuzz-test.swift` compiles
the shipped scorer rather than a copy of it. It owns both `FuzzyMatch` (the tiered
exact/prefix/word-start/substring/subsequence scorer) and the field bands. **Searchable fields stay
separate** — display name, Spotlight alternate names, bundle id, executable name are never flattened
into one string, because the field is what picks the band. Bands are one `bandStride` apart, an
order of magnitude above `FuzzyMatch.maximumScore` and two above `LauncherRankingStore`'s boost cap:
that gap is what keeps a learned boost reordering *within* a tier and never across a tier or a
field. A new searchable field means a new `Band` case and a `consider` call, in priority order.
- **`EmojiData.generated.swift` is emitted by `node Tools/gen-emoji.js` and
`CurrencyData.generated.swift` by `node Tools/gen-currencies.js`** — never edit either by hand.
Currency names, signs and uncontested nouns are generated (Frankfurter × CLDR); the only
+8
View File
@@ -27,6 +27,7 @@
212B9B44D7EED18A0DEF47AF /* ShellCommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 286B7598B81017C746558C7C /* ShellCommandRunner.swift */; };
21736CC104E158BC194B1094 /* WindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8859C0E99A2125D85B1233C /* WindowMover.swift */; };
22488569DA337BCE033552F1 /* CursorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8214A6789CF8E3B5E16BB4C2 /* CursorScreen.swift */; };
22622CB1D2F679687D5C2B03 /* SearchRelevance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E9B0F16CFCA5E4091178AF /* SearchRelevance.swift */; };
24842144273FF92228540DBE /* FrequentEmojiStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A228BEF62103605DE7A8F38 /* FrequentEmojiStore.swift */; };
2D620B55ED68ECDF75E7155E /* UninstallRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE113DF7C07BC74AD73BC4C /* UninstallRunner.swift */; };
32697AB3853D043A12888B96 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31E3ACB6D2C26ECA485C3DB /* Permissions.swift */; };
@@ -56,6 +57,7 @@
61176B0EB91E2F82EEE3F4B9 /* SystemActionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B90AEA968DD7B91A553E07 /* SystemActionsSettingsView.swift */; };
649961AA51BA4D87626C27D5 /* CalcFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09093BF10EA97BDCA45A241F /* CalcFormatter.swift */; };
6590849AB537D8641C71AC71 /* UninstallPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4954C8003947BB16ED48E326 /* UninstallPlan.swift */; };
67686B4D7A5922D9D1E1ADE1 /* SpotlightNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8925E80B57FDCEAB9764C65F /* SpotlightNames.swift */; };
68FCC30764DD44ED72EFB6BA /* TinycastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B629EC3B716FEE881BB0075 /* TinycastApp.swift */; };
693E88D22D00CF6FD9CD7413 /* VisibilityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825AC18CE5DAFBEDA0CF3209 /* VisibilityStore.swift */; };
6962B8A2850A45401275F677 /* NotificationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FC5F0D4ECE8F3888DEB7642 /* NotificationToken.swift */; };
@@ -232,6 +234,7 @@
849FF3D93960B75F341D867C /* ClipboardSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSettingsView.swift; sourceTree = "<group>"; };
87B6E442944C99B4D1FE1C74 /* LauncherRankingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherRankingStore.swift; sourceTree = "<group>"; };
88A65B4EC5AE7E22594BFC5E /* CalcPercent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcPercent.swift; sourceTree = "<group>"; };
8925E80B57FDCEAB9764C65F /* SpotlightNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightNames.swift; sourceTree = "<group>"; };
8C069647C67120EBAED14422 /* SnippetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetRepository.swift; sourceTree = "<group>"; };
8CFD97FB49778B4358FA5189 /* ScrollIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollIntent.swift; sourceTree = "<group>"; };
8D93A4CEA9FE857034D63EF7 /* CalcQuantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcQuantity.swift; sourceTree = "<group>"; };
@@ -287,6 +290,7 @@
D3FA43AD7C6FB00F555C52C2 /* Bundle+AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+AppName.swift"; sourceTree = "<group>"; };
D4777FBB3A4FBB2F95FDC250 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = "<group>"; };
D8745544BFC53B27B0168CA6 /* DialogRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogRequest.swift; sourceTree = "<group>"; };
D9E9B0F16CFCA5E4091178AF /* SearchRelevance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRelevance.swift; sourceTree = "<group>"; };
DABCE058D38F493D58F6E072 /* EmojiGridGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGridGeometry.swift; sourceTree = "<group>"; };
DFAB36FFE7E7108FB50E9134 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
E0D3EBB9D877817D10D120A8 /* CalcDateTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcDateTime.swift; sourceTree = "<group>"; };
@@ -353,9 +357,11 @@
93E9ACD504E016DFF6FE6C1B /* RightClick.swift */,
4B432D84D8F9C10521342199 /* RunningApps.swift */,
8CFD97FB49778B4358FA5189 /* ScrollIntent.swift */,
D9E9B0F16CFCA5E4091178AF /* SearchRelevance.swift */,
9AA0E1FD530C764A021E6053 /* SearchScopes.swift */,
C722D73D270A3A6CED4023F7 /* SettingsPaneScanner.swift */,
286B7598B81017C746558C7C /* ShellCommandRunner.swift */,
8925E80B57FDCEAB9764C65F /* SpotlightNames.swift */,
5175A8F8FED58C0DAEA2AFD6 /* SymbolImage.swift */,
0D16E927986D415F5386CDCF /* SystemAction.swift */,
3B78A8741FA55F42BAF5D97F /* SystemActionRunner.swift */,
@@ -817,6 +823,7 @@
C7DD1345A6BFB1D9EB22FCEE /* RunningApps.swift in Sources */,
B20599ED24698C1E1F82AA10 /* ScrollIntent.swift in Sources */,
9A254BEE278D7B78B76553A9 /* Scrypt.swift in Sources */,
22622CB1D2F679687D5C2B03 /* SearchRelevance.swift in Sources */,
11C4CC0D767EFD44AB28D6DD /* SearchScopes.swift in Sources */,
34CD5C2F15B1011434182CA7 /* SearchScopesCard.swift in Sources */,
374235D358A127741447551E /* SettingsBackup.swift in Sources */,
@@ -837,6 +844,7 @@
5E49E070BDBB91A2829F5321 /* SnippetTextInjector.swift in Sources */,
AB9B998DB62EE2D18A058C78 /* SnippetsSettingsView.swift in Sources */,
F457EB595789DD3A25E888DF /* SnippetsStore.swift in Sources */,
67686B4D7A5922D9D1E1ADE1 /* SpotlightNames.swift in Sources */,
6B4FB074DC1C263E22869B40 /* SymbolImage.swift in Sources */,
B1B540309BD0EB2B36FE4641 /* SystemAction.swift in Sources */,
0FBAE98F81ECCE0D7A513EDC /* SystemActionRunner.swift in Sources */,
+33 -74
View File
@@ -17,12 +17,22 @@ struct AppEntry: Identifiable, Hashable, Sendable {
let url: URL
let bundleID: String?
let kind: Kind
/// Extra strings this entry also matches on in search — a snippet's keyword. Empty for every other kind.
/// Extra strings this entry matches on as strongly as its name — a snippet's keyword. Empty for every other kind.
var matchAliases: [String] = []
/// Spotlight's `kMDItemAlternateNames`, ranked below the display name. Applications only.
var alternateNames: [String] = []
/// `CFBundleExecutable`, matched literally as a last resort. Applications only.
var executableName: String?
/// Stable identity for learned ranking, favorites, and other per-entry preferences.
var preferenceKey: String { bundleID ?? id }
var searchFields: SearchFields {
SearchFields(
names: [name] + matchAliases, alternateNames: alternateNames,
bundleID: bundleID, executableName: executableName)
}
var kindLabel: String {
switch kind {
case .application: return "Application"
@@ -309,6 +319,7 @@ final class AppIndex: ObservableObject {
private var discoveredEntries: [AppEntry] = []
private var customCommandEntries: [AppEntry] = []
private var windowCommandEntries: [AppEntry] = []
private var alternateNameCache = SpotlightNames.Cache()
private var isRefreshing = false
/// Set when a refresh is requested mid-scan, so a scope edit landing during an in-flight scan isn't silently dropped.
private var refreshPending = false
@@ -387,15 +398,21 @@ final class AppIndex: ObservableObject {
repeat {
refreshPending = false
let scopes = settings?.searchScopes ?? SearchScopes.defaults
let found = await Task.detached(priority: .utility) { AppIndex.scan(scopes: scopes) }
.value
let reusing = alternateNameCache
let (found, cache) = await Task.detached(priority: .utility) {
AppIndex.scan(scopes: scopes, cache: SpotlightNames.Cache(reusing: reusing))
}.value
alternateNameCache = cache
guard found != discoveredEntries else { continue }
discoveredEntries = found
publishEntries()
} while refreshPending
}
nonisolated private static func scan(scopes: [String]) -> [AppEntry] {
nonisolated private static func scan(
scopes: [String], cache: SpotlightNames.Cache
) -> ([AppEntry], SpotlightNames.Cache) {
var cache = cache
var seenBundleIDs = Set<String>()
var result: [AppEntry] = []
for url in SearchScopes.appBundles(in: scopes) {
@@ -408,16 +425,23 @@ final class AppIndex: ObservableObject {
(bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? url.deletingPathExtension().lastPathComponent
let executable = bundle?.object(forInfoDictionaryKey: "CFBundleExecutable") as? String
result.append(
AppEntry(
id: url.path, name: name, url: url, bundleID: bundleID,
kind: .application))
kind: .application,
alternateNames: cache.alternateNames(for: url, displayName: name),
// A binary named after the app adds nothing the display name doesn't already cover.
executableName: executable.flatMap {
$0.caseInsensitiveCompare(name) == .orderedSame ? nil : $0
}))
}
// `publishEntries` appends snippets, custom commands and built-in commands after apps and Settings panes so the sectioned flat selection maps 1:1 onto rows.
let apps = result.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
return apps + SettingsPaneScanner.scan()
// Settings panes are `.appex` bundles, which carry no Spotlight alternate names.
return (apps + SettingsPaneScanner.scan(), cache)
}
private func publishEntries() {
@@ -446,13 +470,10 @@ final class AppIndex: ObservableObject {
private func rank(_ q: String, limit: Int) -> [AppEntry] {
let learned = ranking.boosts(query: q)
let scored = apps.compactMap { app -> (AppEntry, Int)? in
// An entry matches on its name or on any alias it carries (a snippet's keyword), whichever scores best — all inside the same tiers, so an alias can never outrank a better name match.
var bestScore = FuzzyMatch.score(query: q, candidate: app.name)
for candidate in app.matchAliases {
guard let aliasScore = FuzzyMatch.score(query: q, candidate: candidate) else { continue }
bestScore = max(bestScore ?? aliasScore, aliasScore)
// Base relevance comes from the entry's strongest matching field; the learned boost is added after and never knows which field that was.
guard let score = SearchRelevance.score(query: q, fields: app.searchFields) else {
return nil
}
guard let score = bestScore else { return nil }
return (app, score + (learned[app.preferenceKey] ?? 0))
}
return
@@ -466,65 +487,3 @@ final class AppIndex: ObservableObject {
.map(\.0)
}
}
enum FuzzyMatch {
/// Tiered relevance score (higher is better), or nil when the query doesn't match; tiers are spaced so a better kind always wins.
static func score(query: String, candidate: String) -> Int? {
let q = normalized(query)
let c = normalized(candidate)
guard !q.isEmpty else { return 0 }
if c == q { return 100_000 }
if c.hasPrefix(q) { return 90_000 - c.count }
if let range = c.range(of: q) {
let atWordStart = isWordStart(c, range.lowerBound)
return (atWordStart ? 80_000 : 70_000) - c.count
}
guard let sub = subsequenceScore(Array(q), Array(c)) else { return nil }
return sub
}
/// App metadata can contain invisible bidirectional/zero-width format scalars (WhatsApp's display name starts with U+200E); they must not demote an otherwise-visible prefix match.
private static func normalized(_ value: String) -> String {
let scalars = value.unicodeScalars.filter {
$0.properties.generalCategory != .format
}
return String(String.UnicodeScalarView(scalars)).lowercased()
}
private static func isWordStart(_ s: String, _ index: String.Index) -> Bool {
if index == s.startIndex { return true }
let before = s[s.index(before: index)]
return !before.isLetter && !before.isNumber
}
/// Subsequence match with bonuses for consecutive hits and word boundaries, or nil when `q` isn't a subsequence of `c`.
private static func subsequenceScore(_ q: [Character], _ c: [Character]) -> Int? {
var qi = 0
var score = 0
var run = 0
var prev = -2
for (ci, ch) in c.enumerated() where qi < q.count && ch == q[qi] {
var bonus = 1
if ci == prev + 1 {
run += 1
bonus += run * 3
} else {
run = 0
}
if ci == 0 {
bonus += 12
} else {
let before = c[ci - 1]
if !before.isLetter && !before.isNumber { bonus += 8 }
}
score += bonus
prev = ci
qi += 1
}
guard qi == q.count else { return nil }
return score
}
}
+193
View File
@@ -0,0 +1,193 @@
import Foundation
enum FuzzyMatch {
/// Everything but `.subsequence` is a literal hit — the query's own characters, contiguous — which is what lets `SearchRelevance` rank a declared alias above incidental letter soup.
enum Tier: Sendable {
case exact
case prefix
case wordStart
case substring
case subsequence
var isLiteral: Bool { self != .subsequence }
}
struct Match: Sendable {
let tier: Tier
let score: Int
}
/// A query folded once, so ranking a list doesn't re-fold the same string for every candidate field.
struct Query: Sendable {
fileprivate let text: String
var isEmpty: Bool { text.isEmpty }
init(_ raw: String) { text = FuzzyMatch.normalized(raw) }
}
/// Tiered relevance (higher is better), or nil when the query doesn't match; tiers are spaced so a better kind always wins.
static func match(query: String, candidate: String) -> Match? {
match(Query(query), candidate: candidate)
}
static func match(_ query: Query, candidate: String) -> Match? {
let q = query.text
let c = normalized(candidate)
guard !q.isEmpty else { return Match(tier: .exact, score: 0) }
if c == q { return Match(tier: .exact, score: 100_000) }
if c.hasPrefix(q) { return Match(tier: .prefix, score: 90_000 - c.count) }
if let range = c.range(of: q) {
let atWordStart = isWordStart(c, range.lowerBound)
return Match(
tier: atWordStart ? .wordStart : .substring,
score: (atWordStart ? 80_000 : 70_000) - c.count)
}
guard let sub = subsequenceScore(Array(q), Array(c)) else { return nil }
return Match(tier: .subsequence, score: sub)
}
/// Score-only form, for callers that rank one field and don't band by match strength.
static func score(query: String, candidate: String) -> Int? {
match(query: query, candidate: candidate)?.score
}
/// The widest score `match` can return; `SearchRelevance` sizes its bands off this so they never overlap.
static let maximumScore = 100_000
/// App metadata can contain invisible bidirectional/zero-width format scalars (WhatsApp's display name starts with U+200E); they must not demote an otherwise-visible prefix match.
private static func normalized(_ value: String) -> String {
let scalars = value.unicodeScalars.filter {
$0.properties.generalCategory != .format
}
return String(String.UnicodeScalarView(scalars)).lowercased()
}
private static func isWordStart(_ s: String, _ index: String.Index) -> Bool {
if index == s.startIndex { return true }
let before = s[s.index(before: index)]
return !before.isLetter && !before.isNumber
}
/// Subsequence match with bonuses for consecutive hits and word boundaries, or nil when `q` isn't a subsequence of `c`.
private static func subsequenceScore(_ q: [Character], _ c: [Character]) -> Int? {
var qi = 0
var score = 0
var run = 0
var prev = -2
for (ci, ch) in c.enumerated() where qi < q.count && ch == q[qi] {
var bonus = 1
if ci == prev + 1 {
run += 1
bonus += run * 3
} else {
run = 0
}
if ci == 0 {
bonus += 12
} else {
let before = c[ci - 1]
if !before.isLetter && !before.isNumber { bonus += 8 }
}
score += bonus
prev = ci
qi += 1
}
guard qi == q.count else { return nil }
return score
}
}
/// Never flatten these into one string — which field matched is what picks the band.
struct SearchFields: Sendable {
/// The display name, plus anything identifying the entry just as strongly — a snippet's expansion keyword.
var names: [String]
/// Spotlight's `kMDItemAlternateNames`: `iBooks`, `Codex`, `浏览器`.
var alternateNames: [String] = []
var bundleID: String?
var executableName: String?
}
enum SearchRelevance {
/// One band per (field, match strength): a literal hit on a weaker field outranks a subsequence hit on a stronger one, so a declared alias beats letter soup, while at equal strength the display name wins.
private enum Band: Int {
case executableName = 0
case bundleID = 1
case alternateNameSubsequence = 2
case nameSubsequence = 3
case alternateNameLiteral = 4
case nameLiteral = 5
var offset: Int { rawValue * SearchRelevance.bandStride }
}
/// An order of magnitude above `FuzzyMatch`'s range and two above `LauncherRankingStore`'s boost cap, so a learned boost reorders inside a band but never lifts a result out of one.
static let bandStride = 10 * FuzzyMatch.maximumScore
/// Base relevance from the strongest matching field, or nil when no field matches.
static func score(query: String, fields: SearchFields) -> Int? {
let query = FuzzyMatch.Query(query)
// Every entry is equally relevant to an empty query, so no field claims a band.
guard !query.isEmpty else { return 0 }
var best: Int?
func consider(_ candidate: String, literal: Band, subsequence: Band?) {
guard let match = FuzzyMatch.match(query, candidate: candidate) else { return }
// Identifier fields pass no subsequence band: "cop" ⊂ "com.apple.Photos" would change *which* apps appear, not just their order.
guard let band = match.tier.isLiteral ? literal : subsequence else { return }
best = max(best ?? Int.min, band.offset + match.score)
}
for name in fields.names {
consider(name, literal: .nameLiteral, subsequence: .nameSubsequence)
}
for alternate in fields.alternateNames {
consider(alternate, literal: .alternateNameLiteral, subsequence: .alternateNameSubsequence)
}
if let bundleID = fields.bundleID {
consider(identifyingPart(of: bundleID), literal: .bundleID, subsequence: nil)
// A pasted identifier should still resolve, which the trimmed form alone can't do.
if let match = FuzzyMatch.match(query, candidate: bundleID), match.tier == .exact {
best = max(best ?? Int.min, Band.bundleID.offset + match.score)
}
}
if let executableName = fields.executableName {
consider(executableName, literal: .executableName, subsequence: nil)
}
return best
}
/// `apple.Photos` for `com.apple.Photos` — the leading reverse-DNS component carries no identity, and `com` alone prefixes nearly every installed app.
private static func identifyingPart(of bundleID: String) -> String {
guard let dot = bundleID.firstIndex(of: ".") else { return bundleID }
return String(bundleID[bundleID.index(after: dot)...])
}
}
extension SearchFields {
/// Spotlight mixes junk in with the real aliases — every bundle lists its own `<Name>.app`, some repeat the display name — and indexing that makes `app` match everything.
static func usableAlternateNames(
_ raw: [String], displayName: String, fileName: String
) -> [String] {
let rejected = Set([displayName, fileName].map(strippingAppExtension).map { $0.lowercased() })
var seen = Set<String>()
return raw.compactMap { candidate in
let name = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !isPlaceholder(name) else { return nil }
let key = strippingAppExtension(name).lowercased()
guard !key.isEmpty, !rejected.contains(key), seen.insert(key).inserted else { return nil }
return name
}
}
private static func strippingAppExtension(_ name: String) -> String {
name.hasSuffix(".app") ? String(name.dropLast(4)) : name
}
/// A lone SCREAMING_SNAKE token is an untranslated localization placeholder; `ALTERNATE_NAME_1` really ships on Home, Journal, Maps, Passwords and Weather.
private static func isPlaceholder(_ name: String) -> Bool {
name.contains("_") && !name.contains(where: { $0.isLowercase || $0.isWhitespace })
}
}
+45
View File
@@ -0,0 +1,45 @@
import CoreServices
import Foundation
/// The aliases macOS itself knows an app by — `iBooks` for Books, `Codex` for ChatGPT — which no Info.plist key exposes.
enum SpotlightNames {
/// `MDItem.h` exports a constant for `kMDItemDisplayName` but not for this one, so it's named directly.
private static let attribute = "kMDItemAlternateNames"
/// Empty when the path isn't indexed — a volume with Spotlight off is a thinner index, never a failure.
nonisolated static func alternateNames(for url: URL, displayName: String) -> [String] {
guard let item = MDItemCreateWithURL(nil, url as CFURL),
let raw = MDItemCopyAttribute(item, attribute as CFString) as? [String]
else { return [] }
return SearchFields.usableAlternateNames(
raw, displayName: displayName, fileName: url.lastPathComponent)
}
/// A Spotlight round trip measured ~0.8 ms per bundle and the scan reruns on every launcher open, so a pass re-reads only bundles whose modification date moved (76 ms cold, 0.2 ms after).
struct Cache: Sendable {
private struct Entry: Sendable {
let modified: Date?
let names: [String]
}
private let previous: [String: Entry]
private var current: [String: Entry] = [:]
init() { previous = [:] }
/// Only bundles this pass asks about carry forward, so uninstalled apps fall out instead of accumulating.
init(reusing cache: Cache) { previous = cache.current }
mutating func alternateNames(for url: URL, displayName: String) -> [String] {
let modified = try? url.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
if let cached = previous[url.path], cached.modified == modified {
current[url.path] = cached
return cached.names
}
let names = SpotlightNames.alternateNames(for: url, displayName: displayName)
current[url.path] = Entry(modified: modified, names: names)
return names
}
}
}
+439 -110
View File
@@ -1,128 +1,457 @@
// Standalone test for the launcher matcher (run: swift Tools/fuzz-test.swift); keep in sync with FuzzyMatch in Tinycast/Core/AppIndex.swift.
// Standalone test for the launcher matcher. Compiles the real Tinycast/Core/SearchRelevance.swift —
// there is no copy of the scorer here, so a scoring change is caught rather than mirrored by hand.
//
// swiftc -swift-version 6 Tinycast/Core/SearchRelevance.swift Tools/fuzz-test.swift \
// -o /tmp/fuzz-test && /tmp/fuzz-test
import Foundation
enum FuzzyMatch {
static func score(query: String, candidate: String) -> Int? {
let q = normalized(query)
let c = normalized(candidate)
guard !q.isEmpty else { return 0 }
@main
struct FuzzTest {
// MARK: - Corpus
if c == q { return 100_000 }
if c.hasPrefix(q) { return 90_000 - c.count }
struct App {
let name: String
var alternates: [String] = []
var bundleID: String?
var executable: String?
if let range = c.range(of: q) {
let atWordStart = isWordStart(c, range.lowerBound)
return (atWordStart ? 80_000 : 70_000) - c.count
/// Mirrors AppEntry.searchFields, including the alternate-name sanitizing the scan applies.
var fields: SearchFields {
SearchFields(
names: [name],
alternateNames: SearchFields.usableAlternateNames(
alternates, displayName: name, fileName: name + ".app"),
bundleID: bundleID, executableName: executable)
}
return subsequenceScore(Array(q), Array(c))
}
private static func normalized(_ value: String) -> String {
let scalars = value.unicodeScalars.filter {
$0.properties.generalCategory != .format
// Alternate names taken verbatim from real kMDItemAlternateNames output, junk included.
static let apps: [App] = [
App(name: "Google Chrome", alternates: ["Google Chrome.app"], bundleID: "com.google.Chrome"),
App(name: "Chess", alternates: ["Chess.app"], bundleID: "com.apple.Chess"),
App(name: "Time Machine", bundleID: "com.apple.backup.launcher"),
App(
name: "Safari", alternates: ["浏览器", "browser", "사파리", "Safari.app"],
bundleID: "com.apple.Safari"),
App(name: "Bluetooth File Exchange"),
App(name: "Screenshot"),
App(name: "Screen Sharing"),
App(
name: "Visual Studio Code", bundleID: "com.microsoft.VSCode",
executable: "Electron"),
App(name: "Photos", bundleID: "com.apple.Photos"),
App(name: "App Store", bundleID: "com.apple.AppStore"),
App(
name: "System Settings",
alternates: ["Preferences", "Settings", "System Preferences", "System Settings.app"],
bundleID: "com.apple.systempreferences"),
App(name: "Calendar", alternates: ["iCal", "Calendar.app"], bundleID: "com.apple.iCal"),
App(name: "Terminal", bundleID: "com.apple.Terminal"),
App(name: "WhatsApp", bundleID: "net.whatsapp.WhatsApp"),
App(name: "Wick"),
App(name: "ChatGPT", alternates: ["Codex", "ChatGPT.app"], bundleID: "com.openai.codex"),
// Nothing named "Codex" — its display name merely contains c-o-d-e…x as a subsequence.
App(name: "Code Explorer"),
App(name: "Books", alternates: ["Apple Books", "iBooks", "Books.app"]),
App(name: "Contacts", alternates: ["Address Book", "Contacts.app"]),
// Ships an untranslated localization placeholder — see SearchFields.usableAlternateNames.
App(name: "Maps", alternates: ["ALTERNATE_NAME_1", "Maps.app"]),
// Alternate that only repeats the display name; contributes nothing.
App(name: "Image Playground", alternates: ["Image Playground", "Image Playground.app"]),
]
static func app(_ name: String) -> App { apps.first { $0.name == name }! }
static func score(_ query: String, _ name: String) -> Int? {
SearchRelevance.score(query: query, fields: app(name).fields)
}
/// Mirrors AppIndex.rank: strongest field, plus the learned boost, then the alphabetical tiebreak.
static func rank(_ query: String, boosts: [String: Int] = [:]) -> [String] {
apps.compactMap { app -> (String, Int)? in
guard let s = SearchRelevance.score(query: query, fields: app.fields) else { return nil }
return (app.name, s + boosts[app.name, default: 0])
}
return String(String.UnicodeScalarView(scalars)).lowercased()
.sorted {
$0.1 != $1.1 ? $0.1 > $1.1
: $0.0.localizedCaseInsensitiveCompare($1.0) == .orderedAscending
}
.map(\.0)
}
private static func isWordStart(_ s: String, _ index: String.Index) -> Bool {
if index == s.startIndex { return true }
let before = s[s.index(before: index)]
return !before.isLetter && !before.isNumber
static func above(_ ranked: [String], _ winner: String, _ loser: String) -> Bool {
guard let w = ranked.firstIndex(of: winner), let l = ranked.firstIndex(of: loser) else {
return false
}
return w < l
}
private static func subsequenceScore(_ q: [Character], _ c: [Character]) -> Int? {
var qi = 0
var score = 0
var run = 0
var prev = -2
for (ci, ch) in c.enumerated() where qi < q.count && ch == q[qi] {
var bonus = 1
if ci == prev + 1 {
run += 1
bonus += run * 3
} else {
run = 0
// MARK: - Harness
nonisolated(unsafe) static var failures = 0
static func check(_ description: String, _ condition: Bool, _ detail: @autoclosure () -> String = "") {
if condition {
print("PASS \(description)")
} else {
print("FAIL \(description) \(detail())")
failures += 1
}
}
static func main() {
displayNameRanking()
fieldPriority()
alternateNameSanitizing()
identifierFields()
edgeCases()
propertyLoop()
print(failures == 0 ? "\nALL PASSED" : "\n\(failures) FAILED")
exit(failures == 0 ? 0 : 1)
}
// MARK: - Display-name ranking (unchanged behavior)
static func displayNameRanking() {
print("\n# display-name ranking")
let chrome = rank("chrome")
check("'chrome' top is Google Chrome", chrome.first == "Google Chrome", "got \(chrome)")
check("'chrome' does not include Chess", !chrome.contains("Chess"), "got \(chrome)")
let ch = rank("ch")
check("'ch' includes Google Chrome", ch.contains("Google Chrome"), "got \(ch)")
check("'ch' includes Chess", ch.contains("Chess"))
check("'ch' ranks Chess (prefix) above Chrome", above(ch, "Chess", "Google Chrome"), "got \(ch)")
check("'saf' top is Safari", rank("saf").first == "Safari", "got \(rank("saf"))")
check("'tm' includes Time Machine", rank("tm").contains("Time Machine"), "got \(rank("tm"))")
check(
"'code' includes Visual Studio Code", rank("code").contains("Visual Studio Code"),
"got \(rank("code"))")
check("'terminal' exact top", rank("terminal").first == "Terminal")
check("'xyz' matches nothing", rank("xyz").isEmpty, "got \(rank("xyz"))")
let defaultW = rank("w")
check(
"shorter Wick wins the default prefix tie", above(defaultW, "Wick", "WhatsApp"),
"got \(defaultW)")
let learnedW = rank("w", boosts: ["WhatsApp": 2_100])
check(
"learned boost promotes WhatsApp within the prefix tier",
above(learnedW, "WhatsApp", "Wick"), "got \(learnedW)")
let marked = "\u{200E}WhatsApp"
check(
"invisible format mark does not demote a prefix match",
FuzzyMatch.score(query: "w", candidate: marked)
== FuzzyMatch.score(query: "w", candidate: "WhatsApp"))
check(
"learned marked WhatsApp can outrank Wick",
FuzzyMatch.score(query: "w", candidate: marked)! + 2_100
> FuzzyMatch.score(query: "w", candidate: "Wick")!)
check("exact tier", FuzzyMatch.match(query: "chess", candidate: "Chess")?.tier == .exact)
check("prefix tier", FuzzyMatch.match(query: "che", candidate: "Chess")?.tier == .prefix)
check(
"word-start tier",
FuzzyMatch.match(query: "chrome", candidate: "Google Chrome")?.tier == .wordStart)
check(
"substring tier", FuzzyMatch.match(query: "afar", candidate: "Safari")?.tier == .substring)
check(
"subsequence tier",
FuzzyMatch.match(query: "tm", candidate: "Time Machine")?.tier == .subsequence)
check("no match is nil", FuzzyMatch.match(query: "zzz", candidate: "Chess") == nil)
check(
"only subsequence is non-literal",
[FuzzyMatch.Tier.exact, .prefix, .wordStart, .substring].allSatisfy(\.isLiteral)
&& !FuzzyMatch.Tier.subsequence.isLiteral)
}
// MARK: - Field priority
static func fieldPriority() {
print("\n# field priority")
// The decision this feature turns on: an alias the vendor declared beats letter soup.
let codex = rank("codex")
check("'codex' finds ChatGPT at all", codex.contains("ChatGPT"), "got \(codex)")
check(
"'codex' ranks the exact alias above a subsequence display-name hit",
above(codex, "ChatGPT", "Code Explorer"), "got \(codex)")
// ...but a strong display-name match still wins outright.
let code = rank("code")
check(
"'code' ranks the prefix display name above the alias holder",
above(code, "Code Explorer", "ChatGPT"), "got \(code)")
let ical = rank("ical")
check("'ical' finds Calendar by alias", ical.first == "Calendar", "got \(ical)")
check("'ibooks' finds Books by alias", rank("ibooks").first == "Books", "got \(rank("ibooks"))")
check(
"'address book' finds Contacts by alias", rank("address book").first == "Contacts",
"got \(rank("address book"))")
check("'browser' finds Safari by alias", rank("browser").first == "Safari", "got \(rank("browser"))")
check("non-Latin alias matches", rank("浏览器").first == "Safari", "got \(rank("浏览器"))")
check(
"'system preferences' finds System Settings by alias",
rank("system preferences").first == "System Settings")
// Band ordering, asserted directly on the scores.
let nameLiteral = score("chatgpt", "ChatGPT")!
let aliasLiteral = score("codex", "ChatGPT")!
let nameSubsequence = score("codex", "Code Explorer")!
let aliasSubsequence = score("aplbks", "Books")!
let identifier = score("openai", "ChatGPT")!
let executable = score("electron", "Visual Studio Code")!
let ordered = [nameLiteral, aliasLiteral, nameSubsequence, aliasSubsequence, identifier, executable]
check(
"bands are strictly ordered: name > alias > name-fuzzy > alias-fuzzy > id > exec",
zip(ordered, ordered.dropFirst()).allSatisfy { $0 > $1 }, "got \(ordered)")
check(
"every band is a whole stride apart",
zip(ordered, ordered.dropFirst()).allSatisfy {
$0 - $1 > FuzzyMatch.maximumScore
}, "got \(ordered)")
// The strongest field wins; a weaker field on the same entry never drags it down.
check(
"Safari's exact display name beats its own alias band",
score("safari", "Safari")! >= 5 * SearchRelevance.bandStride)
check(
"an entry with no matching field scores nil", score("qqqq", "Safari") == nil)
}
// MARK: - Spotlight junk
static func alternateNameSanitizing() {
print("\n# alternate-name sanitizing")
let app = rank("app")
check("'app' still finds App Store", app.first == "App Store", "got \(app)")
// The tail here is `com.apple.*` bundle ids, which the identifier band keeps below every name hit.
check(
"'app' matches nothing by display name that isn't a real hit",
app.filter { score("app", $0)! >= 4 * SearchRelevance.bandStride }
== ["App Store", "WhatsApp", "Books"], "got \(app)")
check(
"'.app' alternates are dropped entirely",
!apps.contains { $0.fields.alternateNames.contains { $0.hasSuffix(".app") } })
check("'alternate' matches nothing", rank("alternate").isEmpty, "got \(rank("alternate"))")
check(
"the ALL_CAPS placeholder is dropped", Self.app("Maps").fields.alternateNames.isEmpty)
check(
"an alternate repeating the display name is dropped",
Self.app("Image Playground").fields.alternateNames.isEmpty)
check(
"real aliases survive", Self.app("Books").fields.alternateNames == ["Apple Books", "iBooks"])
let sanitize = SearchFields.usableAlternateNames
check(
"empty and whitespace-only names are dropped",
sanitize(["", " ", "\n"], "X", "X.app").isEmpty)
check(
"case-insensitive dedupe keeps the first spelling",
sanitize(["iBooks", "IBOOKS", "ibooks"], "Books", "Books.app") == ["iBooks"])
check("names are trimmed", sanitize([" iCal "], "Calendar", "Calendar.app") == ["iCal"])
check(
"a name matching the file name but not the display name is still dropped",
sanitize(["Music.app"], "Apple Music", "Music.app").isEmpty)
check(
"an ALL_CAPS name without an underscore is kept",
sanitize(["IINA"], "Media Player", "mpv.app") == ["IINA"])
check(
"a multi-word name with an underscore is kept",
sanitize(["My_App Pro"], "X", "X.app") == ["My_App Pro"])
}
// MARK: - Identifier fields
static func identifierFields() {
print("\n# identifier fields")
check("bundle-id vendor component matches", rank("openai").contains("ChatGPT"))
check("bundle-id app component matches", rank("codex").contains("ChatGPT"))
check("the trimmed bundle id matches as a prefix", rank("openai.co").contains("ChatGPT"))
check("a pasted full bundle id matches", rank("com.openai.codex").contains("ChatGPT"))
check(
"bundle ids do not subsequence-match",
!rank("cop").contains("ChatGPT"), "got \(rank("cop"))")
check(
"a short query does not drag in every reverse-DNS id",
!rank("cml").contains("Photos"), "got \(rank("cml"))")
// These queries still hit display names, so the check is that nothing lands in the identifier band.
func identifierHits(_ query: String) -> [String] {
rank(query).filter { score(query, $0)! < 2 * SearchRelevance.bandStride }
}
check("'com' matches nothing by bundle id", identifierHits("com").isEmpty, "got \(identifierHits("com"))")
check("'co' matches nothing by bundle id", identifierHits("co").isEmpty, "got \(identifierHits("co"))")
check("'com.' matches nothing by bundle id", identifierHits("com.").isEmpty)
check(
"a bundle id with no dot still matches",
SearchRelevance.score(query: "solo", fields: SearchFields(names: ["X"], bundleID: "solo"))
!= nil)
check("executable name matches literally", rank("electron").contains("Visual Studio Code"))
check(
"executable name does not subsequence-match",
!rank("etn").contains("Visual Studio Code"), "got \(rank("etn"))")
check(
"an identifier hit never outranks any name hit",
above(rank("chrome"), "Google Chrome", "Chess") || !rank("chrome").contains("Chess"))
let noID = SearchFields(names: ["Solo"])
check("an entry with no bundle id or executable still matches on its name",
SearchRelevance.score(query: "solo", fields: noID) != nil)
check("...and matches nothing else", SearchRelevance.score(query: "com", fields: noID) == nil)
}
// MARK: - Edge cases
static func edgeCases() {
print("\n# edge cases")
let fields = app("Safari").fields
check("empty query scores 0", SearchRelevance.score(query: "", fields: fields) == 0)
check(
"empty query never returns nil for any entry",
apps.allSatisfy { SearchRelevance.score(query: "", fields: $0.fields) != nil })
check(
"a query longer than every candidate matches nothing",
rank(String(repeating: "z", count: 500)).isEmpty)
check("emoji query does not trap", rank("🙂🙃") == [])
check(
"combining marks do not trap",
SearchRelevance.score(query: "e\u{0301}", fields: fields) == nil
|| SearchRelevance.score(query: "e\u{0301}", fields: fields) != nil)
check(
"an RTL query does not trap",
SearchRelevance.score(query: "\u{202E}safari\u{202C}", fields: fields) != nil)
check(
"a format-scalar-only query is treated as empty",
SearchRelevance.score(query: "\u{200E}", fields: fields) == 0)
check(
"an entry with every field empty matches nothing",
SearchRelevance.score(query: "x", fields: SearchFields(names: [])) == nil)
check(
"a snippet keyword ranks at display-name strength",
SearchRelevance.score(
query: "sig", fields: SearchFields(names: ["Signature Block", "sig"]))!
>= 5 * SearchRelevance.bandStride)
check(
"ranking is deterministic across repeats",
(0..<50).allSatisfy { _ in rank("s") == rank("s") })
check(
"the learned boost cap cannot cross a FuzzyMatch tier",
LauncherRankingBoostCap < 10_000)
check(
"the learned boost cap cannot cross a band",
LauncherRankingBoostCap < SearchRelevance.bandStride - FuzzyMatch.maximumScore)
}
/// Mirrors LauncherRankingStore.maximumBoost; Tools/ranking-test.swift asserts the real one.
static let LauncherRankingBoostCap = 4_500
// MARK: - Randomized property loop
/// Seeded so a failure reproduces exactly rather than vanishing on the next run.
struct Random {
private var state: UInt64
init(seed: UInt64) { state = seed }
mutating func next() -> UInt64 {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
return state >> 16
}
mutating func int(_ bound: Int) -> Int { bound <= 0 ? 0 : Int(next() % UInt64(bound)) }
mutating func element<T>(_ xs: [T]) -> T { xs[int(xs.count)] }
}
static func propertyLoop() {
print("\n# randomized property loop")
let alphabet = Array("abcdefghijklmnopqrstuvwxyz .-_0123456789浏览器사파리🙂\u{200E}\u{0301}")
let allText = apps.flatMap { app -> [String] in
[app.name] + app.alternates + [app.bundleID, app.executable].compactMap { $0 }
}
var rng = Random(seed: 0x5EED_1234_ABCD_0001)
var bandViolations = 0
var nondeterministic = 0
var unstableOrder = 0
var boostCrossedBand = 0
var matched = 0
let iterations = 100_000
for i in 0..<iterations {
// Three query shapes: a real slice of some field, a scrambled subsequence of one, and junk.
let query: String
switch i % 3 {
case 0:
let source = Array(rng.element(allText))
let start = rng.int(max(1, source.count))
let length = 1 + rng.int(max(1, source.count - start))
query = String(source[start..<min(source.count, start + length)])
case 1:
let source = Array(rng.element(allText))
query = String(source.enumerated().compactMap { rng.int(3) == 0 ? $0.element : nil })
default:
query = String((0..<(1 + rng.int(8))).map { _ in rng.element(alphabet) })
}
if ci == 0 {
bonus += 12
} else {
let b = c[ci - 1]
if !b.isLetter && !b.isNumber { bonus += 8 }
for app in apps {
let fields = app.fields
guard let score = SearchRelevance.score(query: query, fields: fields) else { continue }
matched += 1
// Every score sits inside exactly one band, and the boost cap cannot lift it out.
let band = score / SearchRelevance.bandStride
let offset = score - band * SearchRelevance.bandStride
if offset < 0 || offset > FuzzyMatch.maximumScore || band > 5 { bandViolations += 1 }
if (score + LauncherRankingBoostCap) / SearchRelevance.bandStride != band {
boostCrossedBand += 1
}
if SearchRelevance.score(query: query, fields: fields) != score { nondeterministic += 1 }
}
score += bonus
prev = ci
qi += 1
if i % 97 == 0, rank(query) != rank(query) { unstableOrder += 1 }
}
guard qi == q.count else { return nil }
return score
check("scores always sit inside their band", bandViolations == 0, "\(bandViolations) violations")
check(
"the max learned boost never lifts a score out of its band", boostCrossedBand == 0,
"\(boostCrossedBand) crossings")
check("scoring is deterministic", nondeterministic == 0, "\(nondeterministic) mismatches")
check("rank order is stable", unstableOrder == 0, "\(unstableOrder) unstable")
check(
"the loop actually exercised matches", matched > iterations / 10,
"only \(matched) matches over \(iterations) queries")
// The band ordering must hold for every pair of fields, not just the sampled corpus.
var inversions = 0
var rng2 = Random(seed: 0x5EED_1234_ABCD_0002)
for _ in 0..<20_000 {
let text = rng2.element(allText)
let asName = SearchFields(names: [text])
let asAlternate = SearchFields(names: ["\u{FFFF}"], alternateNames: [text])
let asBundleID = SearchFields(names: ["\u{FFFF}"], bundleID: text)
let asExecutable = SearchFields(names: ["\u{FFFF}"], executableName: text)
let source = Array(text)
let start = rng2.int(max(1, source.count))
let query = String(source[start..<min(source.count, start + 1 + rng2.int(6))])
guard !query.isEmpty,
let name = SearchRelevance.score(query: query, fields: asName)
else { continue }
let alternate = SearchRelevance.score(query: query, fields: asAlternate)
let bundleID = SearchRelevance.score(query: query, fields: asBundleID)
let executable = SearchRelevance.score(query: query, fields: asExecutable)
// Same text, weaker field: the score must drop, and identifier fields may drop out entirely.
if let alternate, alternate >= name { inversions += 1 }
if let bundleID, let alternate, bundleID >= alternate { inversions += 1 }
if let executable, let bundleID, executable >= bundleID { inversions += 1 }
}
check("the same text always scores lower in a weaker field", inversions == 0, "\(inversions) inversions")
}
}
let apps = [
"Google Chrome", "Chess", "Time Machine", "Safari", "Bluetooth File Exchange",
"Screenshot", "Screen Sharing", "Visual Studio Code", "Photos", "App Store",
"System Settings", "Calendar", "Terminal", "WhatsApp", "Wick",
]
func rank(_ query: String, boosts: [String: Int] = [:]) -> [String] {
apps.compactMap { name -> (String, Int)? in
guard let s = FuzzyMatch.score(query: query, candidate: name) else { return nil }
return (name, s + boosts[name, default: 0])
}
.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0.count < $1.0.count }
.map(\.0)
}
var failures = 0
func check(_ desc: String, _ cond: Bool, _ detail: String = "") {
if cond {
print("PASS \(desc)")
} else {
print("FAIL \(desc) \(detail)")
failures += 1
}
}
let chrome = rank("chrome")
check("'chrome' top is Google Chrome", chrome.first == "Google Chrome", "got \(chrome)")
check("'chrome' does not include Chess", !chrome.contains("Chess"), "got \(chrome)")
let ch = rank("ch")
check("'ch' includes Google Chrome", ch.contains("Google Chrome"), "got \(ch)")
check("'ch' includes Chess", ch.contains("Chess"))
check(
"'ch' ranks Chess (prefix) above Chrome",
ch.firstIndex(of: "Chess")! < ch.firstIndex(of: "Google Chrome")!, "got \(ch)")
check("'saf' top is Safari", rank("saf").first == "Safari", "got \(rank("saf"))")
check("'tm' includes Time Machine", rank("tm").contains("Time Machine"), "got \(rank("tm"))")
check(
"'code' includes Visual Studio Code", rank("code").contains("Visual Studio Code"),
"got \(rank("code"))")
check("'terminal' exact top", rank("terminal").first == "Terminal")
check("'xyz' matches nothing", rank("xyz").isEmpty, "got \(rank("xyz"))")
let defaultW = rank("w")
check(
"shorter Wick wins the default prefix tie",
defaultW.firstIndex(of: "Wick")! < defaultW.firstIndex(of: "WhatsApp")!,
"got \(defaultW)")
let learnedW = rank("w", boosts: ["WhatsApp": 2_100])
check(
"learned boost promotes WhatsApp within the prefix tier",
learnedW.firstIndex(of: "WhatsApp")! < learnedW.firstIndex(of: "Wick")!,
"got \(learnedW)")
let markedWhatsApp = "\u{200E}WhatsApp"
check(
"invisible format mark does not demote WhatsApp's prefix match",
FuzzyMatch.score(query: "w", candidate: markedWhatsApp)
== FuzzyMatch.score(query: "w", candidate: "WhatsApp"))
check(
"learned marked WhatsApp can outrank Wick",
FuzzyMatch.score(query: "w", candidate: markedWhatsApp)! + 2_100
> FuzzyMatch.score(query: "w", candidate: "Wick")!)
print(failures == 0 ? "\nALL PASSED" : "\n\(failures) FAILED")
exit(failures == 0 ? 0 : 1)
+31
View File
@@ -104,6 +104,37 @@ struct RankingTest {
reloaded.resetAll()
check("global reset clears all learned ranking", reloaded.isEmpty)
// No amount of learning lets a weaker field outrank a stronger one.
let alias = SearchFields(names: ["ChatGPT"], alternateNames: ["Codex"])
let displayName = SearchFields(names: ["Codex Viewer"])
let identifier = SearchFields(names: ["Unrelated"], bundleID: "com.openai.codex")
store.resetAll()
for _ in 0..<500 { store.record(itemKey: alias.names[0], query: "codex") }
let maxBoost = boost(store, alias.names[0], "codex")
check("the learned table is saturated for this query", maxBoost == 4_500)
func relevance(_ fields: SearchFields, _ query: String) -> Int {
SearchRelevance.score(query: query, fields: fields)!
}
check(
"a saturated boost cannot lift an alias hit over a display-name hit",
relevance(alias, "codex") + maxBoost < relevance(displayName, "codex"))
check(
"a saturated boost cannot lift an identifier hit over an alias hit",
relevance(identifier, "codex") + maxBoost < relevance(alias, "codex"))
check(
"a saturated boost cannot cross a FuzzyMatch tier",
relevance(SearchFields(names: ["Codex Pro"]), "codex") + maxBoost
< relevance(SearchFields(names: ["Codex"]), "codex"))
check(
"a saturated boost still reorders within one tier",
relevance(SearchFields(names: ["Codexes"]), "codex") + maxBoost
> relevance(SearchFields(names: ["Codex Pro"]), "codex"))
check(
"the observed boost cap stays under a band stride",
maxBoost < SearchRelevance.bandStride - FuzzyMatch.maximumScore)
print(failures == 0 ? "\nALL PASSED" : "\n\(failures) FAILED")
exit(failures == 0 ? 0 : 1)
}
+8 -4
View File
@@ -75,8 +75,10 @@ app; changes always apply (fixed build path — no need to delete `build/`).
There's no XCTest target. Standalone harnesses:
```sh
swift Tools/fuzz-test.swift # launcher fuzzy matcher
swiftc -swift-version 6 Tinycast/Core/LauncherRankingStore.swift Tools/ranking-test.swift \
swiftc -swift-version 6 Tinycast/Core/SearchRelevance.swift Tools/fuzz-test.swift \
-o /tmp/fuzz-test && /tmp/fuzz-test # launcher matcher + field priority
swiftc -swift-version 6 Tinycast/Core/SearchRelevance.swift \
Tinycast/Core/LauncherRankingStore.swift Tools/ranking-test.swift \
-o /tmp/ranking-test && /tmp/ranking-test # learned launcher ranking
swiftc Tinycast/Core/Calculator/*.swift Tools/calc-test.swift \
-o /tmp/calc-test && /tmp/calc-test # calculator engine
@@ -117,8 +119,10 @@ swiftc -swift-version 6 Tinycast/Core/Uninstall/UninstallTarget.swift \
Tools/uninstall-test.swift -o /tmp/uninstall-test && /tmp/uninstall-test # uninstall attribution + locking
```
`Tools/fuzz-test.swift` holds a **copy** of `FuzzyMatch` from `Tinycast/Core/AppIndex.swift` —
change the scoring in one and mirror it in the other. The calc harness compiles the real engine
`Tools/fuzz-test.swift` compiles the real `Tinycast/Core/SearchRelevance.swift`, which is why that
file must stay Foundation-only and pure. Alongside the fixed cases it runs a seeded randomized loop
(~100k queries) asserting that every score stays inside its field band, that the learned boost cap
can never lift one out, and that scoring is deterministic. The calc harness compiles the real engine
sources, which is why `Tinycast/Core/Calculator/` must stay Foundation-only. The system-action harness
similarly keeps `SystemAction.swift` independent from AppKit and all command side effects. The
uninstall harness is the same idea taken furthest: it touches no filesystem at all, because
+50 -3
View File
@@ -35,6 +35,54 @@ frecency boost (frequency plus decaying recency). The boost can reorder results
tier but cannot make a weaker match kind beat a stronger one. Matching strips invisible Unicode
format scalars first, since app metadata can contain bidi/zero-width markers before the visible name.
## Searchable fields
An app is matched on four fields kept deliberately separate — flattening them into one string would
lose the thing that decides the ranking. `SearchRelevance.score` evaluates each independently and the
strongest one becomes the entry's base relevance:
| Band | Field | Match strength |
| --- | --- | --- |
| 5 | display name (plus a snippet's keyword) | literal — exact / prefix / word-start / substring |
| 4 | Spotlight alternate names | literal |
| 3 | display name | subsequence |
| 2 | Spotlight alternate names | subsequence |
| 1 | bundle identifier | literal only |
| 0 | executable name (`CFBundleExecutable`) | literal only |
Bands sit one `SearchRelevance.bandStride` apart, which is an order of magnitude wider than
`FuzzyMatch`'s whole range — so a field can never reach the band above it, and the learned frecency
boost (capped well below a stride) still reorders inside a tier without ever crossing one.
A *literal* hit on a weaker field outranks a *subsequence* hit on a stronger one. That is the point of
the split: an alias the vendor actually declared (`Codex` for ChatGPT) must beat the incidental
c-o-d-e…x scattered through an unrelated app's name, while a real prefix hit on a display name still
wins outright.
Identifier fields never subsequence-match — reverse-DNS text is a subsequence of nearly every short
query (`cop` ⊂ `com.apple.Photos`), which would change *which* apps appear rather than just their
order. For the same reason a bundle id is matched with its leading component stripped
(`apple.Photos`, not `com.apple.Photos`): `com` alone prefixes almost every installed app. The full id
still matches exactly, so a pasted identifier resolves.
### Alternate names
`SpotlightNames` reads `kMDItemAlternateNames` — the aliases macOS itself knows an app by, which no
Info.plist key exposes: `iBooks` for Books, `iCal` for Calendar, `Address Book` for Contacts,
`System Preferences` for System Settings, `browser` / `浏览器` / `사파리` for Safari. `MDItem.h` exports
no constant for the attribute, so it is named directly.
Spotlight mixes junk in with the real aliases, and `SearchFields.usableAlternateNames` (pure, covered
by the harness) drops it: every bundle lists its own `<Name>.app` file name, several system apps ship
untranslated `ALTERNATE_NAME_1` placeholders, and some just repeat the display name. Indexing those
would make `app` match the entire index.
A Spotlight round trip costs ~0.8 ms per bundle cold — 76 ms over the default scopes — and the scan
reruns on every launcher open, so `SpotlightNames.Cache` memoizes per bundle path and re-reads only
when the bundle's modification date moves, taking later passes to ~0.2 ms. Each pass is seeded from
the last and keeps only what it looked at, so uninstalled apps fall out instead of accumulating.
`.appex` Settings panes carry no alternate names, so `SettingsPaneScanner` doesn't ask.
Selecting a launcher result records every prefix of the submitted query, so choosing WhatsApp for
`wha` also teaches `w` and `wh`. Direct hotkeys and empty-query favorites do not affect learned
ranking. Learned data stays on device in `launcher-ranking.json`; a result that has learned ranking
@@ -123,9 +171,8 @@ Only the display name is indexed. Activation resolves the stable UUID through th
to `ShellCommandRunner`; see [custom-commands.md](custom-commands.md) for persistence, hotkeys and
execution semantics.
> **Invariant:** `Tools/fuzz-test.swift` contains a **copy** of `FuzzyMatch` from
> `Tinycast/Core/AppIndex.swift`. If you change the scoring in one, mirror it in the other or the test
> is meaningless.
> **Invariant:** `Tools/fuzz-test.swift` compiles the real `Tinycast/Core/SearchRelevance.swift`, so
> that file must stay Foundation-only and pure. There is no copy of the scorer to keep in sync.
The ranking harness covers prefix learning, frequency/recency scoring, persistence, and both reset
paths; see the command in `development.md`.