diff --git a/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx b/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx new file mode 100644 index 000000000..1f015858e --- /dev/null +++ b/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx @@ -0,0 +1,58 @@ +/** + * Browse fit chip — a model that's OVER the balanced budget but still loadable ('tight', under the + * aggressive ceiling) now SHOWS in browse with a fit chip, instead of being hidden. + * + * SPEC: browse no longer hides loadable models behind fileExceedsBudget (the balanced budget). It keeps + * any model with a quant that isn't 'wontFit' (past the aggressive ceiling) and tags it with a device-fit + * chip (Easy / Fits / Tight). Only genuinely-too-big models are hidden. Recommended stays filtered and + * search/sort are untouched — this is only the browse-results relaxation + the chip. + * + * Mounts the REAL ModelsScreen, searches, and asserts the previously-hidden 'tight' model now renders with + * its Tight chip. RED before the relaxation (useTextModels hid it via fileExceedsBudget; ModelCardContent + * had no chip). Boundary fakes only: native download + fs + RAM + the HuggingFace transport. + */ +import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; + +const MODEL_ID = 'org/tight-fit'; + +describe('browse shows a loadable-but-over-balanced-budget model with a fit chip (rendered)', () => { + it('renders the tight model (not hidden) and its Tight chip', async () => { + // 8GB Android: balanced soft = 4.8GB, aggressive ceil = 6.0GB. A single 5GB quant is OVER the balanced + // budget (old behaviour: hidden) but under the aggressive ceiling → 'tight' → now shown with a chip. + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 8 * GB, availBytes: 5 * GB } }); + + const tightFile = { name: 'model-Q5_K_M.gguf', size: 5 * GB, quantization: 'Q5_K_M', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-Q5_K_M.gguf` }; + const modelInfo = { id: MODEL_ID, name: 'Tight Fit Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [tightFile] }; + jest.doMock('../../../src/services/huggingface', () => ({ + huggingFaceService: { + searchModels: jest.fn(async () => [modelInfo]), + getModelFiles: jest.fn(async () => [tightFile]), + getModelDetails: jest.fn(async () => modelInfo), + getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, + formatModelSize: jest.fn(() => '5.0 GB'), + formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(1)} GB`), + }, + })); + + /* eslint-disable @typescript-eslint/no-var-requires */ + const React = require('react'); + const { render, fireEvent, waitFor, act } = requireRTL(); + const { hardwareService } = require('../../../src/services/hardware'); + const { fitTier } = require('../../../src/services/memoryBudget'); + const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); + /* eslint-enable @typescript-eslint/no-var-requires */ + + await hardwareService.refreshMemoryInfo(); + const ramGB = hardwareService.getTotalMemoryGB(); + // Precondition: this model is 'tight' — loadable, but over the balanced budget (old code hid it). + expect(fitTier(tightFile.size, ramGB)).toBe('tight'); + + const { getByTestId, getByText, queryByTestId } = render(React.createElement(ModelsScreen, {})); + await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'tight'); }); + await act(async () => { fireEvent(getByTestId('search-input'), 'submitEditing'); await new Promise((r) => setTimeout(r, 600)); }); + + // TERMINAL artifact: the model is NOT hidden (renders), and carries the Tight fit chip. + await waitFor(() => expect(getByText('Tight Fit Model')).toBeTruthy(), { timeout: 6000 }); + expect(queryByTestId('fit-chip-tight')).not.toBeNull(); + }, 30000); +}); diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx index a362b87de..9e112fe3d 100644 --- a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx +++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx @@ -1,26 +1,25 @@ /** - * Detail "Available Files" device-fit hint — the single owned budget (memoryBudget.fileExceedsBudget). + * Detail "Available Files" device-fit list — the single owned budget (memoryBudget.fitTier). * - * SPEC (the OGAM user's view): in a model's detail view, the "Available Files" list offers exactly the - * quant files that FIT this device's RAM budget and hides the ones that don't — and that fit decision is - * the ONE owned primitive `fileExceedsBudget` (device-tier fraction of TOTAL RAM), never a hand-rolled - * copy of the budget arithmetic that could drift from the download-warning / picker / browse-list copies. + * SPEC (the OGAM user's view): in a model's detail view, the "Available Files" list offers every LOADABLE + * quant (fitTier !== 'wontFit' — down to the aggressive ceiling, since the load path can reach it via + * reclaim credit + Load Anyway) and hides only the genuinely-too-big ones. That decision is the ONE owned + * primitive `fitTier`, never a hand-rolled copy that could drift from the browse-list / picker copies. * * This mounts the REAL ModelsScreen, arrives at a model's detail via a real search+tap, and asserts the - * rendered file list against `fileExceedsBudget`'s verdict for each file: the under-budget quant renders, - * the over-budget quant is absent. Boundary fakes only: native download + fs + RAM (installNativeBoundary) - * and the HuggingFace network transport. The budget math, screen, hooks, ModelCard all run REAL. + * rendered file list against `fitTier`'s verdict per file: a loadable quant renders, a 'wontFit' quant is + * absent. Boundary fakes only: native download + fs + RAM (installNativeBoundary) and the HuggingFace + * network transport. The budget math, screen, hooks, ModelCard all run REAL. * - * Falsification (DRY): the expected present/absent set is computed from `fileExceedsBudget` itself, so if - * a caller's inline copy of the formula drifts from the owner (different fraction, wrong comparison, a - * unit slip), the rendered list stops matching the owner's verdict and this test goes red. + * Falsification (DRY): the expected present/absent set is computed from `fitTier` itself, so if a caller's + * inline copy of the formula drifts from the owner, the rendered list stops matching and this test reds. */ import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; const MODEL_ID = 'org/fit-hint'; describe('detail Available Files fit hint matches the owned fileExceedsBudget verdict (rendered)', () => { - it('offers exactly the files fileExceedsBudget says fit — hides the over-budget quant', async () => { + it('offers every loadable quant (fitTier !== wontFit) — hides only the genuinely-too-big one', async () => { // Device: a 6GB Android phone → budget = 6 * modelBudgetFraction(6)=0.60 = 3.6GB. installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 6 * GB, availBytes: 4 * GB } }); @@ -43,16 +42,18 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve const React = require('react'); const { render, fireEvent, waitFor, act } = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); - const { fileExceedsBudget } = require('../../../src/services/memoryBudget'); + const { fitTier } = require('../../../src/services/memoryBudget'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); /* eslint-enable @typescript-eslint/no-var-requires */ await hardwareService.refreshMemoryInfo(); const ramGB = hardwareService.getTotalMemoryGB(); - // The owner's verdict is the source of truth for what the list must show. - expect(fileExceedsBudget(fitFile.size, ramGB)).toBe(false); // fits → must render - expect(fileExceedsBudget(bigFile.size, ramGB)).toBe(true); // exceeds → must be hidden + // The list now offers every LOADABLE quant (fitTier !== 'wontFit', up to the aggressive ceiling) + // and hides only the genuinely-too-big ones. On 6GB the aggressive ceiling is 4.5GB: 2GB is loadable, + // 5GB is past it → 'wontFit'. + expect(fitTier(fitFile.size, ramGB)).not.toBe('wontFit'); // loadable → must render + expect(fitTier(bigFile.size, ramGB)).toBe('wontFit'); // past aggressive ceiling → hidden const utils = render(React.createElement(ModelsScreen, {})); const { getByTestId, getByText, queryByText } = utils; @@ -75,29 +76,27 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve expect(queryByText('model-Q8_0')).toBeNull(); }, 30000); - it('BOUNDARY (M5a): a file EXACTLY at the budget is treated as over-budget (>=), one just under fits', async () => { - // Pins the exact budget comparison (the `>=` in fileExceedsBudget). The verifier's `>=`→`>` mutant - // survived because no test straddled equality. Device chosen so the budget is a WHOLE number of - // bytes: 4GB × balanced 0.50 = EXACTLY 2.0 GB (2147483648 B) — the only tier where integer bytes can - // hit exact equality (0.60×6GB is 3.6GB, not an integer, so >= and > can't differ there). A file of - // EXACTLY 2.0GB must be HIDDEN (>= = exceeds); 2.0GB−1byte must SHOW. Reverting to `>` flips the - // exact-budget file to "fits" → this test goes red (mutant killed). - installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 4 * GB, availBytes: 3 * GB } }); + it('BOUNDARY: a file EXACTLY at the aggressive ceiling is Won\'t-fit (hidden), one just under is loadable (shown)', async () => { + // The list hides only 'wontFit' — files past the AGGRESSIVE ceiling (fitTier's `size < ceil`). Pin + // that boundary on a device where the ceiling is a WHOLE number of bytes: 8GB × aggressive 0.75 = + // EXACTLY 6.0 GB. A file of EXACTLY 6.0GB is 'wontFit' (not `< ceil`) → HIDDEN; 6.0GB−1byte is 'tight' + // → SHOWN. Flipping fitTier's `<` to `<=` would make the exact-ceiling file 'tight'/shown → red. + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 8 * GB, availBytes: 5 * GB } }); /* eslint-disable @typescript-eslint/no-var-requires */ const { modelBudgetFraction } = require('../../../src/services/memoryBudget'); /* eslint-enable @typescript-eslint/no-var-requires */ - const budgetBytes = 4 * modelBudgetFraction(4, 'android', 'balanced') * GB; // 4 × 0.50 × GB = exactly 2.0 GB (integer bytes) - const atBudget = { name: 'model-atbudget.gguf', size: budgetBytes, quantization: 'Q5', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-atbudget.gguf` }; - const underBudget = { name: 'model-under.gguf', size: budgetBytes - 1, quantization: 'Q4', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-under.gguf` }; - const modelInfo = { id: MODEL_ID, name: 'Boundary Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [underBudget, atBudget] }; + const ceilBytes = 8 * modelBudgetFraction(8, 'android', 'aggressive') * GB; // 8 × 0.75 × GB = exactly 6.0 GB (integer bytes) + const atCeiling = { name: 'model-atceiling.gguf', size: ceilBytes, quantization: 'Q6', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-atceiling.gguf` }; + const underCeiling = { name: 'model-under.gguf', size: ceilBytes - 1, quantization: 'Q5', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-under.gguf` }; + const modelInfo = { id: MODEL_ID, name: 'Boundary Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [underCeiling, atCeiling] }; jest.doMock('../../../src/services/huggingface', () => ({ huggingFaceService: { searchModels: jest.fn(async () => [modelInfo]), - getModelFiles: jest.fn(async () => [underBudget, atBudget]), + getModelFiles: jest.fn(async () => [underCeiling, atCeiling]), getModelDetails: jest.fn(async () => modelInfo), getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, - formatModelSize: jest.fn(() => '2.0 GB'), + formatModelSize: jest.fn(() => '6.0 GB'), formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(2)} GB`), }, })); @@ -106,15 +105,15 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve const React = require('react'); const { render, fireEvent, waitFor, act } = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); - const { fileExceedsBudget } = require('../../../src/services/memoryBudget'); + const { fitTier } = require('../../../src/services/memoryBudget'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); /* eslint-enable @typescript-eslint/no-var-requires */ await hardwareService.refreshMemoryInfo(); const ramGB = hardwareService.getTotalMemoryGB(); - // Owner verdict is the source of truth: exact-budget EXCEEDS (>=), just-under FITS. - expect(fileExceedsBudget(atBudget.size, ramGB)).toBe(true); - expect(fileExceedsBudget(underBudget.size, ramGB)).toBe(false); + // Owner verdict: EXACTLY at the aggressive ceiling is 'wontFit'; just-under is loadable ('tight'). + expect(fitTier(atCeiling.size, ramGB)).toBe('wontFit'); + expect(fitTier(underCeiling.size, ramGB)).not.toBe('wontFit'); const { getByTestId, getByText, queryByText } = render(React.createElement(ModelsScreen, {})); await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'boundary'); }); @@ -124,8 +123,8 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve await waitFor(() => expect(getByTestId('model-detail-screen')).toBeTruthy(), { timeout: 4000 }); await waitFor(() => expect(getByText('model-under')).toBeTruthy(), { timeout: 4000 }); - // TERMINAL artifact: just-under renders; EXACTLY-at-budget is hidden (the `>=` boundary). + // TERMINAL artifact: just-under (loadable) renders; EXACTLY-at-ceiling (Won't fit) is hidden. expect(queryByText('model-under')).not.toBeNull(); - expect(queryByText('model-atbudget')).toBeNull(); + expect(queryByText('model-atceiling')).toBeNull(); }, 30000); }); diff --git a/src/components/ModelCardContent.tsx b/src/components/ModelCardContent.tsx index a5c5c1862..4f9529f04 100644 --- a/src/components/ModelCardContent.tsx +++ b/src/components/ModelCardContent.tsx @@ -7,8 +7,15 @@ import type { ThemeColors } from '../theme'; import { createStyles } from './ModelCard.styles'; import { huggingFaceService } from '../services/huggingface'; import { ModelCredibility } from '../types'; +import { fitTierLabel, type FitTier } from '../services/memoryBudget'; +import type { ThemeColors as TC } from '../theme'; import { triggerHaptic } from '../utils/haptics'; +/** Chip accent per fit tier: emerald for a comfortable easy/fits, muted for a tight (snug, still + * loadable) fit. Browse never shows 'wontFit' (those models are filtered out), so it isn't styled. */ +const fitTierColor = (colors: TC, tier: FitTier): string => + tier === 'tight' ? colors.textMuted : colors.primary; + interface CredibilityInfo { color: string; label: string; @@ -49,6 +56,7 @@ interface CompactModelCardContentProps { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; + fitTier?: FitTier; }; credibility?: ModelCredibility; credibilityInfo: CredibilityInfo | null; @@ -90,6 +98,46 @@ function modelTypeTextStyle( return null; } +/** The compact card's badge row (fit chip + NPU/GPU + type + params + RAM). Extracted to module + * scope so CompactModelCardContent stays under the complexity gate; renders null when empty. */ +const InfoBadgesRow: React.FC<{ + model: { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; fitTier?: FitTier }; + supportsAcceleration?: boolean; + styles: ReturnType; + colors: TC; +}> = ({ model, supportsAcceleration, styles, colors }) => { + if (!model.modelType && !model.paramCount && !supportsAcceleration && !model.fitTier) return null; + return ( + + {/* Device-fit chip: how snugly the best quant fits THIS phone (Easy/Fits/Tight). Browse shows + loadable models with this instead of hiding over-budget ones. */} + {model.fitTier && ( + + {fitTierLabel(model.fitTier)} + + )} + {supportsAcceleration && ( + + NPU/GPU + + )} + {model.modelType && ( + + {modelTypeLabel(model.modelType)} + + )} + {/* `!!` coerces a falsy 0/undefined to false — `{0 && …}` would render a bare "0" text node + outside and crash RN (CodeRabbit). A 0-param / 0-RAM badge is meaningless anyway. */} + {!!model.paramCount && ( + {model.paramCount}B params + )} + {!!model.minRamGB && ( + {model.minRamGB}GB+ RAM + )} + + ); +}; + export const CompactModelCardContent: React.FC = ({ model, credibility, @@ -150,33 +198,8 @@ export const CompactModelCardContent: React.FC = ( ))} - ) : (model.modelType || model.paramCount || supportsAcceleration) && ( - - {/* Capability badge: this model can run on the GPU/NPU (a LiteRT model or a - Q4_0/Q8_0 GGUF). K-quants silently fall back to CPU, so they get no badge. */} - {supportsAcceleration && ( - - NPU/GPU - - )} - {model.modelType && ( - - - {modelTypeLabel(model.modelType)} - - - )} - {model.paramCount && ( - - {model.paramCount}B params - - )} - {model.minRamGB && ( - - {model.minRamGB}GB+ RAM - - )} - + ) : ( + )} ); diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index af1c8526e..5dd25cf75 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { View, Text, FlatList, TextInput, ActivityIndicator, RefreshControl, TouchableOpacity, InteractionManager, Platform } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import Icon from 'react-native-vector-icons/Feather'; -import { fileExceedsBudget } from '../../services/memoryBudget'; +import { fileExceedsBudget, isLoadableOnDevice } from '../../services/memoryBudget'; import { AttachStep, useSpotlightTour } from 'react-native-spotlight-tour'; import { Card, ModelCard } from '../../components'; import { AnimatedEntry } from '../../components/AnimatedEntry'; @@ -255,7 +255,10 @@ const ModelDetailView: React.FC = ({ ) : ( f.size > 0 && !fileExceedsBudget(f.size, ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant))) + // Show every loadable quant (easy/fits/tight — down to the aggressive ceiling), not just + // those under the balanced budget, so a 'tight' model opened from browse still lists a + // downloadable file. isCompatible (below) still flags the snug ones for a Load-Anyway warning. + .filter(f => f.size > 0 && isLoadableOnDevice(f.size, ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant))) .sort((a, b) => { if (selectedModel.id === LITERT_PARENT_ID) return a.size - b.size; // curated: small-first // Tier: Q4_K_M (CPU default, lowest size) → GPU/NPU Q4_0/Q8_0 → rest (CPU diff --git a/src/screens/ModelsScreen/useTextModels.ts b/src/screens/ModelsScreen/useTextModels.ts index a2c931079..7e483875f 100644 --- a/src/screens/ModelsScreen/useTextModels.ts +++ b/src/screens/ModelsScreen/useTextModels.ts @@ -4,7 +4,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { showAlert, AlertState } from '../../components/CustomAlert'; import { RECOMMENDED_MODELS, TRENDING_FAMILIES, MODEL_ORGS } from '../../constants'; import { useAppStore } from '../../stores'; -import { fileExceedsBudget } from '../../services/memoryBudget'; +import { fitTier, isLoadableOnDevice, type FitTier } from '../../services/memoryBudget'; import { useDownloadStore } from '../../stores/downloadStore'; import { huggingFaceService, modelManager, hardwareService, activeModelService } from '../../services'; import { startModelDownload } from '../../services/startModelDownload'; @@ -69,6 +69,26 @@ async function fetchRecommendedModelDetails(): Promise return details; } +const TIER_RANK: Record = { easy: 0, fits: 1, tight: 2, wontFit: 3 }; + +/** True when EVERY sized quant is past the aggressive ceiling ('wontFit') — the only case browse still + * hides. A model with any easy/fits/tight quant stays visible (shown with a fit chip). */ +function noLoadableQuant(model: ModelInfo, ramGB: number): boolean { + const sized = (model.files || []).filter(f => f.size > 0); + return sized.length > 0 && !sized.some(f => isLoadableOnDevice(f.size, ramGB)); +} + +/** The fit tier of a model's BEST (most-fitting) quant — what the browse chip shows. undefined when + * no file sizes are known yet (chip hidden until they load). */ +function bestFitTier(model: ModelInfo, ramGB: number): FitTier | undefined { + const sized = (model.files || []).filter(f => f.size > 0); + if (sized.length === 0) return undefined; + return sized.reduce((best, f) => { + const t = fitTier(f.size, ramGB); + return TIER_RANK[t] < TIER_RANK[best] ? t : best; + }, 'wontFit'); +} + function computeFilteredResults( searchResults: ModelInfo[], filterState: FilterState, @@ -85,14 +105,14 @@ function computeFilteredResults( if (sizeOpt && (params < sizeOpt.min || params >= sizeOpt.max)) return false; } } - const filesWithSize = (model.files || []).filter(f => f.size > 0); - if (filesWithSize.length > 0 && !filesWithSize.some(f => !fileExceedsBudget(f.size, ramGB))) return false; - return true; + // Browse no longer hides loadable models behind the balanced budget; keep a model unless EVERY + // quant is past the aggressive ceiling ('wontFit') — not loadable even with reclaim + Load Anyway. + return !noLoadableQuant(model, ramGB); }); return filtered.map(model => { const type = getModelType(model); const params = parseParamCount(model); - return { ...model, modelType: type === 'image-gen' ? undefined : type as 'text' | 'vision' | 'code', paramCount: params ?? undefined }; + return { ...model, modelType: type === 'image-gen' ? undefined : type as 'text' | 'vision' | 'code', paramCount: params ?? undefined, fitTier: bestFitTier(model, ramGB) }; }); } diff --git a/src/services/memoryBudget.ts b/src/services/memoryBudget.ts index 329e40b02..a56336995 100644 --- a/src/services/memoryBudget.ts +++ b/src/services/memoryBudget.ts @@ -158,6 +158,46 @@ export function fileExceedsBudget(sizeBytes: number, ramGB: number): boolean { return sizeBytes / BYTES_PER_GB >= ramGB * modelBudgetFraction(ramGB); } +export type FitTier = 'easy' | 'fits' | 'tight' | 'wontFit'; + +/** + * A device-fit TIER for a model file — a short chip label, NOT the load gate. The load path can + * attempt anything up to the aggressive ceiling (Android reclaim credit + Load Anyway), so browse + * no longer HIDES loadable models or says "Too large"; it shows how snug the fit is, and reserves an + * honest "Won't fit" only for models past even the aggressive ceiling (e.g. a 30B on a phone). + * Two thresholds, both from the SAME owned budgets — no new magic numbers: + * soft = ramGB * modelBudgetFraction(balanced) (recommended stays within this — see fileExceedsBudget) + * ceil = ramGB * modelBudgetFraction(aggressive) (the most Load-Anyway/aggressive can hold) + * size < 0.6*soft → 'easy' · < soft → 'fits' · < ceil → 'tight' · else → 'wontFit' + * Zero-IO; callers pass ramGB + platform from the device boundary (hardwareService). + */ +export function fitTier(sizeBytes: number, ramGB: number, platform: Plat = Platform.OS): FitTier { + const sizeGB = sizeBytes / BYTES_PER_GB; + const soft = ramGB * modelBudgetFraction(ramGB, platform, 'balanced'); + const ceil = ramGB * modelBudgetFraction(ramGB, platform, 'aggressive'); + if (sizeGB < 0.6 * soft) return 'easy'; + if (sizeGB < soft) return 'fits'; + if (sizeGB < ceil) return 'tight'; + return 'wontFit'; +} + +/** Loadable on THIS device: the tier is not 'wontFit' — i.e. within the aggressive ceiling, reachable + * via reclaim credit + Load Anyway. The ONE predicate the browse filter and the detail file list share, + * so "what counts as loadable" is defined once here, not copied inline per caller. */ +export function isLoadableOnDevice(sizeBytes: number, ramGB: number, platform: Plat = Platform.OS): boolean { + return fitTier(sizeBytes, ramGB, platform) !== 'wontFit'; +} + +/** The chip label for a fit tier. One source for the copy so every surface reads the same words. */ +export function fitTierLabel(tier: FitTier): string { + switch (tier) { + case 'easy': return 'Easy'; + case 'fits': return 'Fits'; + case 'tight': return 'Tight'; + case 'wontFit': return "Won't fit"; + } +} + /** * Block until a just-released native model's memory is reclaimed (process footprint drops by * ~minDropMB) or a bounded timeout — so the NEXT model load never allocates on top of the outgoing diff --git a/src/types/index.ts b/src/types/index.ts index e5d1d8249..bb62e0ef8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,9 @@ export interface ModelInfo { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; + /** Device-fit tier of this model's BEST (most-fitting) quant, for the browse fit chip. Set by the + * models VM from memoryBudget.fitTier; undefined until the file sizes are known. */ + fitTier?: import('../services/memoryBudget').FitTier; } export interface ModelFile {