badgr-cli 1.0.45 → 1.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * badgr train lora — dataset validation, upload dedup, and --resume.
3
+ *
4
+ * A malformed local .jsonl dataset must be rejected before it's uploaded or
5
+ * a GPU is provisioned. A dataset whose content hasn't changed since a prior
6
+ * run must not be re-uploaded (upload cache keyed by content hash). --resume
7
+ * must thread resume_from_checkpoint_url into the job input.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { trainLoraCommand, parseTrainLoraArgs } from '../src/commands/train.js';
11
+
12
+ vi.mock('../src/api.js', async (importOriginal) => {
13
+ const actual = await importOriginal();
14
+ return { ...actual, callApi: vi.fn(), uploadBlob: actual.uploadBlob };
15
+ });
16
+
17
+ vi.mock('../src/store.js', () => ({
18
+ addReceipt: vi.fn(),
19
+ generateReceiptId: vi.fn(() => 'rcpt-ds-001'),
20
+ getCachedUploadId: vi.fn(() => null),
21
+ setCachedUploadId: vi.fn(),
22
+ }));
23
+
24
+ vi.mock('fs', async (importOriginal) => {
25
+ const actual = await importOriginal();
26
+ return {
27
+ ...actual,
28
+ readFileSync: vi.fn(),
29
+ existsSync: vi.fn(() => true),
30
+ };
31
+ });
32
+
33
+ import * as api from '../src/api.js';
34
+ import * as store from '../src/store.js';
35
+ import * as fs from 'fs';
36
+
37
+ const chalk = {
38
+ bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
39
+ };
40
+
41
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
42
+
43
+ beforeEach(() => {
44
+ process.exitCode = undefined;
45
+ vi.spyOn(console, 'log').mockImplementation(() => {});
46
+ vi.spyOn(console, 'error').mockImplementation(() => {});
47
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
48
+ vi.clearAllMocks();
49
+ fs.existsSync.mockReturnValue(true);
50
+ store.getCachedUploadId.mockReturnValue(null);
51
+ // POST /jobs returns the created job; the subsequent GET /jobs/{id} poll
52
+ // (pollJobUntilTerminal, 15s real-world interval) must resolve terminal
53
+ // immediately so these tests don't wait on real timers.
54
+ api.callApi.mockImplementation((path) => {
55
+ if (path === '/jobs') return Promise.resolve({ job_id: 'job-1', status: 'queued' });
56
+ return Promise.resolve({ job_id: 'job-1', status: 'completed', output: {} });
57
+ });
58
+ vi.useFakeTimers();
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.useRealTimers();
63
+ vi.restoreAllMocks();
64
+ process.exitCode = undefined;
65
+ });
66
+
67
+ async function runTrainLora(args) {
68
+ const promise = trainLoraCommand(config, args, chalk);
69
+ await vi.advanceTimersByTimeAsync(20_000);
70
+ return promise;
71
+ }
72
+
73
+ describe('parseTrainLoraArgs --resume', () => {
74
+ it('parses --resume', () => {
75
+ const flags = parseTrainLoraArgs(['--base-model', 'x', '--resume', 'https://cdn/ckpt.tar.gz']);
76
+ expect(flags.resume).toBe('https://cdn/ckpt.tar.gz');
77
+ });
78
+ });
79
+
80
+ describe('trainLoraCommand dataset validation', () => {
81
+ it('rejects a .jsonl dataset with an invalid line before uploading', async () => {
82
+ fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\nnot json\n{"b":2}\n'));
83
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
84
+
85
+ await trainLoraCommand(config, [
86
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
87
+ '--dataset', './bad.jsonl',
88
+ '--max-cost', '10',
89
+ ], chalk);
90
+
91
+ expect(process.exitCode).toBe(1);
92
+ expect(fetchSpy).not.toHaveBeenCalled();
93
+ const logged = console.error.mock.calls.flat().join('\n');
94
+ expect(logged).toContain('not valid JSONL');
95
+ });
96
+
97
+ it('rejects an empty .jsonl dataset', async () => {
98
+ fs.readFileSync.mockReturnValue(Buffer.from(' \n \n'));
99
+
100
+ await trainLoraCommand(config, [
101
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
102
+ '--dataset', './empty.jsonl',
103
+ '--max-cost', '10',
104
+ ], chalk);
105
+
106
+ expect(process.exitCode).toBe(1);
107
+ const logged = console.error.mock.calls.flat().join('\n');
108
+ expect(logged).toContain('empty');
109
+ });
110
+
111
+ it('uploads a valid .jsonl dataset and caches the resulting upload id', async () => {
112
+ fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
113
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
114
+ ok: true, json: async () => ({ upload_id: 'up_new_1' }),
115
+ });
116
+
117
+ await runTrainLora([
118
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
119
+ '--dataset', './good.jsonl',
120
+ '--max-cost', '10',
121
+ ]);
122
+
123
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
124
+ expect(store.setCachedUploadId).toHaveBeenCalledWith(expect.any(String), 'up_new_1', expect.any(Object));
125
+ expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
126
+ body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_new_1' }) }),
127
+ }));
128
+ });
129
+
130
+ it('skips re-upload when the dataset content matches a cached hash', async () => {
131
+ fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
132
+ store.getCachedUploadId.mockReturnValue('up_cached_1');
133
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
134
+
135
+ await runTrainLora([
136
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
137
+ '--dataset', './good.jsonl',
138
+ '--max-cost', '10',
139
+ ]);
140
+
141
+ expect(fetchSpy).not.toHaveBeenCalled();
142
+ expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
143
+ body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_cached_1' }) }),
144
+ }));
145
+ const logged = console.log.mock.calls.flat().join('\n');
146
+ expect(logged).toContain('reusing upload');
147
+ });
148
+ });
149
+
150
+ describe('trainLoraCommand --resume', () => {
151
+ it('threads resume_from_checkpoint_url into the job input', async () => {
152
+ await runTrainLora([
153
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
154
+ '--dataset', 'https://example.com/data.jsonl',
155
+ '--max-cost', '10',
156
+ '--resume', 'https://cdn/ckpt.tar.gz',
157
+ ]);
158
+
159
+ expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
160
+ body: expect.objectContaining({
161
+ input: expect.objectContaining({ resume_from_checkpoint_url: 'https://cdn/ckpt.tar.gz' }),
162
+ }),
163
+ }));
164
+ });
165
+
166
+ it('omits resume_from_checkpoint_url when --resume is not given', async () => {
167
+ await runTrainLora([
168
+ '--base-model', 'mistralai/Mistral-7B-v0.1',
169
+ '--dataset', 'https://example.com/data.jsonl',
170
+ '--max-cost', '10',
171
+ ]);
172
+
173
+ const call = api.callApi.mock.calls.find(c => c[0] === '/jobs');
174
+ expect(call[1].body.input.resume_from_checkpoint_url).toBeUndefined();
175
+ });
176
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import {
6
+ parseWorkloadYaml,
7
+ parseInputEntry,
8
+ validateWorkloadSpec,
9
+ WorkloadSpecError,
10
+ } from '../src/workloadSpec.js';
11
+
12
+ let dir;
13
+
14
+ beforeEach(() => {
15
+ dir = mkdtempSync(join(tmpdir(), 'badgr-batch-test-'));
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ function writeYaml(name, content) {
23
+ const path = join(dir, name);
24
+ writeFileSync(path, content);
25
+ return path;
26
+ }
27
+
28
+ describe('parseInputEntry', () => {
29
+ it('splits local:container on the last colon', () => {
30
+ const { localPath, containerPath } = parseInputEntry('./policy.py:/inputs/policy.py');
31
+ expect(localPath).toBe('./policy.py');
32
+ expect(containerPath).toBe('/inputs/policy.py');
33
+ });
34
+
35
+ it('rejects a non-absolute container path', () => {
36
+ expect(() => parseInputEntry('./policy.py:inputs/policy.py')).toThrow(WorkloadSpecError);
37
+ });
38
+
39
+ it('rejects an entry with no colon', () => {
40
+ expect(() => parseInputEntry('./policy.py')).toThrow(WorkloadSpecError);
41
+ });
42
+ });
43
+
44
+ describe('validateWorkloadSpec', () => {
45
+ it('requires name, image, command, max_cost, max_runtime_minutes', () => {
46
+ const errors = validateWorkloadSpec({});
47
+ expect(errors).toContain('name is required');
48
+ expect(errors).toContain('image is required');
49
+ expect(errors).toContain('command is required and must be a non-empty list');
50
+ expect(errors).toContain('max_cost is required and must be a positive number');
51
+ expect(errors).toContain('max_runtime_minutes is required and must be a positive number');
52
+ });
53
+
54
+ it('passes on a minimal valid spec', () => {
55
+ const errors = validateWorkloadSpec({
56
+ name: 'toy', image: 'busybox', command: ['echo', 'hi'],
57
+ max_cost: 5, max_runtime_minutes: 10,
58
+ });
59
+ expect(errors).toEqual([]);
60
+ });
61
+
62
+ it('flags a malformed inputs entry', () => {
63
+ const errors = validateWorkloadSpec({
64
+ name: 'toy', image: 'busybox', command: ['echo'],
65
+ max_cost: 5, max_runtime_minutes: 10,
66
+ inputs: ['./policy.py'],
67
+ });
68
+ expect(errors.some(e => e.includes('inputs entry'))).toBe(true);
69
+ });
70
+
71
+ it('flags outputs that are not absolute paths', () => {
72
+ const errors = validateWorkloadSpec({
73
+ name: 'toy', image: 'busybox', command: ['echo'],
74
+ max_cost: 5, max_runtime_minutes: 10,
75
+ outputs: ['outputs/metrics.json'],
76
+ });
77
+ expect(errors).toContain('outputs must be a list of absolute container paths');
78
+ });
79
+
80
+ it('requires success_metric.file and key when success_metric is set', () => {
81
+ const errors = validateWorkloadSpec({
82
+ name: 'toy', image: 'busybox', command: ['echo'],
83
+ max_cost: 5, max_runtime_minutes: 10,
84
+ success_metric: {},
85
+ });
86
+ expect(errors.some(e => e.includes('success_metric.file'))).toBe(true);
87
+ expect(errors.some(e => e.includes('success_metric.key'))).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe('parseWorkloadYaml', () => {
92
+ it('parses the minimal toy-robot-eval example from the spec', () => {
93
+ const path = writeYaml('workload.yml', `
94
+ name: toy-robot-eval
95
+ image: badgr/physical-ai-eval-lite:latest
96
+
97
+ command:
98
+ - python
99
+ - run_eval.py
100
+ - --policy
101
+ - /inputs/policy.py
102
+ - --scenarios
103
+ - /inputs/scenarios
104
+
105
+ inputs:
106
+ - ./policy.py:/inputs/policy.py
107
+ - ./scenarios:/inputs/scenarios
108
+
109
+ outputs:
110
+ - /outputs/metrics.json
111
+ - /outputs/videos
112
+ - /outputs/logs
113
+
114
+ max_cost: 20
115
+ max_runtime_minutes: 60
116
+
117
+ success_metric:
118
+ file: /outputs/metrics.json
119
+ key: pass_rate
120
+ higher_is_better: true
121
+ `);
122
+ const spec = parseWorkloadYaml(path);
123
+ expect(spec.name).toBe('toy-robot-eval');
124
+ expect(spec.image).toBe('badgr/physical-ai-eval-lite:latest');
125
+ expect(spec.command).toEqual(['python', 'run_eval.py', '--policy', '/inputs/policy.py', '--scenarios', '/inputs/scenarios']);
126
+ expect(spec.inputs).toHaveLength(2);
127
+ expect(spec.inputs[0].containerPath).toBe('/inputs/policy.py');
128
+ expect(spec.inputs[0].localPath).toBe(join(dir, 'policy.py'));
129
+ expect(spec.outputs).toEqual(['/outputs/metrics.json', '/outputs/videos', '/outputs/logs']);
130
+ expect(spec.maxCost).toBe(20);
131
+ expect(spec.maxRuntimeMinutes).toBe(60);
132
+ expect(spec.successMetric).toEqual({ file: '/outputs/metrics.json', key: 'pass_rate', higherIsBetter: true });
133
+ });
134
+
135
+ it('defaults higherIsBetter to true when omitted', () => {
136
+ const path = writeYaml('workload.yml', `
137
+ name: toy
138
+ image: busybox
139
+ command: [echo, hi]
140
+ max_cost: 1
141
+ max_runtime_minutes: 1
142
+ success_metric:
143
+ file: /outputs/metrics.json
144
+ key: score
145
+ `);
146
+ const spec = parseWorkloadYaml(path);
147
+ expect(spec.successMetric.higherIsBetter).toBe(true);
148
+ });
149
+
150
+ it('defaults inputs/outputs/env to empty', () => {
151
+ const path = writeYaml('workload.yml', `
152
+ name: toy
153
+ image: busybox
154
+ command: [echo, hi]
155
+ max_cost: 1
156
+ max_runtime_minutes: 1
157
+ `);
158
+ const spec = parseWorkloadYaml(path);
159
+ expect(spec.inputs).toEqual([]);
160
+ expect(spec.outputs).toEqual([]);
161
+ expect(spec.env).toEqual({});
162
+ expect(spec.successMetric).toBeNull();
163
+ });
164
+
165
+ it('throws WorkloadSpecError with all problems on an invalid file', () => {
166
+ const path = writeYaml('workload.yml', `
167
+ image: busybox
168
+ `);
169
+ expect(() => parseWorkloadYaml(path)).toThrow(WorkloadSpecError);
170
+ });
171
+
172
+ it('throws WorkloadSpecError on malformed YAML', () => {
173
+ const path = writeYaml('workload.yml', '{ not: valid: yaml: [');
174
+ expect(() => parseWorkloadYaml(path)).toThrow(WorkloadSpecError);
175
+ });
176
+
177
+ it('throws WorkloadSpecError when the file does not exist', () => {
178
+ expect(() => parseWorkloadYaml(join(dir, 'nope.yml'))).toThrow(WorkloadSpecError);
179
+ });
180
+ });