Skip to content

Commit 4543bbd

Browse files
committed
docs: document reproducibility, base-model benchmarking, and CLI training flags in changelog
1 parent a4b2246 commit 4543bbd

17 files changed

Lines changed: 539 additions & 80 deletions

CHANGELOG.md

Lines changed: 195 additions & 0 deletions
Large diffs are not rendered by default.

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

docs/commands/benchmark.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ With sampling enabled, `--samples <n>` runs each test n times and records the pe
5959
nanotune benchmark --temperature 0.8 --samples 5
6060
```
6161

62+
`--temperature`, `--seed`, and `--samples` are rejected outright if they can't be parsed, rather than falling back to the default. A typo like `--samples 5x` fails with a message naming the flag, so a run never reports a score under settings you didn't ask for.
63+
6264
## Examples
6365

6466
```bash

docs/commands/data.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,15 @@ Export training data to a file. Uses the same formats as `data import`, selected
8484
| Flag | Description |
8585
|------|-------------|
8686
| `-y, --yes` | Skip the overwrite confirmation prompt |
87+
| `-e, --eval` | Export the validation set (`valid.jsonl`) instead of training data |
8788

8889
JSONL and JSON exports preserve every example exactly, including multi-turn conversations and per-example context messages — feeding the output back into `data import` reproduces the original data. **CSV has no way to represent multi-turn examples**, so any example with more than one turn is skipped (not truncated) during a CSV export, and reported in the summary.
8990

9091
If the target file already exists, you're asked to confirm before it's overwritten. Pass `--yes` to skip the prompt — this is also what lets `data export` run in a script or CI job.
9192

9293
```bash
9394
nanotune data export backup.jsonl --yes
95+
nanotune data export valid-backup.jsonl --eval --yes
9496
```
9597

9698
## nanotune data list

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nanocollective/nanotune",
3-
"version": "1.6.0",
3+
"version": "1.7.0",
44
"type": "module",
55
"description": "A simple, interactive CLI for fine-tuning small language models on Apple Silicon. No YAML configs, no complex flags - just an interactive CLI that guides you through the process. ⚒️",
66
"bin": {

src/cli.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ dataCommand
8282
'-y, --yes',
8383
'Skip the overwrite confirmation prompt (for scripts and CI)',
8484
)
85-
.action(async (file: string, options: {yes?: boolean}) => {
85+
.option('-e, --eval', 'Export the validation set instead of training data')
86+
.action(async (file: string, options: {yes?: boolean; eval?: boolean}) => {
8687
const {DataExportCommand} = await import('./commands/data/export.js');
8788
// Without a TTY there is no way to answer the overwrite prompt, so require --yes.
8889
if (!options.yes && !supportsRawMode()) {
@@ -91,7 +92,9 @@ dataCommand
9192
process.exitCode = 1;
9293
return;
9394
}
94-
render(<DataExportCommand file={file} yes={options.yes} />);
95+
render(
96+
<DataExportCommand file={file} yes={options.yes} isEval={options.eval} />,
97+
);
9598
});
9699

97100
dataCommand

src/commands/benchmark.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,32 @@ export function BenchmarkCommand({options}: Props) {
309309
return;
310310
}
311311

312+
// Resolve the sampling flags before anything expensive. `--base` can
313+
// spend minutes downloading and quantizing a base model, and failing
314+
// a typo'd `--samples` only after that has finished wastes the whole
315+
// run on something we could see immediately.
316+
const sampling = resolveSamplingOptions({
317+
temperature: options.temperature,
318+
seed: options.seed,
319+
samples: options.samples,
320+
});
321+
322+
// Reject a mistyped flag rather than running a suite under settings
323+
// the user didn't ask for.
324+
if (sampling.errors.length > 0) {
325+
setError(sampling.errors[0]);
326+
setStatus('error');
327+
return;
328+
}
329+
330+
if (sampling.samples > 1 && sampling.temperature === 0) {
331+
setError(
332+
'Cannot use --samples with temperature 0 (greedy decoding produces identical outputs). Use --temperature 0.1 or higher for sampling.',
333+
);
334+
setStatus('error');
335+
return;
336+
}
337+
312338
// Find model
313339
let modelPath: string | null;
314340
if (options.base) {
@@ -452,22 +478,6 @@ export function BenchmarkCommand({options}: Props) {
452478
}
453479
}
454480

455-
// Build inference options from CLI flags or preset
456-
const sampling = resolveSamplingOptions({
457-
temperature: options.temperature,
458-
seed: options.seed,
459-
samples: options.samples,
460-
});
461-
462-
// Validate sampling configuration
463-
if (sampling.samples > 1 && sampling.temperature === 0) {
464-
setError(
465-
'Cannot use --samples with temperature 0 (greedy decoding produces identical outputs). Use --temperature 0.1 or higher for sampling.',
466-
);
467-
setStatus('error');
468-
return;
469-
}
470-
471481
let serverOptions: ServerOptions;
472482
let generateOptions: GenerateOptions;
473483

src/commands/commands.spec.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import test from "ava";
44
import { Text } from "ink";
55
import { render } from "ink-testing-library";
66
import { useKeyInput } from "../components/index.js";
77
import { loadTrainingData } from "../lib/data.js";
88
import { streamPreview } from "./chat.js";
9+
import { DataExportCommand } from "./data/export.js";
910
import { DataImportCommand } from "./data/import.js";
1011
import { DataListCommand } from "./data/list.js";
1112
import { DataValidateCommand } from "./data/validate.js";
@@ -60,6 +61,19 @@ function writeExamples(lines: object[]) {
6061
);
6162
}
6263

64+
function writeEvalExamples(lines: object[]) {
65+
writeFileSync(
66+
join(DATA_DIR, "valid.jsonl"),
67+
`${lines.map((l) => JSON.stringify(l)).join("\n")}\n`,
68+
);
69+
}
70+
71+
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
72+
73+
function userContent(example: { messages: { role: string; content: string }[] }) {
74+
return example.messages.find((m) => m.role === "user")?.content;
75+
}
76+
6377
function example(userInput: string) {
6478
return {
6579
messages: [
@@ -293,3 +307,82 @@ test("streamPreview clips a single very long line by characters", (t) => {
293307
t.true(truncated);
294308
t.is(text.length, 2000);
295309
});
310+
311+
// ── data list edits the set it was opened on ──────────────────────────
312+
313+
test.serial(
314+
"DataListCommand with --eval edits valid.jsonl and leaves train.jsonl alone",
315+
async (t) => {
316+
// Regression: the edit path called updateTrainingExample/loadTrainingData
317+
// without isEval, so editing a validation example overwrote the training
318+
// example at the same index instead.
319+
const originalTTY = process.stdin.isTTY;
320+
try {
321+
setupProject();
322+
writeExamples([example("train-one")]);
323+
writeEvalExamples([example("valid-one")]);
324+
process.stdin.isTTY = true;
325+
326+
const instance = render(<DataListCommand isEval />);
327+
await settle();
328+
instance.stdin.write("e"); // enter edit mode
329+
await settle();
330+
instance.stdin.write("\r"); // submit user input unchanged
331+
await settle();
332+
instance.stdin.write("\r"); // submit assistant output unchanged
333+
await settle();
334+
instance.unmount();
335+
336+
t.is(userContent(loadTrainingData(false)[0]), "train-one");
337+
t.is(userContent(loadTrainingData(true)[0]), "valid-one");
338+
t.is(loadTrainingData(false).length, 1);
339+
t.is(loadTrainingData(true).length, 1);
340+
} finally {
341+
process.stdin.isTTY = originalTTY;
342+
teardown();
343+
}
344+
},
345+
);
346+
347+
// ── data export honours --eval ────────────────────────────────────────
348+
349+
test.serial("DataExportCommand exports training data by default", async (t) => {
350+
try {
351+
setupProject();
352+
writeExamples([example("train-one"), example("train-two")]);
353+
writeEvalExamples([example("valid-one")]);
354+
355+
await renderCommand(
356+
<DataExportCommand file="out.jsonl" yes />,
357+
"Export complete!",
358+
);
359+
360+
const written = readFileSync(join(TEST_DIR, "out.jsonl"), "utf-8").trim();
361+
t.is(written.split("\n").length, 2);
362+
t.true(written.includes("train-one"));
363+
t.false(written.includes("valid-one"));
364+
} finally {
365+
teardown();
366+
}
367+
});
368+
369+
test.serial("DataExportCommand with --eval exports the validation set", async (t) => {
370+
try {
371+
setupProject();
372+
writeExamples([example("train-one"), example("train-two")]);
373+
writeEvalExamples([example("valid-one")]);
374+
375+
const output = await renderCommand(
376+
<DataExportCommand file="out.jsonl" yes isEval />,
377+
"Export complete!",
378+
);
379+
380+
t.true(output.includes("Export Validation Data"));
381+
const written = readFileSync(join(TEST_DIR, "out.jsonl"), "utf-8").trim();
382+
t.is(written.split("\n").length, 1);
383+
t.true(written.includes("valid-one"));
384+
t.false(written.includes("train-one"));
385+
} finally {
386+
teardown();
387+
}
388+
});

src/commands/data/export.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ interface Props {
1616
file: string;
1717
/** Skip the overwrite confirmation — needed to run under CI or in a pipeline. */
1818
yes?: boolean;
19+
isEval?: boolean;
1920
}
2021

21-
export function DataExportCommand({file, yes}: Props) {
22+
export function DataExportCommand({file, yes, isEval = false}: Props) {
2223
const {exit} = useApp();
2324
const filePath = resolve(process.cwd(), file);
2425
const fileExists = existsSync(filePath);
@@ -27,7 +28,8 @@ export function DataExportCommand({file, yes}: Props) {
2728
>(!fileExists || yes ? 'exporting' : 'confirm');
2829
const [result, setResult] = useState<ExportResult | null>(null);
2930
const [error, setError] = useState<string | null>(null);
30-
const count = configExists() ? countExamples() : 0;
31+
const count = configExists() ? countExamples(isEval) : 0;
32+
const title = isEval ? 'Export Validation Data' : 'Export Training Data';
3133

3234
const doExport = useCallback(() => {
3335
try {
@@ -37,7 +39,7 @@ export function DataExportCommand({file, yes}: Props) {
3739
return;
3840
}
3941

40-
const exportResult = exportData(filePath);
42+
const exportResult = exportData(filePath, isEval);
4143
if (
4244
exportResult.exported === 0 &&
4345
exportResult.errors[0]?.startsWith('Unsupported')
@@ -53,7 +55,7 @@ export function DataExportCommand({file, yes}: Props) {
5355
setError(err instanceof Error ? err.message : 'Export failed');
5456
setStatus('error');
5557
}
56-
}, [filePath]);
58+
}, [filePath, isEval]);
5759

5860
useEffect(() => {
5961
if (status === 'exporting') {
@@ -78,7 +80,7 @@ export function DataExportCommand({file, yes}: Props) {
7880
if (!configExists()) {
7981
return (
8082
<Box flexDirection="column" padding={1}>
81-
<Header title="Export Training Data" />
83+
<Header title={title} />
8284
<StatusMessage variant="error">
8385
Not a Nanotune project. Run `nanotune init` first.
8486
</StatusMessage>
@@ -88,7 +90,7 @@ export function DataExportCommand({file, yes}: Props) {
8890

8991
return (
9092
<Box flexDirection="column" padding={1}>
91-
<Header title="Export Training Data" />
93+
<Header title={title} />
9294

9395
{status === 'confirm' && (
9496
<Box flexDirection="column">

src/commands/data/list.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export function DataListCommand({isEval = false}: Props) {
5656
assistantOutput,
5757
),
5858
};
59-
updateTrainingExample(editIndex, updated);
60-
setData(loadTrainingData());
59+
updateTrainingExample(editIndex, updated, isEval);
60+
setData(loadTrainingData(isEval));
6161
setExpandedIndex(null);
6262
setEditIndex(null);
6363
setEditError(null);
@@ -69,10 +69,13 @@ export function DataListCommand({isEval = false}: Props) {
6969
};
7070

7171
const handleEditInputSubmit = (value: string) => {
72-
if (value.trim()) {
73-
setEditInput(value.trim());
74-
setEditField('output');
72+
if (!value.trim()) {
73+
setEditError('Input cannot be empty');
74+
return;
7575
}
76+
setEditError(null);
77+
setEditInput(value.trim());
78+
setEditField('output');
7679
};
7780

7881
const handleEditOutputSubmit = (value: string) => {

0 commit comments

Comments
 (0)