diff --git a/RunAnywhereAI/Features/Models/ModelOrg.swift b/RunAnywhereAI/Features/Models/ModelOrg.swift index b309184b..989a7bf9 100644 --- a/RunAnywhereAI/Features/Models/ModelOrg.swift +++ b/RunAnywhereAI/Features/Models/ModelOrg.swift @@ -17,8 +17,10 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable { case alibaba case google case microsoft + case ibm case deepseek case liquid + case deepgrove case mistral case prism case openAI @@ -35,8 +37,10 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable { case .alibaba: return "Alibaba" case .google: return "Google" case .microsoft: return "Microsoft" + case .ibm: return "IBM" case .deepseek: return "DeepSeek" case .liquid: return "Liquid AI" + case .deepgrove: return "Deepgrove" case .mistral: return "Mistral AI" case .prism: return "Prism" case .openAI: return "OpenAI" @@ -53,8 +57,10 @@ enum ModelOrg: String, CaseIterable, Identifiable, Comparable { case .alibaba: return "q.circle.fill" case .google: return "g.circle.fill" case .microsoft: return "m.square.fill" + case .ibm: return "cpu" case .deepseek: return "brain.head.profile" case .liquid: return "drop.fill" + case .deepgrove: return "leaf.fill" case .mistral: return "wind" case .prism: return "triangle.fill" case .openAI: return "waveform" @@ -103,9 +109,15 @@ enum ModelOrgCatalog { ]), OrgRule(org: .deepseek, patterns: ["deepseek"]), OrgRule(org: .prism, patterns: ["bonsai"]), - OrgRule(org: .microsoft, patterns: ["phi"]), + OrgRule(org: .deepgrove, patterns: ["maple"]), + OrgRule(org: .ibm, patterns: ["granite"]), + // `fara` above Microsoft's `phi` only so the two cannot fight if Fara is + // ever renamed. Fara1.5 ships mirrored under our own HF org, so the + // catalog row does not name a publisher; this files it by its own name + // rather than guessing one into a UI label. + OrgRule(org: .microsoft, patterns: ["phi", "fara"]), OrgRule(org: .google, patterns: ["gemma", "embeddinggemma", "siglip"]), - OrgRule(org: .meta, patterns: ["llama"]), + OrgRule(org: .meta, patterns: ["llama", "muse-glimmer", "muse_glimmer"]), OrgRule(org: .alibaba, patterns: ["qwen"]), OrgRule(org: .liquid, patterns: ["lfm2"]), OrgRule(org: .mistral, patterns: ["mistral"]), diff --git a/RunAnywhereAI/Features/Models/ModelRecommendation.swift b/RunAnywhereAI/Features/Models/ModelRecommendation.swift index 6f83a2a1..db968082 100644 --- a/RunAnywhereAI/Features/Models/ModelRecommendation.swift +++ b/RunAnywhereAI/Features/Models/ModelRecommendation.swift @@ -80,9 +80,10 @@ struct ModelRecommendationEngine { let recommendedLLMs = pickModels( ids: prefs.llmIDs, + category: .language, from: byID, canRunByModelID: canRunByModelID, - limit: tier == .highEnd ? 5 : 4 + limit: 5 ) let appleFoundation = appleFoundationAvailable @@ -94,10 +95,31 @@ struct ModelRecommendationEngine { return RecommendedSelection( defaultChatModel: defaultChat, recommendedLLMs: recommendedLLMs, - recommendedASR: pickFirst(ids: prefs.asrIDs, from: byID, canRunByModelID: canRunByModelID), - recommendedTTS: pickFirst(ids: prefs.ttsIDs, from: byID, canRunByModelID: canRunByModelID), - recommendedVLM: pickFirst(ids: prefs.vlmIDs, from: byID, canRunByModelID: canRunByModelID), - recommendedEmbedding: pickFirst(ids: prefs.embeddingIDs, from: byID, canRunByModelID: canRunByModelID) + recommendedASR: pickFirst( + ids: prefs.asrIDs, + category: .speechRecognition, + from: byID, + canRunByModelID: canRunByModelID + ), + recommendedTTS: pickFirst( + ids: prefs.ttsIDs, + category: .speechSynthesis, + from: byID, + canRunByModelID: canRunByModelID + ), + recommendedVLM: pickFirst( + ids: prefs.vlmIDs, + category: .multimodal, + secondaryCategory: .vision, + from: byID, + canRunByModelID: canRunByModelID + ), + recommendedEmbedding: pickFirst( + ids: prefs.embeddingIDs, + category: .embedding, + from: byID, + canRunByModelID: canRunByModelID + ) ) } @@ -117,13 +139,24 @@ struct ModelRecommendationEngine { ? models.first { $0.isAppleFoundationModel && $0.category == .language } : nil let llm = appleFoundation - ?? pickFirst(ids: prefs.llmIDs, from: byID, canRunByModelID: canRunByModelID) + ?? pickFirst(ids: prefs.llmIDs, category: .language, from: byID, canRunByModelID: canRunByModelID) return VoicePipeline( - stt: pickFirst(ids: prefs.asrIDs, from: byID, canRunByModelID: canRunByModelID), + stt: pickFirst( + ids: prefs.asrIDs, + category: .speechRecognition, + from: byID, + canRunByModelID: canRunByModelID + ), llm: llm, - tts: pickFirst(ids: prefs.ttsIDs, from: byID, canRunByModelID: canRunByModelID), + tts: pickFirst( + ids: prefs.ttsIDs, + category: .speechSynthesis, + from: byID, + canRunByModelID: canRunByModelID + ), vad: byID[Self.vadModelID] + ?? byID.values.first { $0.category == .voiceActivityDetection } ) } @@ -133,26 +166,62 @@ struct ModelRecommendationEngine { // MARK: - Selection helpers /// Keep the ordered ids that exist in the catalog and pass can_run (when - /// known), up to `limit`. Preserves the curated order (light → smart). + /// known), up to `limit`, then back-fill from the category if the curated + /// list came up short. + /// + /// The back-fill is not a nicety. Curated ids are the app's opinion about + /// which models are good; the catalog is edited on its own schedule, in its + /// own PR, usually by someone not reading this file. Without a floor, a + /// catalog pass that renames or drops a family silently turns the Models + /// screen into a screen that recommends nothing — which is exactly what the + /// 0.20.24 catalog rebuild did here, taking all five reachable ids with it. + /// Mirrors Android `ModelRecommendation.pickLLMs`. private func pickModels( ids: [String], + category: RAModelCategory, from byID: [String: RAModelInfo], canRunByModelID: [String: Bool], limit: Int ) -> [RAModelInfo] { var picked: [RAModelInfo] = [] + var pickedIDs = Set() + for id in ids { guard picked.count < limit else { break } if let model = byID[id], isRunnable(model, canRunByModelID: canRunByModelID) { picked.append(model) + pickedIDs.insert(model.id) } } + + guard picked.count < minimumRecommendations else { return picked } + + // Smallest first, so a back-filled list still opens with something the + // device can plausibly run rather than with the biggest file present. + let backfill = byID.values + .filter { $0.category == category } + .filter { !pickedIDs.contains($0.id) } + .filter { isRunnable($0, canRunByModelID: canRunByModelID) } + .sorted { $0.consumerSizeBytes < $1.consumerSizeBytes } + + for model in backfill where picked.count < limit { + picked.append(model) + pickedIDs.insert(model.id) + } return picked } - /// First catalog model from the ordered ids that passes can_run when known. + /// Below this, the curated list is treated as having failed and the category + /// back-fill runs. Matches Android's threshold. + private let minimumRecommendations = 3 + + /// First catalog model from the ordered ids that passes can_run when known, + /// falling back to the smallest model in the category. Same reasoning as + /// `pickModels`: a stale id must degrade to a worse answer, never to none. private func pickFirst( ids: [String], + category: RAModelCategory, + secondaryCategory: RAModelCategory? = nil, from byID: [String: RAModelInfo], canRunByModelID: [String: Bool] ) -> RAModelInfo? { @@ -161,7 +230,10 @@ struct ModelRecommendationEngine { return model } } - return nil + return byID.values + .filter { $0.category == category || $0.category == secondaryCategory } + .filter { isRunnable($0, canRunByModelID: canRunByModelID) } + .min { $0.consumerSizeBytes < $1.consumerSizeBytes } } /// Prefer commons `can_run`. When the SDK has not returned a verdict for @@ -173,111 +245,118 @@ struct ModelRecommendationEngine { // MARK: - Curated per-tier preferences (real registered ids) + /// `HardwareTierResolver` returns `.unknown` on every device today, so a + /// straight tier switch meant one list was the only list anything ever read. + /// Until commons publishes a typed tier, the platform is the one honest + /// signal available: a Mac is not a phone. That is not a RAM heuristic and + /// not a memory budget, so it does not cross the line the rest of this file + /// holds. A real tier, when it arrives, outranks it. private func preferences(for tier: HardwareTier) -> TierPreferences { switch tier { - case .unknown, .midRange: return .midRange case .lowEnd: return .lowEnd + case .midRange: return .midRange case .highEnd: return .highEnd + case .unknown: + #if os(macOS) + return .highEnd + #else + return .midRange + #endif } } } -// MARK: - Curated id lists (real registered ids from ModelCatalogBootstrap) +// MARK: - Curated id lists + +// Ids from the 0.20.24 catalog rebuild, ordered by how good the model is rather +// than by how small it is. Size still decides the back-fill, because a fallback +// should be cheap; these lists are where the app states a preference. +// +// Per-app curated lists are a deliberate, known-bad tradeoff: six apps hold six +// copies of this judgement, which is the shape of the breakage the back-fill +// above now absorbs. Promoting the ranking onto the catalog row is the escape +// hatch if it bites twice. +// +// MLX ids appear alongside their GGUF twins. MLX registration fails on the arm64 +// simulator, so those rows are simply absent there and the next id wins. private extension ModelRecommendationEngine.TierPreferences { - /// Smallest quantized / ONNX variants only. + /// Smallest current-generation models. Nothing above ~2B. static let lowEnd = Self( llmIDs: [ - "mlx-lfm2-350m", - "lfm2-350m-q4_k_m", - "mlx-qwen3-0.6b-4bit", - "qwen3-0.6b-q4_k_m" + "lfm2.5-230m-q4_k_m", + "qwen3.5-0.8b-q4_k_m", + "lfm2.5-1.2b-instruct-q4_k_m", + "mlx-lfm2.5-1.2b-instruct-4bit", + "gemma-4-e2b-it-q4_k_m" ], asrIDs: [ - "sherpa-onnx-whisper-tiny.en", - "mlx-qwen3-asr-0.6b-8bit" + "sherpa-onnx-whisper-tiny.en" ], ttsIDs: [ - "mlx-soprano-1.1-80m-5bit", "vits-piper-en_US-lessac-medium" ], vlmIDs: [ "smolvlm2-256m-video-instruct-q8_0", - "lfm2-vl-450m-q8_0" + "smolvlm2-500m-video-instruct-q8_0" ], embeddingIDs: [ - "all-minilm-l6-v2", - "mlx-qwen3-embedding-0.6b-4bit-dwq" + "all-minilm-l6-v2" ] ) - /// A spread: tiny/fast, balanced, tool-calling, thinking. + /// The phone and iPad default: a spread from instant to genuinely capable, + /// none of it large enough to be a bad idea on battery. static let midRange = Self( llmIDs: [ - "mlx-lfm2-350m", - "mlx-llama-3.2-1b-instruct-4bit", - "lfm2-1.2b-tool-q4_k_m", - "mlx-qwen3-0.6b-4bit", - "qwen3-1.7b-q4_k_m" + "qwen3.5-2b-q4_k_m", + "lfm2.5-1.2b-instruct-q4_k_m", + "qwen3.5-4b-q4_k_m", + "lfm2.5-1.2b-thinking-q4_k_m", + "granite-4.1-3b-q4_k_m" ], asrIDs: [ - "mlx-qwen3-asr-0.6b-8bit", "sherpa-onnx-whisper-tiny.en" ], ttsIDs: [ - "mlx-soprano-1.1-80m-5bit", "vits-piper-en_US-lessac-medium" ], - // No MLX Qwen2-VL. Measured on this Mac (M4 Max, MLX 4-bit): every vision - // turn decoded its opening token and then repeated only that token — - // 23 × "The" for "what colour is the circle?", the same for a photograph - // and for a synthetic card, on a first turn and on later ones. The prompt - // was sized correctly (418 tokens, image tokens included) and the MLX - // *text* path answered normally with identical sampler settings, so this - // is the model on this runtime, not our image or generation plumbing. - // The web SDK already forces Qwen2-VL off WebGPU for an f16 M-RoPE + // No MLX Qwen2-VL, and no Qwen-family VLM as the default. Measured on an + // M4 Max (MLX 4-bit): every vision turn decoded its opening token and + // then repeated only that token, on photographs and synthetic cards + // alike, while the MLX text path answered normally with the same sampler + // settings. The web SDK forces Qwen2-VL off WebGPU for an f16 M-RoPE // overflow; this is the same family failing the same way on Metal. - // LFM2-VL through llama.cpp answers the same camera correctly (128 tokens - // at 32 tok/s), so it leads instead. Qwen2-VL stays in the catalog — - // pickable, just never the recommendation. vlmIDs: [ - "lfm2-vl-450m-q8_0", "smolvlm2-500m-video-instruct-q8_0", "smolvlm2-256m-video-instruct-q8_0" ], embeddingIDs: [ - "mlx-qwen3-embedding-0.6b-4bit-dwq", "all-minilm-l6-v2" ] ) - /// Full spread including a larger "genius" model. + /// Mac. Commons still gates each id on real device RAM through `can_run`, + /// so naming a 27B here is a preference, not a promise. static let highEnd = Self( llmIDs: [ - "mlx-llama-3.2-1b-instruct-4bit", - "llama-3.2-3b-instruct-q4_k_m", - "lfm2-1.2b-tool-q4_k_m", - "qwen3-4b-q4_k_m", - "mlx-qwen3-4b-4bit" + "qwen3.5-9b-q4_k_m", + "mlx-qwen3.5-9b-4bit", + "gemma-4-12b-it-q4_k_m", + "qwen3.8-27b-q4_k_m", + "granite-4.1-8b-q4_k_m" ], asrIDs: [ - "mlx-qwen3-asr-0.6b-8bit", "sherpa-onnx-whisper-tiny.en" ], ttsIDs: [ - "mlx-soprano-1.1-80m-5bit", "vits-piper-en_US-lessac-medium" ], - // Same reason as `midRange`: MLX Qwen2-VL answers with one repeated - // token. Qwen2.5-VL is a different generation on a different runtime - // (llama.cpp) and leads here; the MLX Qwen3-VL stays as the second - // choice rather than the default no one chose. vlmIDs: [ - "qwen2.5-vl-3b-instruct-q4_k_m", - "mlx-qwen3-vl-4b-instruct-4bit" + "smolvlm2-500m-video-instruct-q8_0", + "smolvlm2-256m-video-instruct-q8_0" ], embeddingIDs: [ - "mlx-qwen3-embedding-0.6b-4bit-dwq", "all-minilm-l6-v2" ] ) diff --git a/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift b/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift new file mode 100644 index 00000000..e16b3104 --- /dev/null +++ b/RunAnywhereAIUnitTests/ModelRecommendationEngineTests.swift @@ -0,0 +1,141 @@ +// +// ModelRecommendationEngineTests.swift +// RunAnywhereAIUnitTests +// +// The engine picks by hardcoded id, and the catalog it picks from is edited in +// a different PR by someone not reading that file. These lock the one property +// that makes the coupling survivable: a curated list that has gone stale +// degrades to a worse recommendation, never to no recommendation. +// +// Deliberately no assertion that any particular id is present. A test that +// names ids is a second copy of the list, and it would fail on the catalog +// change it is supposed to tolerate. +// + +import XCTest +@testable import RunAnywhereAI +import RunAnywhere + +final class ModelRecommendationEngineTests: XCTestCase { + + private let engine = ModelRecommendationEngine() + + /// The 0.20.24 regression, as a test. Every curated id is absent, exactly as + /// it was after the catalog rebuild dropped the superseded families, and the + /// screen still has something to recommend. + func testRecommendsLanguageModelsWhenNoCuratedIDMatches() { + let catalog = [ + makeModel(id: "totally-unrelated-3b", name: "Unrelated 3B", bytes: 2_000_000_000), + makeModel(id: "totally-unrelated-1b", name: "Unrelated 1B", bytes: 900_000_000), + makeModel(id: "totally-unrelated-8b", name: "Unrelated 8B", bytes: 5_000_000_000) + ] + + let selection = engine.recommend( + tier: .unknown, + appleFoundationAvailable: false, + from: catalog + ) + + XCTAssertEqual(selection.recommendedLLMs.count, 3) + XCTAssertNotNil(selection.defaultChatModel) + } + + /// Back-fill opens with the cheapest option rather than the largest file in + /// the category, so a fallback stays runnable on the device that triggered it. + func testBackfillIsOrderedSmallestFirst() { + let catalog = [ + makeModel(id: "big", name: "Big 8B", bytes: 5_000_000_000), + makeModel(id: "small", name: "Small 1B", bytes: 900_000_000), + makeModel(id: "medium", name: "Medium 3B", bytes: 2_000_000_000) + ] + + let selection = engine.recommend( + tier: .unknown, + appleFoundationAvailable: false, + from: catalog + ) + + XCTAssertEqual(selection.recommendedLLMs.map(\.id), ["small", "medium", "big"]) + } + + /// A model commons has ruled out is never recommended, even when nothing + /// else is left to offer. Absent verdicts stay permissive. + func testCanRunVerdictExcludesAndMissingVerdictAllows() { + let catalog = [ + makeModel(id: "too-big", name: "Too Big 30B", bytes: 20_000_000_000), + makeModel(id: "fits", name: "Fits 1B", bytes: 900_000_000) + ] + + let selection = engine.recommend( + tier: .unknown, + appleFoundationAvailable: false, + from: catalog, + canRunByModelID: ["too-big": false] + ) + + XCTAssertEqual(selection.recommendedLLMs.map(\.id), ["fits"]) + } + + /// An empty catalog is the one case where recommending nothing is correct. + func testEmptyCatalogRecommendsNothing() { + let selection = engine.recommend( + tier: .unknown, + appleFoundationAvailable: false, + from: [] + ) + + XCTAssertTrue(selection.recommendedLLMs.isEmpty) + XCTAssertNil(selection.defaultChatModel) + } + + /// Companions fall back by category too, so a stale ASR id does not leave + /// the Voice screen with no speech model. + func testCompanionsFallBackByCategory() { + let catalog = [ + makeModel(id: "some-llm", name: "Some 1B", bytes: 900_000_000), + makeModel(id: "unknown-asr", name: "Unknown ASR", category: .speechRecognition, bytes: 80_000_000), + makeModel(id: "unknown-tts", name: "Unknown TTS", category: .speechSynthesis, bytes: 60_000_000) + ] + + let selection = engine.recommend( + tier: .unknown, + appleFoundationAvailable: false, + from: catalog + ) + + XCTAssertEqual(selection.recommendedASR?.id, "unknown-asr") + XCTAssertEqual(selection.recommendedTTS?.id, "unknown-tts") + } + + /// The voice pipeline shares the same picking, so it inherits the same floor. + func testVoicePipelineResolvesFromAnUnfamiliarCatalog() { + let catalog = [ + makeModel(id: "some-llm", name: "Some 1B", bytes: 900_000_000), + makeModel(id: "unknown-asr", name: "Unknown ASR", category: .speechRecognition, bytes: 80_000_000), + makeModel(id: "unknown-tts", name: "Unknown TTS", category: .speechSynthesis, bytes: 60_000_000) + ] + + let pipeline = engine.recommendVoicePipeline( + tier: .unknown, + appleFoundationAvailable: false, + from: catalog + ) + + XCTAssertTrue(pipeline.isComplete) + } + + private func makeModel( + id: String, + name: String, + category: RAModelCategory = .language, + bytes: Int64 + ) -> RAModelInfo { + var model = RAModelInfo() + model.id = id + model.name = name + model.category = category + model.framework = .llamaCpp + model.downloadSizeBytes = bytes + return model + } +}