diff --git a/docs/commands/clean.md b/docs/commands/clean.md new file mode 100644 index 0000000..4c86c6a --- /dev/null +++ b/docs/commands/clean.md @@ -0,0 +1,45 @@ +--- +title: "nanotune clean" +description: "Remove the cached fused model to reclaim disk space" +sidebar_order: 9 +--- + +# nanotune clean + +Remove the fused model cache at `.nanotune/models/fused/`. + +`nanotune export` keeps a full-precision copy of the fused model around after export so that `nanotune export --skip-fuse` can reuse it for a different quantization without redoing the fusion step. That directory can be multiple gigabytes — `nanotune clean` deletes it to reclaim the space. + +## Usage + +```bash +nanotune clean +``` + +## Options + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Skip the confirmation prompt (for scripts and CI) | + +## Examples + +```bash +# Interactively confirm before removing the fused model cache +nanotune clean + +# Remove it without prompting +nanotune clean --yes +``` + +## What It Does + +1. Checks `.nanotune/models/fused/` for a cached fused model. +2. If found, shows its size and asks for confirmation (unless `--yes` is passed). +3. Deletes the directory and reports how much space was freed. + +If there's nothing to clean, it says so and exits without changes. Your exported `.gguf` files are never touched — only the intermediate fused model is removed. The next `nanotune export` will simply re-fuse the adapter. + +## See Also + +- [`nanotune export`](export.md) — See "Fused Model Cache" for why this directory exists diff --git a/docs/commands/export.md b/docs/commands/export.md index 5fc188f..06e89a0 100644 --- a/docs/commands/export.md +++ b/docs/commands/export.md @@ -20,7 +20,7 @@ nanotune export |------|-------------| | `-q, --quantization ` | Quantization type: `f16`, `q8_0`, `q4_k_m`, `q4_k_s` | | `-o, --output ` | Output filename | -| `--skip-fuse` | Skip adapter fusion if already fused | +| `--skip-fuse` | Skip adapter fusion — requires a `fused/` cache from a previous export | ## Examples @@ -46,6 +46,12 @@ nanotune export --skip-fuse Pre-built llama.cpp binaries are downloaded automatically — no compilation needed. +## Fused Model Cache + +Fusing the LoRA adapter into the base model produces a full-precision copy, which is kept at `.nanotune/models/fused/` after export finishes — it's not deleted. Keeping it around lets `nanotune export --skip-fuse` reuse it to produce a different quantization without redoing the fusion step. + +That directory can be multiple gigabytes. Its size is shown when export completes and in `nanotune status`. Run [`nanotune clean`](clean.md) to remove it and reclaim the space — the next export will simply re-fuse the adapter. + ## Quantization Types | Type | Description | diff --git a/docs/commands/index.md b/docs/commands/index.md index 1b02e0e..916d360 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -12,6 +12,7 @@ A complete reference for all Nanotune CLI commands. |---------|-------------| | [`nanotune init`](init.md) | Initialize a new fine-tuning project | | [`nanotune status`](status.md) | Show current project status | +| [`nanotune clean`](clean.md) | Remove the cached fused model to reclaim disk space | | [`nanotune data add`](data.md) | Add training examples interactively | | [`nanotune data import`](data.md) | Import training data from a file | | [`nanotune data list`](data.md) | View and manage training data | diff --git a/docs/commands/status.md b/docs/commands/status.md index e2c00a7..e6efef5 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -19,4 +19,5 @@ nanotune status - **Training data** — Number of examples loaded - **Training progress** — Current state and last training run - **Exports** — Available GGUF files +- **Fused model cache** — Disk space used by the retained `fused/` model, if present - **Benchmark results** — Latest benchmark summary diff --git a/src/cli.tsx b/src/cli.tsx index 261897c..4e44803 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -173,7 +173,10 @@ program 'Quantization type (f16, q8_0, q4_k_m, q4_k_s)', ) .option('-o, --output ', 'Output filename') - .option('--skip-fuse', 'Skip adapter fusion (if already fused)') + .option( + '--skip-fuse', + 'Skip adapter fusion — requires a fused/ cache from a previous export', + ) .action(async options => { const {ExportCommand} = await import('./commands/export.js'); render(); @@ -293,4 +296,26 @@ program render(); }); +// Clean command +program + .command('clean') + .description('Remove the cached fused model to reclaim disk space') + .option('-y, --yes', 'Skip the confirmation prompt (for scripts and CI)') + .action(async (options: {yes?: boolean}) => { + // Only require --yes when there's actually a confirmation to answer — + // "nothing to clean" and "not a project" are safe to just report. + if (!options.yes && !supportsRawMode()) { + const {configExists, getFusedModelDir, hasUsableFusedModel} = + await import('./lib/config.js'); + if (configExists() && hasUsableFusedModel(getFusedModelDir())) { + console.error(interactiveRequiredMessage('clean')); + console.error('Pass --yes to clean without confirmation.'); + process.exitCode = 1; + return; + } + } + const {CleanCommand} = await import('./commands/clean.js'); + render(); + }); + program.parse(); diff --git a/src/commands/clean.tsx b/src/commands/clean.tsx new file mode 100644 index 0000000..6f006e1 --- /dev/null +++ b/src/commands/clean.tsx @@ -0,0 +1,156 @@ +import {rmSync} from 'node:fs'; +import {StatusMessage} from '@inkjs/ui'; +import {Box, Text, useApp} from 'ink'; +import {useCallback, useEffect, useState} from 'react'; +import { + ExitHint, + Header, + useAutoExit, + useKeyInput, +} from '../components/index.js'; +import { + configExists, + formatFileSize, + getDirectorySize, + getFusedModelDir, + hasUsableFusedModel, +} from '../lib/config.js'; + +interface Props { + options: { + /** Skip the y/n confirmation — needed to run under CI or in a pipeline. */ + yes?: boolean; + }; +} + +type Status = 'confirm' | 'cleaning' | 'nothing' | 'done' | 'error'; + +export function CleanCommand({options}: Props) { + const {exit} = useApp(); + const hasProject = configExists(); + const fusedDir = getFusedModelDir(); + const fusedExists = hasProject && hasUsableFusedModel(fusedDir); + + const [status, setStatus] = useState(() => { + if (!hasProject) return 'error'; + if (!fusedExists) return 'nothing'; + return options.yes ? 'cleaning' : 'confirm'; + }); + const [error, setError] = useState(null); + const [sizeLabel] = useState(() => + fusedExists ? formatFileSize(getDirectorySize(fusedDir)) : null, + ); + + const doClean = useCallback(() => { + try { + rmSync(fusedDir, {recursive: true, force: true}); + setStatus('done'); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to remove fused model cache', + ); + setStatus('error'); + } + }, [fusedDir]); + + // `--yes` (or the initial `cleaning` state it sets above) skips straight + // past the confirmation prompt. + useEffect(() => { + if (status === 'cleaning') { + doClean(); + } + }, [status, doClean]); + + useKeyInput(input => { + if (status === 'confirm') { + if (input.toLowerCase() === 'y') { + setStatus('cleaning'); + } else if (input.toLowerCase() === 'n' || input === '\x1b') { + exit(); + } + } else if ( + status === 'done' || + status === 'nothing' || + status === 'error' + ) { + exit(); + } + }); + + useAutoExit( + status === 'done' || status === 'nothing' || status === 'error', + status === 'error', + ); + + if (!hasProject) { + return ( + +
+ + Not a Nanotune project. Run `nanotune init` first. + + + ); + } + + return ( + +
+ + {status === 'confirm' && ( + + + Fused model cache: {sizeLabel} at{' '} + .nanotune/models/fused + + + This is kept to speed up repeat exports via --skip-fuse. + + + + Remove it? (y/n) + + + )} + + {status === 'cleaning' && Removing fused model cache...} + + {status === 'nothing' && ( + + + Nothing to clean — no fused model cache found. + + + Press any key to exit + + )} + + {status === 'done' && ( + + + Removed fused model cache + + + + Freed: {sizeLabel} + + + The next `nanotune export` will re-fuse the adapter. + + + Press any key to exit + + )} + + {status === 'error' && ( + + {error} + + Press any key to exit + + )} + + ); +} diff --git a/src/commands/commands.spec.tsx b/src/commands/commands.spec.tsx index ef9a080..06dcd89 100644 --- a/src/commands/commands.spec.tsx +++ b/src/commands/commands.spec.tsx @@ -1,10 +1,12 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import test from "ava"; import { Text } from "ink"; import { render } from "ink-testing-library"; import { useKeyInput } from "../components/index.js"; +import { getFusedModelDir } from "../lib/config.js"; import { loadTrainingData } from "../lib/data.js"; +import { CleanCommand } from "./clean.js"; import { streamPreview } from "./chat.js"; import { DataExportCommand } from "./data/export.js"; import { DataImportCommand } from "./data/import.js"; @@ -61,6 +63,20 @@ function writeExamples(lines: object[]) { ); } +function writeFusedModel() { + const fusedDir = join(NANOTUNE_DIR, "models", "fused"); + mkdirSync(fusedDir, { recursive: true }); + writeFileSync(join(fusedDir, "model.safetensors"), "x".repeat(1024)); +} + +// Simulates an export interrupted before mlx_lm.fuse finished writing +// weights: the directory exists but has no .safetensors file yet. +function writeIncompleteFusedModel() { + const fusedDir = join(NANOTUNE_DIR, "models", "fused"); + mkdirSync(fusedDir, { recursive: true }); + writeFileSync(join(fusedDir, "config.json"), "{}"); +} + function writeEvalExamples(lines: object[]) { writeFileSync( join(DATA_DIR, "valid.jsonl"), @@ -280,6 +296,206 @@ test.serial("DataImportCommand without yes waits for confirmation", async (t) => } }); +// ── clean ──────────────────────────────────────────────────────────── + +test.serial("CleanCommand renders its error state with no project", async (t) => { + try { + setupEmptyDir(); + const output = await renderCommand( + , + "Not a Nanotune project", + ); + t.true(output.includes("Not a Nanotune project")); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand reports nothing to clean when fused/ is absent", async (t) => { + try { + setupProject(); + const output = await renderCommand( + , + "Nothing to clean", + ); + t.true(output.includes("Nothing to clean")); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand with yes removes fused/ without prompting", async (t) => { + try { + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + t.true(existsSync(fusedDir)); + const output = await renderCommand( + , + "Removed fused model cache", + ); + t.false(output.includes("Remove it?")); + t.true(output.includes("Removed fused model cache")); + t.true(output.includes("Freed:")); + t.false(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand without yes waits for confirmation before deleting", async (t) => { + try { + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const output = await renderCommand( + , + "Remove it?", + ); + t.true(output.includes("Remove it?")); + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand treats a leftover incomplete fused/ as nothing to clean", async (t) => { + try { + setupProject(); + writeIncompleteFusedModel(); + const fusedDir = getFusedModelDir(); + const output = await renderCommand( + , + "Nothing to clean", + ); + t.true(output.includes("Nothing to clean")); + t.false(output.includes("Remove it?")); + // The (non-safetensors) leftover directory is left alone, not deleted. + t.true(existsSync(fusedDir)); + } finally { + teardown(); + } +}); + +test.serial("CleanCommand deletes the cache on a confirming 'y' keypress", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("y"); + const timeout = 2000; + const pollInterval = 10; + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (instance.frames.join("\n").includes("Removed fused model cache")) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + const output = instance.frames.join("\n"); + instance.unmount(); + + t.true(output.includes("Removed fused model cache")); + t.true(output.includes("Freed:")); + t.false(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + +test.serial("CleanCommand leaves the cache in place on an 'n' keypress", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("n"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const output = instance.frames.join("\n"); + instance.unmount(); + + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + +test.serial("CleanCommand leaves the cache in place on Escape", async (t) => { + const originalIsTTY = process.stdin.isTTY; + try { + process.stdin.isTTY = true as true; + setupProject(); + writeFusedModel(); + const fusedDir = getFusedModelDir(); + const instance = render(); + await new Promise((resolve) => setTimeout(resolve, 50)); + t.true(instance.frames.join("\n").includes("Remove it?")); + + instance.stdin.write("\x1b"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const output = instance.frames.join("\n"); + instance.unmount(); + + t.false(output.includes("Removed fused model cache")); + t.true(existsSync(fusedDir)); + } finally { + process.stdin.isTTY = originalIsTTY; + teardown(); + } +}); + +// ── status: fused model cache ─────────────────────────────────────── + +test.serial("StatusCommand shows the fused model cache when present", async (t) => { + try { + setupProject(); + writeFusedModel(); + const output = await renderCommand(, "Fused model cache"); + t.true(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + +test.serial("StatusCommand omits the fused model cache line when absent", async (t) => { + try { + setupProject(); + const output = await renderCommand(, "Exports:"); + t.false(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + +test.serial("StatusCommand omits the fused model cache line for a leftover incomplete fused/", async (t) => { + // Regression: status used existsSync while clean used hasUsableFusedModel, + // so an interrupted export made status report a cache that clean then + // said didn't exist. Both must agree. + try { + setupProject(); + writeIncompleteFusedModel(); + const output = await renderCommand(, "Exports:"); + t.false(output.includes("Fused model cache")); + } finally { + teardown(); + } +}); + // ── chat streaming preview ──────────────────────────────────────────── test("streamPreview passes short content through untouched", (t) => { diff --git a/src/commands/export.tsx b/src/commands/export.tsx index b924680..10e9fff 100644 --- a/src/commands/export.tsx +++ b/src/commands/export.tsx @@ -13,9 +13,14 @@ import { } from '../components/index.js'; import { configExists, + formatFileSize, getAdaptersDir, + getDirectorySize, + getFusedModelDir, getModelsDir, + hasUsableFusedModel, loadConfig, + skipFuseValidationError, } from '../lib/config.js'; import { checkLlamaCppInstalled, @@ -55,6 +60,7 @@ export function ExportCommand({options}: Props) { const [currentStep, setCurrentStep] = useState(''); const [outputPath, setOutputPath] = useState(null); const [fileSize, setFileSize] = useState(null); + const [fusedCacheInfo, setFusedCacheInfo] = useState(null); const [steps, setSteps] = useState([ {name: 'Fusing adapters', status: 'pending'}, {name: 'Converting to GGUF', status: 'pending'}, @@ -91,6 +97,7 @@ export function ExportCommand({options}: Props) { const config = loadConfig(); const adaptersDir = getAdaptersDir(); const modelsDir = getModelsDir(); + const fusedModelPath = getFusedModelDir(); // Check adapters exist const adapterFile = join(adaptersDir, 'adapters.safetensors'); @@ -100,6 +107,19 @@ export function ExportCommand({options}: Props) { return; } + // Fail fast with a clear message rather than a cryptic error deep + // inside GGUF conversion if --skip-fuse is passed with no fused + // model on disk (e.g. after `nanotune clean`). + const skipFuseError = skipFuseValidationError( + options.skipFuse, + hasUsableFusedModel(fusedModelPath), + ); + if (skipFuseError) { + setError(skipFuseError); + setStatus('error'); + return; + } + // Check llama.cpp setStatus('checking'); const hasLlamaCpp = await checkLlamaCppInstalled(); @@ -116,7 +136,6 @@ export function ExportCommand({options}: Props) { const outputName = options.output || `${config.export.outputName}-${quantization}`; const finalOutputPath = join(modelsDir, `${outputName}.gguf`); - const fusedModelPath = join(modelsDir, 'fused'); // Step 1: Fuse adapters if (!options.skipFuse) { @@ -162,8 +181,13 @@ export function ExportCommand({options}: Props) { // Get file size if (existsSync(finalOutputPath)) { const stats = statSync(finalOutputPath); - const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); - setFileSize(`${sizeMB} MB`); + setFileSize(formatFileSize(stats.size)); + } + + // The fused model cache is kept for faster re-exports via + // --skip-fuse; surface its size so it isn't a silent disk cost. + if (hasUsableFusedModel(fusedModelPath)) { + setFusedCacheInfo(formatFileSize(getDirectorySize(fusedModelPath))); } setOutputPath(finalOutputPath); @@ -260,6 +284,12 @@ export function ExportCommand({options}: Props) { Size: {fileSize} )} + {fusedCacheInfo && ( + + Fused model cache: {fusedCacheInfo} at .nanotune/models/fused + (kept for faster re-exports — run `nanotune clean` to remove) + + )} Test your model: llama-cli -m {outputPath} -p "Your prompt here" diff --git a/src/commands/status.tsx b/src/commands/status.tsx index cfda066..89da672 100644 --- a/src/commands/status.tsx +++ b/src/commands/status.tsx @@ -2,6 +2,7 @@ import {existsSync, readdirSync, statSync} from 'node:fs'; import {join} from 'node:path'; import {StatusMessage} from '@inkjs/ui'; import {Box, Text, useApp} from 'ink'; +import {useMemo} from 'react'; import { ExitHint, Header, @@ -12,9 +13,13 @@ import { import { configExists, findLatestBenchmark, + formatFileSize, getAdaptersDir, getDataDir, + getDirectorySize, + getFusedModelDir, getModelsDir, + hasUsableFusedModel, loadBenchmark, loadConfig, } from '../lib/config.js'; @@ -36,17 +41,15 @@ function formatRelativeTime(date: Date): string { return 'just now'; } -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - export function StatusCommand() { const {exit} = useApp(); const hasConfig = configExists(); + const fusedDir = getFusedModelDir(); + const fusedExists = hasConfig && hasUsableFusedModel(fusedDir); + const fusedDirSize = useMemo( + () => (fusedExists ? getDirectorySize(fusedDir) : 0), + [fusedExists, fusedDir], + ); useKeyInput((input, key) => { if (key.escape || key.return || input === 'q') { @@ -104,6 +107,9 @@ export function StatusCommand() { .sort((a, b) => b.modified.getTime() - a.modified.getTime()) : []; + // Fused model cache + const fusedSize = fusedExists ? formatFileSize(fusedDirSize) : null; + // Latest benchmark let latestBenchmark: BenchmarkResult | null = null; const latestBenchmarkPath = findLatestBenchmark(); @@ -179,6 +185,12 @@ export function StatusCommand() { ) : ( {' '}No exported models yet )} + {fusedExists && ( + + {' '}Fused model cache: {fusedSize} (run{' '} + nanotune clean to remove) + + )} Benchmarks: diff --git a/src/lib/config.spec.ts b/src/lib/config.spec.ts index a0d755b..9cd115b 100644 --- a/src/lib/config.spec.ts +++ b/src/lib/config.spec.ts @@ -14,6 +14,10 @@ import { createDefaultConfig, ensureBenchmarksDir, findLatestGGUF, + formatFileSize, + getDirectorySize, + hasUsableFusedModel, + skipFuseValidationError, formatConfigIssues, listBenchmarks, resolveBenchmarkPath, @@ -681,6 +685,119 @@ test.serial("loadConfig prints each unknown key once across loads", (t) => { } }); +// ── formatFileSize ────────────────────────────────────────────────── + +test("formatFileSize formats bytes below 1 KB", (t) => { + t.is(formatFileSize(0), "0 B"); + t.is(formatFileSize(1023), "1023 B"); +}); + +test("formatFileSize formats kilobytes", (t) => { + t.is(formatFileSize(1024), "1.0 KB"); + t.is(formatFileSize(1024 * 1024 - 1), "1024.0 KB"); +}); + +test("formatFileSize formats megabytes", (t) => { + t.is(formatFileSize(1024 * 1024), "1.0 MB"); + t.is(formatFileSize(1024 * 1024 * 1024 - 1), "1024.0 MB"); +}); + +test("formatFileSize formats gigabytes with two decimal places", (t) => { + t.is(formatFileSize(1024 * 1024 * 1024), "1.00 GB"); + t.is(formatFileSize(1.5 * 1024 * 1024 * 1024), "1.50 GB"); +}); + +// ── getDirectorySize / hasUsableFusedModel ────────────────────────── + +const SIZE_TEST_DIR = join(ORIG_CWD, ".test-config-dirsize"); + +function resetSizeTestDir() { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + mkdirSync(SIZE_TEST_DIR, { recursive: true }); +} + +test.serial("getDirectorySize returns 0 for a missing directory", (t) => { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + t.is(getDirectorySize(SIZE_TEST_DIR), 0); +}); + +test.serial("getDirectorySize sums files in a flat directory", (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "a.bin"), "x".repeat(10)); + writeFileSync(join(SIZE_TEST_DIR, "b.bin"), "x".repeat(20)); + t.is(getDirectorySize(SIZE_TEST_DIR), 30); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } +}); + +test.serial("getDirectorySize recurses into nested subdirectories", (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "a.bin"), "x".repeat(10)); + const nested = join(SIZE_TEST_DIR, "nested"); + mkdirSync(nested); + writeFileSync(join(nested, "b.bin"), "x".repeat(20)); + const deeper = join(nested, "deeper"); + mkdirSync(deeper); + writeFileSync(join(deeper, "c.bin"), "x".repeat(5)); + t.is(getDirectorySize(SIZE_TEST_DIR), 35); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } +}); + +test.serial("hasUsableFusedModel is false for a missing directory", (t) => { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + t.false(hasUsableFusedModel(SIZE_TEST_DIR)); +}); + +test.serial( + "hasUsableFusedModel is false when the directory has no .safetensors file", + (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "config.json"), "{}"); + t.false(hasUsableFusedModel(SIZE_TEST_DIR)); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } + }, +); + +test.serial( + "hasUsableFusedModel is true once a .safetensors file is present", + (t) => { + resetSizeTestDir(); + try { + writeFileSync(join(SIZE_TEST_DIR, "config.json"), "{}"); + writeFileSync(join(SIZE_TEST_DIR, "model.safetensors"), "x"); + t.true(hasUsableFusedModel(SIZE_TEST_DIR)); + } finally { + rmSync(SIZE_TEST_DIR, { recursive: true, force: true }); + } + }, +); + +// ── skipFuseValidationError ───────────────────────────────────────── + +test("skipFuseValidationError is null when --skip-fuse is not set", (t) => { + t.is(skipFuseValidationError(false, false), null); + t.is(skipFuseValidationError(undefined, false), null); +}); + +test("skipFuseValidationError is null when --skip-fuse is set and fused/ exists", (t) => { + t.is(skipFuseValidationError(true, true), null); +}); + +test("skipFuseValidationError reports a clear error when --skip-fuse is set but fused/ is missing", (t) => { + const message = skipFuseValidationError(true, false); + t.truthy(message); + t.true(message!.includes("--skip-fuse")); + t.true(message!.includes("fused")); +}); + // ── ensureBenchmarksDir ─────────────────────────────────────────────── // diff --git a/src/lib/config.ts b/src/lib/config.ts index 4a6e781..2db7a9c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -62,6 +62,74 @@ export function getChatsDir(): string { return join(getProjectDir(), 'chats'); } +export function getFusedModelDir(): string { + return join(getModelsDir(), 'fused'); +} + +/** + * Recursively sum the size of every file under `dirPath`. Returns 0 if the + * directory doesn't exist. Used to report the size of the fused/ model + * cache, which is a directory of HF-format model files rather than a + * single file. + */ +export function getDirectorySize(dirPath: string): number { + if (!existsSync(dirPath)) { + return 0; + } + let total = 0; + for (const entry of readdirSync(dirPath, {withFileTypes: true})) { + const entryPath = join(dirPath, entry.name); + try { + if (entry.isDirectory()) { + total += getDirectorySize(entryPath); + } else if (entry.isFile()) { + total += statSync(entryPath).size; + } + } catch { + // Broken symlink or unreadable entry (permissions, race with a + // concurrent delete) — skip it rather than failing the whole walk. + } + } + return total; +} + +/** + * Whether `dirPath` contains a real fused-model artifact rather than just an + * empty or partially-written directory. `mlx_lm.fuse` creates `--save-path` + * before it finishes writing weights, so directory existence alone isn't a + * reliable signal that a fuse completed. + */ +export function hasUsableFusedModel(dirPath: string): boolean { + if (!existsSync(dirPath)) { + return false; + } + return readdirSync(dirPath).some(name => name.endsWith('.safetensors')); +} + +/** + * `--skip-fuse` relies on a fused model from a previous export. Lives here + * rather than in `commands/export.tsx` so it's testable without importing + * that file's `ExportCommand` component, which asserts Apple Silicon up + * front and so can never actually render in CI. + */ +export function skipFuseValidationError( + skipFuse: boolean | undefined, + fusedModelExists: boolean, +): string | null { + if (skipFuse && !fusedModelExists) { + return 'No fused model found at .nanotune/models/fused. Run `nanotune export` without --skip-fuse first.'; + } + return null; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + export function configExists(): boolean { return existsSync(getConfigPath()); }