mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user