Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { initDebugLogFile, appendDebugLine } from './src/utils/debugLogFile';
import { loadProFeatures } from './src/bootstrap/loadProFeatures';
import { checkProStatus } from './src/services/proLicenseService';
import { hydrateDownloadStore } from './src/services/downloadHydration';
import { startLoadPolicySync } from './src/services/loadPolicySync';
import { registerCoreDownloadProviders } from './src/services/modelDownloadService/registerProviders';
import { useDownloadListeners } from './src/hooks/useDownloads';
import { KeyboardProvider } from 'react-native-keyboard-controller';
Expand Down Expand Up @@ -142,6 +143,11 @@ function App() {
// Ensure persisted download metadata is loaded before restore logic reads it.
await ensureAppStoreHydrated();

// Project the persisted "aggressive model loading" setting onto the residency
// manager (single owner of the runtime load policy) now that settings are
// hydrated, and keep it in sync for the app's lifetime.
startLoadPolicySync();

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
// Hydrate download store from SQLite before any screen mounts.
await hydrateDownloadStore().catch((error) => {
logger.error('[App] Failed to hydrate download store during startup:', error);
Expand Down
59 changes: 57 additions & 2 deletions __tests__/integration/models/activeModelService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ describe('ActiveModelService Integration', () => {
await activeModelService.syncWithNativeState();
});

describe('Text Model Loading — aggressive load override ("Load Anyway")', () => {
// A GGUF whose estimated RAM exceeds the budget on the current device.
const oversized = () => createDownloadedModel({
id: 'huge-1', engine: 'llama' as any, fileName: 'huge.gguf', filePath: '/huge.gguf',
fileSize: 21 * 1024 * 1024 * 1024,
});

it('throws an OverridableMemoryError when the model does not fit (no override)', async () => {
mockHardwareService.getTotalMemoryGB.mockReturnValue(24);
mockHardwareService.getAvailableMemoryGB.mockReturnValue(24);
mockLlmService.isModelLoaded.mockReturnValue(false);
useAppStore.setState({ downloadedModels: [oversized()] });

const { isOverridableMemoryError } = require('../../../src/services/modelLoadErrors');
let caught: unknown;
await activeModelService.loadTextModel('huge-1').catch((e: unknown) => { caught = e; });
expect(caught).toBeDefined();
expect(isOverridableMemoryError(caught)).toBe(true);
expect(mockLlmService.loadModel).not.toHaveBeenCalled();
});

it('loads the same model when called with { override: true } (forces past the gate)', async () => {
mockHardwareService.getTotalMemoryGB.mockReturnValue(24);
mockHardwareService.getAvailableMemoryGB.mockReturnValue(24);
mockLlmService.isModelLoaded.mockReturnValue(true);
useAppStore.setState({ downloadedModels: [oversized()] });

await activeModelService.loadTextModel('huge-1', undefined, { override: true });

expect(mockLlmService.loadModel).toHaveBeenCalled();
expect(getAppState().activeModelId).toBe('huge-1');
});
});

describe('Text Model Loading', () => {
it('should load text model via llmService and update store', async () => {
const model = createDownloadedModel({ id: 'test-model-1' });
Expand Down Expand Up @@ -113,14 +147,14 @@ describe('ActiveModelService Integration', () => {
const litert = createDownloadedModel({ id: 'litert-1', engine: 'litert' as any, fileName: 'm.litertlm', filePath: '/m.litertlm' });
useAppStore.setState({ downloadedModels: [litert] });
await activeModelService.loadTextModel('litert-1').catch(() => {});
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'text', dirtyMemory: true }));
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'text', dirtyMemory: true }), expect.anything());

// GGUF/llama is clean mmap -> physical-cap budgeting unchanged (dirtyMemory:false).
spy.mockClear();
const gguf = createDownloadedModel({ id: 'gguf-1', engine: 'llama' as any });
useAppStore.setState({ downloadedModels: [gguf] });
await activeModelService.loadTextModel('gguf-1').catch(() => {});
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'text', dirtyMemory: false }));
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'text', dirtyMemory: false }), expect.anything());
spy.mockRestore();
});

Expand Down Expand Up @@ -521,6 +555,27 @@ describe('ActiveModelService Integration', () => {
expect(result.severity).toBe('critical');
});

it('aggressive load policy relaxes the PRE-CHECK too (not just residency), end-to-end', async () => {
// 6.5GB file → ~9.75GB required (1.5x). On a 12GB device this exceeds the
// balanced budget (0.70 Android / 0.78 iOS) but fits the aggressive budget
// (0.88 / 0.92) — proving the pre-check reads the residency manager's policy.
const model = createDownloadedModel({ id: 'mid-model', fileSize: 6.5 * 1024 * 1024 * 1024 });
useAppStore.setState({ downloadedModels: [model] });
mockHardwareService.getDeviceInfo.mockResolvedValue(
createDeviceInfo({ totalMemory: 12 * 1024 * 1024 * 1024 })
);

modelResidencyManager.setLoadPolicy('balanced');
const balanced = await activeModelService.checkMemoryForModel('mid-model', 'text');
expect(balanced.canLoad).toBe(false);

modelResidencyManager.setLoadPolicy('aggressive');
const aggressive = await activeModelService.checkMemoryForModel('mid-model', 'text');
expect(aggressive.canLoad).toBe(true);

modelResidencyManager.setLoadPolicy('balanced'); // don't leak policy to other tests
});

it('should return blocked for non-existent model', async () => {
useAppStore.setState({ downloadedModels: [] });

Expand Down
1 change: 0 additions & 1 deletion __tests__/rntl/components/TranscriptionModelsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ jest.mock('../../../src/services', () => ({

const mockWhisperActions = {
downloadModel: jest.fn(async () => {}),
downloadFromUrl: jest.fn(async () => {}),
selectModel: jest.fn(async () => {}),
deleteModel: jest.fn(),
deleteModelById: jest.fn(async () => {}),
Expand Down
23 changes: 23 additions & 0 deletions __tests__/rntl/screens/ModelSettingsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,28 @@ describe('ModelSettingsScreen', () => {

});

// ============================================================================
describe('aggressive loading toggle', () => {
it('renders the Aggressive Loading label', () => {
const { getByText } = renderWithSections('text');
expect(getByText('Aggressive Loading')).toBeTruthy();
});

it('turns the setting on via the switch', () => {
useAppStore.getState().updateSettings({ aggressiveModelLoading: false });
const { getByTestId } = renderWithSections('text');
fireEvent(getByTestId('aggressive-loading-switch'), 'valueChange', true);
expect(useAppStore.getState().settings.aggressiveModelLoading).toBe(true);
});

it('turns the setting off via the switch', () => {
useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
const { getByTestId } = renderWithSections('text');
fireEvent(getByTestId('aggressive-loading-switch'), 'valueChange', false);
expect(useAppStore.getState().settings.aggressiveModelLoading).toBe(false);
});
});

// ============================================================================
// Image Generation Settings
// ============================================================================
Expand Down Expand Up @@ -796,6 +818,7 @@ describe('ModelSettingsScreen', () => {
inferenceBackend: undefined as any,
gpuLayers: undefined as any,
flashAttn: undefined as any,
aggressiveModelLoading: undefined as any,
cacheType: undefined as any,
showGenerationDetails: undefined as any,
enhanceImagePrompts: undefined as any,
Expand Down
61 changes: 61 additions & 0 deletions __tests__/rntl/screens/NoModelScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* NoModelScreen — the empty state shown before a model is active.
*
* Regression: selecting a model kicks off a background load, but activeModelId
* stays null until the native load finishes, so this screen stayed visible with
* NO feedback and the user thought nothing happened. It must show a loading
* indicator while isModelLoading is true instead of the "Select Model" prompt.
*/
import React from 'react';
import { render } from '@testing-library/react-native';

jest.mock('../../../src/components', () => ({
ModelSelectorModal: () => null,
}));
jest.mock('../../../src/services', () => ({
llmService: { getLoadedModelPath: () => null },
}));

import { NoModelScreen } from '../../../src/screens/ChatScreen/ChatScreenComponents';
import { createStyles } from '../../../src/screens/ChatScreen/styles';
import { getTheme } from '../../../src/theme';

const theme = getTheme('light');
const colors = theme.colors;
const styles = createStyles(colors, theme.shadows);

function renderScreen(overrides: Partial<React.ComponentProps<typeof NoModelScreen>> = {}) {
return render(
<NoModelScreen
styles={styles}
colors={colors}
navigation={{ goBack: jest.fn() }}
hasAvailableModels
showModelSelector={false}
setShowModelSelector={jest.fn()}
onSelectModel={jest.fn()}
onUnloadModel={jest.fn()}
isModelLoading={false}
{...overrides}
/>,
);
}

describe('NoModelScreen', () => {
it('shows the "Select Model" prompt when idle (not loading)', () => {
const { getByText, queryByTestId } = renderScreen({ isModelLoading: false });
expect(getByText('No Model Selected')).toBeTruthy();
expect(getByText('Select Model')).toBeTruthy();
expect(queryByTestId('no-model-loading-indicator')).toBeNull();
});

it('shows a loading indicator (not the prompt) while a model loads in the background', () => {
const { getByText, getByTestId, queryByText } = renderScreen({ isModelLoading: true });
expect(getByTestId('no-model-loading-indicator')).toBeTruthy();
expect(getByText('Loading Model')).toBeTruthy();
// The idle prompt + Select button must be hidden so the user isn't told to
// re-select a model that is already loading.
expect(queryByText('No Model Selected')).toBeNull();
expect(queryByText('Select Model')).toBeNull();
});
});
6 changes: 4 additions & 2 deletions __tests__/unit/hooks/useChatModelActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ describe('initiateModelLoad', () => {
loadAnyway.onPress();
await jest.advanceTimersByTimeAsync(400); // flush waitForRenderFrame -> doLoadTextModel -> resume

expect(mockLoadTextModel).toHaveBeenCalledWith('model-1');
// Load Anyway must force the residency gate too (override:true), or the load
// re-hits the budget and fails — the exact "Load Anyway did nothing" bug.
expect(mockLoadTextModel).toHaveBeenCalledWith('model-1', undefined, { override: true });
expect(onResume).toHaveBeenCalledTimes(1); // the message is NOT dropped
iaSpy.mockRestore();
jest.useRealTimers();
Expand All @@ -192,7 +194,7 @@ describe('initiateModelLoad', () => {
alert.buttons.find((b: any) => b.text === 'Load Anyway').onPress();
await jest.advanceTimersByTimeAsync(400);

expect(mockLoadTextModel).toHaveBeenCalledWith('model-1'); // still loads
expect(mockLoadTextModel).toHaveBeenCalledWith('model-1', undefined, { override: true }); // still loads (forced)
iaSpy.mockRestore();
jest.useRealTimers();
});
Expand Down
86 changes: 86 additions & 0 deletions __tests__/unit/services/loadPolicySync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* loadPolicySync — the single projection of the persisted "aggressive model
* loading" setting onto the residency manager's runtime policy.
*
* Drives the REAL appStore and the REAL modelResidencyManager (the thing under
* test is not mocked) so a green test means the projection actually works end to
* end: flip the setting → the manager's policy changes.
*/
import { loadPolicyFromSettings, startLoadPolicySync } from '../../../src/services/loadPolicySync';
import { modelResidencyManager } from '../../../src/services/modelResidency';
import { useAppStore } from '../../../src/stores';

describe('loadPolicyFromSettings (the one boolean→policy mapping)', () => {
it('maps the flag to a policy', () => {
expect(loadPolicyFromSettings({ aggressiveModelLoading: true })).toBe('aggressive');
expect(loadPolicyFromSettings({ aggressiveModelLoading: false })).toBe('balanced');
expect(loadPolicyFromSettings({})).toBe('balanced');
});
});

describe('startLoadPolicySync', () => {
let unsubscribe: (() => void) | undefined;

beforeEach(() => {
useAppStore.getState().updateSettings({ aggressiveModelLoading: false });
modelResidencyManager.setLoadPolicy('balanced');
});
afterEach(() => {
unsubscribe?.();
unsubscribe = undefined;
modelResidencyManager.setLoadPolicy('balanced');
});

it('seeds the manager from the current (hydrated) setting on start', () => {
useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
modelResidencyManager.setLoadPolicy('balanced'); // simulate a fresh manager
unsubscribe = startLoadPolicySync();
expect(modelResidencyManager.getLoadPolicy()).toBe('aggressive');
});

it('projects a later toggle onto the manager (View dispatches intent, service owns state)', () => {
unsubscribe = startLoadPolicySync();
expect(modelResidencyManager.getLoadPolicy()).toBe('balanced');

useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
expect(modelResidencyManager.getLoadPolicy()).toBe('aggressive');

useAppStore.getState().updateSettings({ aggressiveModelLoading: false });
expect(modelResidencyManager.getLoadPolicy()).toBe('balanced');
});

it('stops projecting after unsubscribe', () => {
unsubscribe = startLoadPolicySync();
unsubscribe();
unsubscribe = undefined;
useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
// No longer synced → manager keeps its last value.
expect(modelResidencyManager.getLoadPolicy()).toBe('balanced');
});

it('is a singleton — calling twice does NOT stack subscriptions (leak guard)', () => {
const first = startLoadPolicySync();
const second = startLoadPolicySync();
expect(second).toBe(first); // same live unsubscribe returned, not a new subscription
unsubscribe = first;

const spy = jest.spyOn(modelResidencyManager, 'setLoadPolicy');
// One flag flip → setLoadPolicy fires exactly once, not once-per-start.
useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});

it('does not fire setLoadPolicy for unrelated setting changes (only on flag flip)', () => {
unsubscribe = startLoadPolicySync();
const spy = jest.spyOn(modelResidencyManager, 'setLoadPolicy');
// Change an unrelated setting several times.
useAppStore.getState().updateSettings({ temperature: 0.5 });
useAppStore.getState().updateSettings({ maxTokens: 2048 });
expect(spy).not.toHaveBeenCalled();
// Flipping the flag DOES fire it.
useAppStore.getState().updateSettings({ aggressiveModelLoading: true });
expect(spy).toHaveBeenCalledWith('aggressive');
spy.mockRestore();
});
});
38 changes: 38 additions & 0 deletions __tests__/unit/services/memoryBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
modelBudgetFraction,
modelMemoryBudgetMB,
modelWarningThresholdMB,
memoryReserveMB,
MEMORY_RESERVE_MB,
AGGRESSIVE_RESERVE_MB,
} from '../../../src/services/memoryBudget';

const GB = 1024;
Expand Down Expand Up @@ -54,3 +56,39 @@ describe('modelWarningThresholdMB', () => {
}
});
});

describe('load policy — aggressive vs balanced', () => {
it("defaults to balanced (behaviour-neutral) when policy omitted", () => {
for (const gb of [4, 8, 12, 24]) {
expect(modelBudgetFraction(gb, 'android')).toBe(modelBudgetFraction(gb, 'android', 'balanced'));
expect(modelMemoryBudgetMB(gb * GB, 'android')).toBe(modelMemoryBudgetMB(gb * GB, 'android', 'balanced'));
}
});

it('aggressive commits a strictly larger fraction at every tier', () => {
for (const [gb, plat] of [[4, 'android'], [8, 'android'], [12, 'android'], [12, 'ios'], [24, 'android']] as const) {
expect(modelBudgetFraction(gb, plat, 'aggressive')).toBeGreaterThan(modelBudgetFraction(gb, plat, 'balanced'));
}
});

it('aggressive holds a smaller (but non-zero) OS reserve — the lenient safeguard', () => {
expect(memoryReserveMB('aggressive')).toBe(AGGRESSIVE_RESERVE_MB);
expect(memoryReserveMB('balanced')).toBe(MEMORY_RESERVE_MB);
expect(AGGRESSIVE_RESERVE_MB).toBeGreaterThan(0);
expect(AGGRESSIVE_RESERVE_MB).toBeLessThan(MEMORY_RESERVE_MB);
});

it('fails-before/passes-after: a 21GB model is rejected on a 24GB phone under balanced but fits under aggressive', () => {
const total = 24 * GB;
const model = 21 * GB;
// Balanced: 24GB * 0.70 = 16.8GB budget → 21GB does NOT fit.
expect(modelMemoryBudgetMB(total, 'android', 'balanced')).toBeLessThan(model);
// Aggressive: pushes near the physical ceiling → 21GB fits (Nico's Qwen3 MoE case).
expect(modelMemoryBudgetMB(total, 'android', 'aggressive')).toBeGreaterThanOrEqual(model);
});

it('aggressive still never commits past its own reserve floor', () => {
const total = 24 * GB;
expect(modelMemoryBudgetMB(total, 'android', 'aggressive')).toBeLessThanOrEqual(total - AGGRESSIVE_RESERVE_MB);
});
});
28 changes: 28 additions & 0 deletions __tests__/unit/services/modelLoadErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { OverridableMemoryError, isOverridableMemoryError } from '../../../src/services/modelLoadErrors';

describe('OverridableMemoryError', () => {
it('is a real Error subclass carrying the overridable discriminant', () => {
const err = new OverridableMemoryError('no room');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(OverridableMemoryError);
expect(err.message).toBe('no room');
expect(err.name).toBe('OverridableMemoryError');
expect(err.overridable).toBe(true);
});

it('isOverridableMemoryError recognises the class', () => {
expect(isOverridableMemoryError(new OverridableMemoryError('x'))).toBe(true);
});

it('isOverridableMemoryError recognises a duck-typed object (survives async/serialisation boundaries)', () => {
expect(isOverridableMemoryError({ overridable: true, message: 'x' })).toBe(true);
});

it('rejects plain errors and non-errors', () => {
expect(isOverridableMemoryError(new Error('generic'))).toBe(false);
expect(isOverridableMemoryError('memory')).toBe(false);
expect(isOverridableMemoryError(null)).toBe(false);
expect(isOverridableMemoryError(undefined)).toBe(false);
expect(isOverridableMemoryError({ overridable: false })).toBe(false);
});
});
Loading