@mlx-node/trl 0.0.12 → 0.0.15

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,2151 @@
1
+ /**
2
+ * GRPO Training Engine - Rust-Native Training
3
+ *
4
+ * This module provides a Rust-native GRPO training engine that minimizes
5
+ * FFI overhead by keeping the training loop entirely in Rust.
6
+ *
7
+ * ## Key Features
8
+ * - Training loop runs in Rust (eliminates FFI overhead)
9
+ * - Built-in reward functions (tool use, XML format, length, JSON schema)
10
+ * - Custom JS rewards via callback pattern
11
+ * - Gradient accumulation and memory management in Rust
12
+ * - High-level train() method for full training runs
13
+ * - Low-level trainStep() for custom training loops
14
+ *
15
+ * ## High-Level Usage (train with dataset)
16
+ * ```typescript
17
+ * const trainer = await GRPOTrainer.create({
18
+ * modelPath: './model',
19
+ * modelName: 'qwen3-0.6b',
20
+ * rewardFunction: (prompts, completions) => [...scores],
21
+ * });
22
+ * await trainer.train(dataset);
23
+ * ```
24
+ *
25
+ * ## Low-Level Usage (step-by-step)
26
+ * ```typescript
27
+ * const model = await Qwen3Model.load(modelPath);
28
+ * const trainer = new GRPOTrainer(model, config);
29
+ *
30
+ * trainer.registerBuiltinReward({
31
+ * rewardType: 'ToolUse',
32
+ * allowedTools: ['search', 'calculate'],
33
+ * });
34
+ *
35
+ * for (const batch of dataset) {
36
+ * const completions = await trainer.generateBatch(batch.prompts);
37
+ * const rewards = await myRewardFunction(batch.prompts, completions);
38
+ * const metrics = await trainer.trainStep(batch.prompts, rewards);
39
+ * }
40
+ * ```
41
+ */
42
+
43
+ import { createHash } from 'node:crypto';
44
+ import {
45
+ existsSync,
46
+ mkdirSync,
47
+ writeFileSync,
48
+ readFileSync,
49
+ readdirSync,
50
+ copyFileSync,
51
+ cpSync,
52
+ rmSync,
53
+ statSync,
54
+ } from 'node:fs';
55
+ import { dirname, join } from 'node:path';
56
+ import * as readline from 'node:readline';
57
+
58
+ import {
59
+ GrpoTrainingEngine,
60
+ NativeRewardRegistry,
61
+ Qwen3Model,
62
+ Qwen35Model,
63
+ Qwen35MoeModel,
64
+ OutputStore,
65
+ buildRewardOutputs,
66
+ type GrpoEngineConfig,
67
+ type EngineEpochMetrics,
68
+ type BuiltinRewardConfig,
69
+ type GenerateBatchResult as NativeGenerateBatchResult,
70
+ type TrainStepResultWithOutputs,
71
+ type RewardOutput,
72
+ type ToolDefinition,
73
+ } from '@mlx-node/core';
74
+ import { loadModel, type TrainableModel } from '@mlx-node/lm';
75
+
76
+ import type { ChatMessage, DatasetExample, RewardFunction } from '../types.js';
77
+ import { createTrainingLogger, type TrainingLogger } from './training-logger.js';
78
+
79
+ // Re-export native types
80
+ export { GrpoTrainingEngine, NativeRewardRegistry } from '@mlx-node/core';
81
+ export type { GrpoEngineConfig, EngineStepMetrics, EngineEpochMetrics, BuiltinRewardConfig } from '@mlx-node/core';
82
+
83
+ /**
84
+ * Configuration for GRPOTrainer
85
+ */
86
+ export interface GRPOTrainerConfig<T = unknown> {
87
+ // Model loading (for create() factory)
88
+ modelPath?: string;
89
+ modelName?: string;
90
+
91
+ // Training hyperparameters
92
+ learningRate?: number;
93
+ gradientAccumulationSteps?: number;
94
+ gradientClipNorm?: number;
95
+ weightDecay?: number;
96
+
97
+ // Training loop settings
98
+ numEpochs?: number;
99
+ batchSize?: number;
100
+
101
+ // GRPO hyperparameters
102
+ groupSize?: number;
103
+ clipEpsilon?: number;
104
+ klCoef?: number;
105
+ lossType?: 'grpo' | 'dapo' | 'dr_grpo' | 'bnpo';
106
+ advantageNormalization?: boolean;
107
+
108
+ // Generation parameters
109
+ /** Maximum completion length for both generation and training (default: 256).
110
+ * Matches Python TRL's max_completion_length config. */
111
+ maxCompletionLength?: number;
112
+ temperature?: number;
113
+ topP?: number;
114
+ topK?: number;
115
+ repetitionPenalty?: number;
116
+ /** Presence penalty (0.0 = disabled). Subtracts a flat penalty from logits of any token in context. */
117
+ presencePenalty?: number;
118
+ /** Frequency penalty (0.0 = disabled). Subtracts penalty * count for each token in context. */
119
+ frequencyPenalty?: number;
120
+
121
+ // Tool calling (for tool-use training)
122
+ /**
123
+ * Tool definitions for function calling.
124
+ * When provided, tools are included in the chat template so the model
125
+ * can generate tool calls. Essential for tool-use training.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import { createToolDefinition } from '@mlx-node/lm';
130
+ *
131
+ * const config: GRPOTrainerConfig = {
132
+ * tools: [
133
+ * createToolDefinition('lsp', 'Query API docs', { method: { type: 'string' } }, ['method']),
134
+ * createToolDefinition('run_js', 'Execute code', { code: { type: 'string' } }, ['code']),
135
+ * ],
136
+ * };
137
+ * ```
138
+ */
139
+ tools?: ToolDefinition[];
140
+
141
+ /** Enable thinking mode for Qwen3 models (default: true).
142
+ * When false, adds empty <think></think> tags to disable model thinking.
143
+ * This is useful for tool-use training where you want direct outputs. */
144
+ enableThinking?: boolean;
145
+
146
+ // Reward configuration
147
+ rewardType?: 'function' | 'builtin' | 'model';
148
+ rewardFunction?: RewardFunction<T>;
149
+ rewardModelPath?: string;
150
+
151
+ // Optimization
152
+ gradientClipValue?: number;
153
+
154
+ // Logging and checkpointing
155
+ logInterval?: number;
156
+ saveInterval?: number;
157
+ evalInterval?: number;
158
+ outputDir?: string;
159
+ logConsole?: boolean;
160
+ logJsonl?: boolean;
161
+ runName?: string;
162
+ /** Maximum number of checkpoints to keep (default: 3). Set to 0 for unlimited. */
163
+ maxCheckpoints?: number;
164
+
165
+ // Device
166
+ device?: string;
167
+
168
+ // Checkpoint resumption
169
+ /** Resume training from a checkpoint directory, or 'latest' to auto-find */
170
+ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
171
+ resumeFromCheckpoint?: 'latest' | string;
172
+
173
+ // TUI mode
174
+ /** Enable TUI mode - outputs structured JSONL to stdout and listens for commands on stdin */
175
+ tuiMode?: boolean;
176
+
177
+ // Reward callback timeout
178
+ /**
179
+ * Timeout for the reward function callback in milliseconds.
180
+ *
181
+ * If your reward function calls external APIs or performs expensive
182
+ * computations, you may need to increase this value.
183
+ *
184
+ * Set to 0 to disable timeout (not recommended for production).
185
+ *
186
+ * @default 60000 (60 seconds)
187
+ */
188
+ rewardTimeout?: number;
189
+
190
+ // Memory optimization
191
+ /**
192
+ * Batch chunk size for LM head computation (memory optimization).
193
+ * When set, the LM head (hidden_states -> logits) is computed in chunks
194
+ * of this size to reduce peak memory usage.
195
+ * Default: undefined (no chunking, full batch at once)
196
+ * Recommended: 2 for batch_size >= 4 with large vocabularies (e.g., Qwen3 with 151936 vocab)
197
+ * This reduces peak memory from ~1.2GB to ~300MB for Qwen3.
198
+ */
199
+ lmHeadChunkSize?: number;
200
+
201
+ /**
202
+ * Batch chunk size for transformer forward pass (memory optimization).
203
+ * When set, the transformer layers process the batch in chunks of this size,
204
+ * reducing peak memory from O(batch × heads × seq²) for attention.
205
+ * Default: undefined (no chunking, full batch at once)
206
+ * Recommended: 4 for batch_size >= 4 with groupSize >= 4
207
+ * Memory savings: ~70-80% for batch=4, groupSize=4 (16 sequences → 4 at a time)
208
+ */
209
+ forwardChunkSize?: number;
210
+
211
+ /**
212
+ * Enable true parallel batch generation (default: false).
213
+ * When true, all N*G sequences are processed in parallel using batched FFI
214
+ * with per-sequence RoPE offsets. This provides 2-4x speedup for GRPO training.
215
+ * When false, uses sequential generation (process one prompt at a time,
216
+ * then expand KV cache for G completions).
217
+ */
218
+ useParallelBatchGeneration?: boolean;
219
+
220
+ /**
221
+ * Enable gradient checkpointing (default: true).
222
+ * Discards intermediate activations during forward pass and recomputes during backward,
223
+ * reducing peak memory from O(num_layers) to O(1) for intermediate states.
224
+ * For Qwen3.5 0.8B, this reduces autograd peak from ~105GB to ~11GB.
225
+ * Trade-off: ~30% more compute (one extra forward pass per layer during backward).
226
+ */
227
+ gradientCheckpointing?: boolean;
228
+
229
+ /** Optimizer type: 'sgd' or 'adamw' (default: 'adamw') */
230
+ optimizerType?: 'sgd' | 'adamw';
231
+ /** AdamW beta1 (default: 0.9) */
232
+ adamwBeta1?: number;
233
+ /** AdamW beta2 (default: 0.999) */
234
+ adamwBeta2?: number;
235
+ /** AdamW epsilon (default: 1e-8) */
236
+ adamwEps?: number;
237
+
238
+ /**
239
+ * Chunk size for vocabulary dimension in cross-entropy computation.
240
+ * When computing logsumexp over large vocabularies (e.g., Qwen3's 151,936 tokens),
241
+ * the computation is split into chunks of this size to reduce peak memory usage.
242
+ *
243
+ * Default: 65536 (2^16)
244
+ *
245
+ * Memory impact for Qwen3 (vocab=151936):
246
+ * - Standard: Full [B, T, 151936] intermediate tensor
247
+ * - Chunked (65536): 3 chunks, ~2.3x lower peak memory
248
+ *
249
+ * Set to a larger value (e.g., 262144) to reduce chunking overhead,
250
+ * or smaller value (e.g., 32768) for tighter memory constraints.
251
+ */
252
+ vocabChunkSize?: number;
253
+
254
+ // Output recording
255
+ /** Output recording configuration (records all generations for debugging/research) */
256
+ outputStore?: {
257
+ /** Enable output recording (default: false) */
258
+ enabled: boolean;
259
+ /** Local database path (default: "{outputDir}/outputs.db") */
260
+ localPath?: string;
261
+ /** Remote Turso URL for cloud sync (optional) */
262
+ remoteUrl?: string;
263
+ /** Turso auth token (required if remoteUrl is set) */
264
+ authToken?: string;
265
+ /** Sync interval in seconds (default: 60, only for embedded replica mode) */
266
+ syncInterval?: number;
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Dataset metadata for resume validation
272
+ *
273
+ * Stored in checkpoints to validate that the same dataset is used on resume.
274
+ * Prevents issues where batch indices don't align due to dataset changes.
275
+ */
276
+ export interface DatasetMetadata {
277
+ /** Total number of examples in the dataset */
278
+ size: number;
279
+ /** Hash of first N example prompts for identity check */
280
+ contentHash: string;
281
+ /** Shuffle seed if deterministic shuffling was used */
282
+ shuffleSeed?: number;
283
+ /** Indices of batches already processed in current epoch */
284
+ processedBatchIndices?: number[];
285
+ }
286
+
287
+ /**
288
+ * Training state saved with checkpoints for resumption
289
+ */
290
+ export interface TrainingState {
291
+ step: number;
292
+ epoch: number;
293
+ timestamp: string;
294
+ /** Dataset information for resume validation */
295
+ dataset?: DatasetMetadata;
296
+ /** Whether optimizer state was saved alongside this checkpoint */
297
+ hasOptimizerState?: boolean;
298
+ }
299
+
300
+ /**
301
+ * Result from generateBatch with detailed information
302
+ */
303
+ export interface GenerateBatchResult {
304
+ /** Generated completion texts */
305
+ completionTexts: string[];
306
+ /** Native generation result for passing to trainStepWithGenerations */
307
+ nativeResult: NativeGenerateBatchResult;
308
+ /** Completion token counts (derived from nativeResult) */
309
+ tokenCounts: number[];
310
+ /** Finish reasons for each completion ("stop", "length", or "repetition") */
311
+ finishReasons: string[];
312
+ }
313
+
314
+ /**
315
+ * Default configuration
316
+ */
317
+ export const DEFAULT_GRPO_CONFIG: GRPOTrainerConfig = {
318
+ learningRate: 1e-6,
319
+ gradientAccumulationSteps: 1,
320
+ gradientClipNorm: 1.0,
321
+ weightDecay: 0.01,
322
+ numEpochs: 1,
323
+ batchSize: 1,
324
+ groupSize: 4,
325
+ clipEpsilon: 0.2,
326
+ klCoef: 0.0,
327
+ lossType: 'grpo',
328
+ advantageNormalization: true,
329
+ maxCompletionLength: 256,
330
+ temperature: 0.8,
331
+ topP: 0.95,
332
+ repetitionPenalty: 1.1,
333
+ logInterval: 1,
334
+ saveInterval: 100,
335
+ evalInterval: 100,
336
+ logConsole: true,
337
+ logJsonl: true,
338
+ maxCheckpoints: 3,
339
+ lmHeadChunkSize: 2,
340
+ };
341
+
342
+ /**
343
+ * Training step metrics (compatible with both old and new APIs)
344
+ */
345
+ export interface TrainStepMetrics {
346
+ /** Current step number */
347
+ step: number;
348
+ /** GRPO loss value */
349
+ loss: number;
350
+ /** Mean reward across completions */
351
+ meanReward: number;
352
+ /** Standard deviation of rewards */
353
+ stdReward: number;
354
+ /** Mean advantage value */
355
+ meanAdvantage: number;
356
+ /** Std of advantages - indicates reward variance within groups */
357
+ stdAdvantage: number;
358
+ /** Total tokens generated this step */
359
+ totalTokens: number;
360
+ /** Whether gradients were applied */
361
+ gradientsApplied?: boolean;
362
+ /** Time for generation (ms) */
363
+ generationTimeMs?: number;
364
+ /** Time for training (ms) */
365
+ trainingTimeMs?: number;
366
+ /** Current epoch (for high-level API) */
367
+ epoch?: number;
368
+ }
369
+
370
+ /**
371
+ * Legacy type alias for backward compatibility
372
+ */
373
+ export type TrainingMetrics = TrainStepMetrics;
374
+
375
+ /**
376
+ * Compute a hash of dataset content for identity checking on resume.
377
+ *
378
+ * Hashes the first N examples to create a fingerprint that can detect
379
+ * if the dataset has been modified between training runs.
380
+ *
381
+ * @param dataset - Array of dataset examples
382
+ * @param sampleSize - Number of examples to hash (default: 10)
383
+ * @returns 16-character hex hash string
384
+ */
385
+ export function computeDatasetHash(dataset: DatasetExample[], sampleSize = 10): string {
386
+ const samples = dataset.slice(0, sampleSize);
387
+ // Create a content string from prompts (stringified for consistency)
388
+ const content = samples.map((ex) => JSON.stringify(ex.prompt)).join('|||');
389
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
390
+ }
391
+
392
+ // Note: RewardOutput is now built using the Rust buildRewardOutputs function
393
+ // which handles tool call parsing and thinking extraction natively.
394
+
395
+ /**
396
+ * Error thrown when a reward function times out.
397
+ */
398
+ export class RewardTimeoutError extends Error {
399
+ constructor(
400
+ message: string,
401
+ public readonly timeoutMs: number,
402
+ ) {
403
+ super(message);
404
+ this.name = 'RewardTimeoutError';
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Wraps a promise with a timeout.
410
+ *
411
+ * @param promise - The promise to wrap
412
+ * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
413
+ * @param errorMessage - Error message if timeout is reached
414
+ * @returns The promise result or throws RewardTimeoutError
415
+ */
416
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorMessage: string): Promise<T> {
417
+ // Timeout of 0 means no timeout
418
+ if (timeoutMs <= 0) {
419
+ return promise;
420
+ }
421
+
422
+ return new Promise((resolve, reject) => {
423
+ const timer = setTimeout(() => {
424
+ reject(new RewardTimeoutError(errorMessage, timeoutMs));
425
+ }, timeoutMs);
426
+
427
+ promise
428
+ .then((result) => {
429
+ clearTimeout(timer);
430
+ resolve(result);
431
+ })
432
+ .catch((error) => {
433
+ clearTimeout(timer);
434
+ reject(error);
435
+ });
436
+ });
437
+ }
438
+
439
+ /**
440
+ * GRPO Trainer - Rust-Native Training Engine
441
+ *
442
+ * Provides a TypeScript-friendly interface to the Rust training engine.
443
+ * Supports both high-level training (train()) and low-level step-by-step (trainStep()).
444
+ */
445
+ export class GRPOTrainer<T = unknown> {
446
+ private engine: GrpoTrainingEngine;
447
+ private model: TrainableModel;
448
+ private config: GRPOTrainerConfig<T>;
449
+ private rewardFn?: RewardFunction<T>;
450
+ private currentEpoch: number = 0;
451
+ private currentStep: number = 0;
452
+ /** Original model path (for tokenizer files when saving checkpoints) */
453
+ private originalModelPath?: string;
454
+
455
+ // TUI state
456
+ private paused: boolean = false;
457
+ private stopRequested: boolean = false;
458
+ private stdinInterface?: import('readline').Interface;
459
+ private logger: TrainingLogger;
460
+ private sampleDisplayMode: 'all' | 'best_worst' | 'random' = 'all';
461
+
462
+ // Output recording
463
+ private outputStore?: OutputStore;
464
+ private outputStoreInitPromise?: Promise<void>;
465
+ private outputStoreRunId?: string;
466
+ private outputStorePath?: string;
467
+
468
+ // Crash recovery
469
+ private lastCheckpointStep: number = 0;
470
+ private signalHandlersInstalled: boolean = false;
471
+
472
+ // Last known good checkpoint tracking (for NaN gradient recovery)
473
+ private lastGoodCheckpointPath: string | null = null;
474
+ private lastGoodCheckpointStep: number = 0;
475
+
476
+ // Dataset tracking for resume validation
477
+ private datasetMetadata?: DatasetMetadata;
478
+ private processedBatchIndices: Set<number> = new Set();
479
+
480
+ /**
481
+ * Create a new GRPO trainer from a model
482
+ *
483
+ * @param model - Pre-loaded Qwen3 model
484
+ * @param config - Training configuration
485
+ */
486
+ constructor(model: TrainableModel, config: Partial<GRPOTrainerConfig<T>> = {}, logger?: TrainingLogger) {
487
+ // Auto-detect TUI mode from environment variable (set by mlx-train TUI)
488
+ const tuiModeFromEnv = process.env.MLX_TUI_MODE === '1';
489
+ if (tuiModeFromEnv && config.tuiMode === undefined) {
490
+ config.tuiMode = true;
491
+ }
492
+
493
+ // Auto-enable database persistence in TUI mode (enables Database tab)
494
+ if (tuiModeFromEnv && config.outputStore === undefined) {
495
+ config.outputStore = { enabled: true };
496
+ }
497
+
498
+ this.config = { ...DEFAULT_GRPO_CONFIG, ...config };
499
+ this.model = model;
500
+
501
+ // Create or use provided logger (TUI mode auto-detected from MLX_TUI_MODE env var)
502
+ this.logger =
503
+ logger ??
504
+ createTrainingLogger({
505
+ logConsole: this.config.logConsole,
506
+ logJsonl: this.config.logJsonl,
507
+ outputDir: this.config.outputDir,
508
+ runName: this.config.runName,
509
+ logInterval: this.config.logInterval ?? 1,
510
+ });
511
+
512
+ // Set reward function if provided
513
+ if (this.config.rewardFunction) {
514
+ this.rewardFn = this.config.rewardFunction;
515
+ }
516
+
517
+ // Convert to native config
518
+ const engineConfig: GrpoEngineConfig = {
519
+ learningRate: this.config.learningRate,
520
+ gradientAccumulationSteps: this.config.gradientAccumulationSteps,
521
+ gradientClipNorm: this.config.gradientClipNorm,
522
+ groupSize: this.config.groupSize,
523
+ clipEpsilon: this.config.clipEpsilon,
524
+ klCoef: this.config.klCoef,
525
+ lossType: this.config.lossType,
526
+ maxCompletionLength: this.config.maxCompletionLength,
527
+ temperature: this.config.temperature,
528
+ topP: this.config.topP,
529
+ topK: this.config.topK,
530
+ repetitionPenalty: this.config.repetitionPenalty,
531
+ presencePenalty: this.config.presencePenalty,
532
+ frequencyPenalty: this.config.frequencyPenalty,
533
+ // Tool calling support
534
+ tools: this.config.tools,
535
+ enableThinking: this.config.enableThinking,
536
+ // Memory optimization
537
+ lmHeadChunkSize: this.config.lmHeadChunkSize,
538
+ forwardChunkSize: this.config.forwardChunkSize,
539
+ vocabChunkSize: this.config.vocabChunkSize,
540
+ // Parallel batch generation
541
+ useParallelBatchGeneration: this.config.useParallelBatchGeneration,
542
+ // Gradient checkpointing
543
+ gradientCheckpointing: this.config.gradientCheckpointing,
544
+ // Optimizer
545
+ optimizerType: this.config.optimizerType,
546
+ adamwBeta1: this.config.adamwBeta1,
547
+ adamwBeta2: this.config.adamwBeta2,
548
+ adamwEps: this.config.adamwEps,
549
+ weightDecay: this.config.weightDecay,
550
+ };
551
+
552
+ if (model instanceof Qwen35Model) {
553
+ this.engine = GrpoTrainingEngine.fromQwen35(model, engineConfig);
554
+ } else if (model instanceof Qwen35MoeModel) {
555
+ this.engine = GrpoTrainingEngine.fromQwen35Moe(model, engineConfig);
556
+ } else if (model instanceof Qwen3Model) {
557
+ this.engine = new GrpoTrainingEngine(model, engineConfig);
558
+ } else {
559
+ throw new Error(`Unsupported model type: ${(model as object).constructor?.name ?? typeof model}`);
560
+ }
561
+
562
+ // Setup stdin handler if TUI mode
563
+ if (this.config.tuiMode) {
564
+ this.setupStdinHandler();
565
+ }
566
+
567
+ // Always setup signal handlers for crash recovery
568
+ this.setupSignalHandlers();
569
+ }
570
+
571
+ /**
572
+ * Setup stdin handler for TUI control commands
573
+ */
574
+ private setupStdinHandler(): void {
575
+ if (!this.config.tuiMode) return;
576
+
577
+ this.stdinInterface = readline.createInterface({
578
+ input: process.stdin,
579
+ output: process.stdout,
580
+ terminal: false,
581
+ });
582
+
583
+ this.stdinInterface.on('line', (line: string) => {
584
+ const cmd = line.trim();
585
+ this.handleStdinCommand(cmd);
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Setup OS signal handlers for graceful shutdown on crash/interrupt
591
+ *
592
+ * Catches SIGTERM, SIGINT, and uncaught exceptions to:
593
+ * - Save emergency checkpoint (if > 10 steps since last)
594
+ * - Finalize OutputStore with 'crashed' status
595
+ * - Exit cleanly
596
+ */
597
+ private setupSignalHandlers(): void {
598
+ if (this.signalHandlersInstalled) return;
599
+ this.signalHandlersInstalled = true;
600
+
601
+ const gracefulShutdown = async (signal: string) => {
602
+ this.logger.warn(`Received ${signal}, initiating graceful shutdown...`);
603
+ this.stopRequested = true;
604
+
605
+ try {
606
+ // Skip checkpoint if recent one exists (within 10 steps)
607
+ const stepsSinceCheckpoint = this.currentStep - this.lastCheckpointStep;
608
+ if (this.config.outputDir && stepsSinceCheckpoint > 10) {
609
+ this.logger.info(`Saving emergency checkpoint (${stepsSinceCheckpoint} steps since last)...`);
610
+ await this.saveCheckpoint(`emergency-${this.currentStep}`);
611
+ } else if (stepsSinceCheckpoint <= 10) {
612
+ this.logger.info(`Skipping checkpoint (only ${stepsSinceCheckpoint} steps since last)`);
613
+ }
614
+
615
+ // Finalize OutputStore with crashed status
616
+ if (this.outputStore) {
617
+ await this.outputStore.endRun('crashed');
618
+ await this.outputStore.flush();
619
+ this.logger.info('OutputStore finalized with crashed status');
620
+ }
621
+ } catch (e) {
622
+ console.error('Emergency save failed:', e);
623
+ }
624
+
625
+ // Cleanup stdin interface
626
+ if (this.stdinInterface) {
627
+ this.stdinInterface.close();
628
+ }
629
+
630
+ process.exit(0);
631
+ };
632
+
633
+ process.on('SIGTERM', () => {
634
+ gracefulShutdown('SIGTERM').catch(console.error);
635
+ });
636
+
637
+ process.on('SIGINT', () => {
638
+ gracefulShutdown('SIGINT').catch(console.error);
639
+ });
640
+
641
+ process.on('uncaughtException', (err) => {
642
+ console.error('Uncaught exception:', err);
643
+ gracefulShutdown('uncaughtException').catch(console.error);
644
+ });
645
+
646
+ process.on('unhandledRejection', (reason) => {
647
+ console.error('Unhandled rejection:', reason);
648
+ gracefulShutdown('unhandledRejection').catch(console.error);
649
+ });
650
+ }
651
+
652
+ /**
653
+ * Initialize the output store for recording training outputs
654
+ */
655
+ private async initOutputStore(stepsPerEpoch?: number): Promise<void> {
656
+ // Guard against re-initialization (e.g., train() called after trainStepAuto())
657
+ if (this.outputStore) return;
658
+
659
+ const cfg = this.config.outputStore;
660
+ if (!cfg?.enabled) {
661
+ // Even without outputStore, send UI resume state if resuming from checkpoint
662
+ if (this.config.resumeFromCheckpoint && this.currentStep > 0 && stepsPerEpoch) {
663
+ this.sendResumeStateUiOnly(stepsPerEpoch);
664
+ }
665
+ return;
666
+ }
667
+
668
+ const localPath = cfg.localPath ?? join(this.config.outputDir ?? '.', 'outputs.db');
669
+ this.outputStorePath = localPath;
670
+
671
+ // Ensure parent directory exists (for lazy init via trainStepAuto)
672
+ const parentDir = dirname(localPath);
673
+ if (parentDir !== '.' && !existsSync(parentDir)) {
674
+ mkdirSync(parentDir, { recursive: true });
675
+ }
676
+ this.outputStore = await OutputStore.local(localPath);
677
+
678
+ // If resuming from checkpoint AND we have a run name AND checkpoint was actually loaded,
679
+ // try to resume the existing database run. currentStep > 0 means checkpoint was loaded.
680
+ if (this.config.resumeFromCheckpoint && this.config.runName && this.currentStep > 0) {
681
+ const existingRun = await this.outputStore.findRunByName(this.config.runName);
682
+ if (existingRun) {
683
+ this.logger.info(`Resuming database run: ${this.config.runName} (${existingRun.id})`);
684
+ await this.outputStore.resumeRun(existingRun.id);
685
+ this.outputStoreRunId = existingRun.id;
686
+
687
+ // Clean up any database records that are ahead of the checkpoint step
688
+ // This prevents UNIQUE constraint errors when re-recording steps
689
+ // Uses cascade delete to also clean up orphaned generations, tool_calls, and logs
690
+ if (this.currentStep > 0) {
691
+ const cleanupStats = await this.outputStore.deleteAllAfterStep(existingRun.id, this.currentStep);
692
+ if (cleanupStats.stepsDeleted > 0) {
693
+ this.logger.info(
694
+ `Cleaned up stale records after step ${this.currentStep}: ` +
695
+ `${cleanupStats.stepsDeleted} steps, ${cleanupStats.generationsDeleted} generations, ` +
696
+ `${cleanupStats.logsDeleted} logs`,
697
+ );
698
+ }
699
+ }
700
+
701
+ this.logger.databasePath(localPath, this.outputStoreRunId, this.config.runName ?? undefined);
702
+
703
+ // Send resume state to TUI for sparkline and aggregate restoration
704
+ await this.sendResumeState(existingRun.id, stepsPerEpoch);
705
+ return;
706
+ } else {
707
+ // Run name specified but not found - warn and create new
708
+ this.logger.warn(`No existing run found with name: ${this.config.runName}. Starting new run.`);
709
+ }
710
+ }
711
+
712
+ // Start a new run with sanitized config (no auth token)
713
+ const modelName = this.config.modelName ?? 'qwen3';
714
+ const modelPath = this.originalModelPath ?? this.config.modelPath ?? undefined;
715
+ const sanitizedConfig = {
716
+ ...this.config,
717
+ outputStore: this.config.outputStore ? { ...this.config.outputStore, authToken: undefined } : undefined,
718
+ };
719
+
720
+ // Use startRunWithName if a run name is provided, otherwise use startRun
721
+ if (this.config.runName) {
722
+ this.outputStoreRunId = await this.outputStore.startRunWithName(
723
+ this.config.runName,
724
+ modelName,
725
+ modelPath,
726
+ JSON.stringify(sanitizedConfig),
727
+ );
728
+ } else {
729
+ this.outputStoreRunId = await this.outputStore.startRun(modelName, modelPath, JSON.stringify(sanitizedConfig));
730
+ }
731
+
732
+ // Emit database path to TUI for the Database tab
733
+ this.logger.databasePath(localPath, this.outputStoreRunId, this.config.runName ?? undefined);
734
+
735
+ // If resuming from checkpoint but didn't find existing DB run, still send UI state
736
+ // This ensures TUI displays correct batch progress even without historical data
737
+ if (this.config.resumeFromCheckpoint && this.currentStep > 0 && stepsPerEpoch) {
738
+ this.sendResumeStateUiOnly(stepsPerEpoch);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Send minimal resume state to TUI for UI display only (no historical data)
744
+ *
745
+ * Used when resuming from checkpoint without a matching database run.
746
+ * Ensures TUI shows correct epoch/batch progress.
747
+ */
748
+ private sendResumeStateUiOnly(stepsPerEpoch: number): void {
749
+ if (!this.logger.isTuiMode) return;
750
+
751
+ const totalEpochs = this.config.numEpochs ?? 1;
752
+ const stepInEpoch = this.currentStep > 0 ? ((this.currentStep - 1) % stepsPerEpoch) + 1 : 0;
753
+
754
+ this.logger.resumeState({
755
+ step: this.currentStep,
756
+ epoch: this.currentEpoch + 1, // 1-indexed
757
+ totalEpochs,
758
+ stepInEpoch,
759
+ totalStepsInEpoch: stepsPerEpoch,
760
+ metricsHistory: [], // No historical data
761
+ aggregates: {
762
+ bestReward: 0,
763
+ avgReward: 0,
764
+ rewardCount: 0,
765
+ bestLoss: Infinity,
766
+ avgLoss: 0,
767
+ lossCount: 0,
768
+ totalTokens: 0,
769
+ avgGenerationTimeMs: 0,
770
+ avgTrainingTimeMs: 0,
771
+ },
772
+ });
773
+
774
+ this.logger.info(`Sent UI resume state to TUI (step ${this.currentStep}, no historical data)`);
775
+ }
776
+
777
+ /**
778
+ * Send resume state to TUI for restoring sparklines and aggregates
779
+ *
780
+ * Queries the database for historical metrics and aggregates, then sends
781
+ * to TUI via the resumeState message.
782
+ *
783
+ * @param runId - Database run ID
784
+ * @param actualStepsPerEpoch - Actual steps per epoch from dataset (if known)
785
+ */
786
+ private async sendResumeState(runId: string, actualStepsPerEpoch?: number): Promise<void> {
787
+ if (!this.outputStore || !this.logger.isTuiMode) return;
788
+
789
+ try {
790
+ // Query historical metrics (last 60 for sparklines)
791
+ const metricsHistory = await this.outputStore.getRecentStepMetrics(runId, 60);
792
+
793
+ // Query aggregate statistics
794
+ const aggregates = await this.outputStore.getRunAggregates(runId);
795
+
796
+ // Use actual steps per epoch if provided, otherwise use a reasonable default
797
+ const totalEpochs = this.config.numEpochs ?? 1;
798
+ const stepsPerEpoch = actualStepsPerEpoch ?? 50;
799
+
800
+ // Calculate step within current epoch
801
+ const stepInEpoch = this.currentStep > 0 ? ((this.currentStep - 1) % stepsPerEpoch) + 1 : 0;
802
+
803
+ // Send resume state to TUI
804
+ // Note: epoch is 1-indexed to match epochStart() convention
805
+ this.logger.resumeState({
806
+ step: this.currentStep,
807
+ epoch: this.currentEpoch + 1,
808
+ totalEpochs,
809
+ stepInEpoch,
810
+ totalStepsInEpoch: stepsPerEpoch,
811
+ metricsHistory: metricsHistory.map((m) => ({
812
+ step: Number(m.step),
813
+ loss: m.loss,
814
+ meanReward: m.meanReward,
815
+ stdAdvantage: m.stdAdvantage,
816
+ perplexity: m.perplexity ?? undefined,
817
+ tokenAccuracy: m.tokenAccuracy ?? undefined,
818
+ generationTimeMs: m.generationTimeMs ?? undefined,
819
+ trainingTimeMs: m.trainingTimeMs ?? undefined,
820
+ })),
821
+ aggregates: {
822
+ bestReward: aggregates.bestReward,
823
+ avgReward: aggregates.avgReward,
824
+ rewardCount: Number(aggregates.rewardCount),
825
+ bestLoss: aggregates.bestLoss,
826
+ avgLoss: aggregates.avgLoss,
827
+ lossCount: Number(aggregates.lossCount),
828
+ totalTokens: Number(aggregates.totalTokens),
829
+ avgGenerationTimeMs: aggregates.avgGenerationTimeMs,
830
+ avgTrainingTimeMs: aggregates.avgTrainingTimeMs,
831
+ },
832
+ });
833
+
834
+ this.logger.info(`Sent ${metricsHistory.length} historical metrics to TUI`);
835
+ } catch (err) {
836
+ this.logger.warn(`Failed to send resume state to TUI: ${err as Error}`);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Ensure output store is initialized (lazy initialization for low-level API users)
842
+ * Uses promise mutex to prevent race conditions from concurrent calls.
843
+ *
844
+ * Call this method from custom training loops before starting training
845
+ * to enable database recording and TUI database tab.
846
+ */
847
+ async ensureOutputStoreInitialized(): Promise<void> {
848
+ if (this.outputStore) return; // Already initialized
849
+ if (!this.config.outputStore?.enabled) return; // Not enabled
850
+
851
+ // Use promise mutex to prevent concurrent initialization
852
+ if (this.outputStoreInitPromise) {
853
+ await this.outputStoreInitPromise;
854
+ return;
855
+ }
856
+
857
+ this.outputStoreInitPromise = this.initOutputStore();
858
+ try {
859
+ await this.outputStoreInitPromise;
860
+ } catch (err) {
861
+ // Clear promise on failure to allow retry
862
+ this.outputStoreInitPromise = undefined;
863
+ throw err;
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Get the output store (for querying recorded data)
869
+ */
870
+ getOutputStore(): OutputStore | undefined {
871
+ return this.outputStore;
872
+ }
873
+
874
+ /**
875
+ * Handle a command received from stdin
876
+ */
877
+ private handleStdinCommand(cmd: string): void {
878
+ switch (cmd) {
879
+ case 'PAUSE':
880
+ this.paused = true;
881
+ this.logger.paused(this.currentStep);
882
+ break;
883
+ case 'RESUME':
884
+ this.paused = false;
885
+ this.logger.resumed(this.currentStep);
886
+ break;
887
+ case 'SAVE_CHECKPOINT':
888
+ // Will be handled in the training loop
889
+ this.saveCheckpoint().catch(() => {});
890
+ break;
891
+ case 'STOP':
892
+ this.stopRequested = true;
893
+ break;
894
+ default:
895
+ // Handle SET commands (e.g., SET sample_display=best_worst)
896
+ if (cmd.startsWith('SET ')) {
897
+ const keyValue = cmd.slice(4); // Remove 'SET ' prefix
898
+ const eqIdx = keyValue.indexOf('=');
899
+ if (eqIdx > 0) {
900
+ const key = keyValue.slice(0, eqIdx);
901
+ const value = keyValue.slice(eqIdx + 1);
902
+ if (key === 'sample_display') {
903
+ if (value === 'all' || value === 'best_worst' || value === 'random') {
904
+ this.sampleDisplayMode = value;
905
+ }
906
+ }
907
+ }
908
+ }
909
+ break;
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Wait for resume if paused, with polling
915
+ */
916
+ private async waitForResume(): Promise<void> {
917
+ while (this.paused && !this.stopRequested) {
918
+ await new Promise((resolve) => setTimeout(resolve, 100));
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Create a trainer by loading a model from disk
924
+ *
925
+ * This is the recommended way to create a trainer for training runs.
926
+ * If resumeFromCheckpoint is set, loads from checkpoint instead of modelPath.
927
+ *
928
+ * @param config - Configuration including modelPath
929
+ * @returns Promise<GRPOTrainer>
930
+ */
931
+ static async create<U>(config: GRPOTrainerConfig<U>): Promise<GRPOTrainer<U>> {
932
+ if (!config.modelPath) {
933
+ throw new Error('modelPath is required when using GRPOTrainer.create()');
934
+ }
935
+
936
+ // Validate unsupported config options (fail fast)
937
+ if (config.advantageNormalization === false) {
938
+ throw new Error('advantageNormalization=false is not yet supported. Remove this option or set to true.');
939
+ }
940
+ if (config.rewardType === 'model') {
941
+ throw new Error(
942
+ 'rewardType="model" is not implemented. Use rewardType="function" with a custom reward function.',
943
+ );
944
+ }
945
+ if (config.rewardModelPath) {
946
+ throw new Error('rewardModelPath is not implemented. Use rewardType="function" with a custom reward function.');
947
+ }
948
+ if (config.device && config.device !== 'metal') {
949
+ throw new Error(
950
+ `device="${config.device}" is not supported. MLX only supports Metal GPU. Remove device from config.`,
951
+ );
952
+ }
953
+ // A nonpositive maxCompletionLength would size every completion to an empty
954
+ // "length"-capped sample, so the GRPO completion filter drops them all and
955
+ // training advances bookkeeping without applying gradients (a silent
956
+ // no-op). The native engine rejects this too; fail fast in JS first.
957
+ if (
958
+ config.maxCompletionLength != null &&
959
+ (!Number.isInteger(config.maxCompletionLength) || config.maxCompletionLength <= 0)
960
+ ) {
961
+ throw new Error(`maxCompletionLength must be a positive integer, got ${config.maxCompletionLength}`);
962
+ }
963
+
964
+ // Create logger early (before model loading)
965
+ // TUI mode is auto-detected from MLX_TUI_MODE env var (set by mlx-tui)
966
+ const logger = createTrainingLogger({
967
+ logConsole: config.logConsole,
968
+ logJsonl: config.logJsonl,
969
+ outputDir: config.outputDir,
970
+ runName: config.runName,
971
+ logInterval: config.logInterval ?? 1,
972
+ });
973
+
974
+ let modelPath = config.modelPath;
975
+ let resumedState: TrainingState | null = null;
976
+
977
+ // Handle checkpoint resumption
978
+ if (config.resumeFromCheckpoint) {
979
+ const checkpointPath =
980
+ config.resumeFromCheckpoint === 'latest'
981
+ ? GRPOTrainer.findLatestCheckpoint(config.outputDir)
982
+ : config.resumeFromCheckpoint;
983
+
984
+ if (checkpointPath) {
985
+ const statePath = join(checkpointPath, 'training_state.json');
986
+ if (existsSync(statePath)) {
987
+ resumedState = JSON.parse(readFileSync(statePath, 'utf-8'));
988
+
989
+ // Fallback: If training_state.json has step 0 but checkpoint name suggests otherwise,
990
+ // derive step from checkpoint name (e.g., checkpoint-8 → step 8)
991
+ // This handles cases where training_state.json was corrupted or overwritten
992
+ if (resumedState && resumedState.step === 0) {
993
+ const checkpointName = checkpointPath.split('/').pop() ?? '';
994
+ const match = checkpointName.match(/^checkpoint-(\d+)$/);
995
+ if (match) {
996
+ const derivedStep = parseInt(match[1], 10);
997
+ if (derivedStep > 0) {
998
+ logger.warn(
999
+ `Checkpoint ${checkpointName} has step 0 in training_state.json but name suggests step ${derivedStep}. Using ${derivedStep}.`,
1000
+ );
1001
+ resumedState.step = derivedStep;
1002
+ // Estimate epoch from step (will be refined by actual training data)
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ logger.info(
1008
+ `Resuming from checkpoint: ${checkpointPath} (step ${resumedState?.step}, epoch ${resumedState?.epoch})`,
1009
+ );
1010
+ }
1011
+ // Load model weights from checkpoint
1012
+ modelPath = checkpointPath;
1013
+ } else if (config.resumeFromCheckpoint === 'latest') {
1014
+ logger.info('No checkpoint found, starting fresh training');
1015
+ }
1016
+ }
1017
+
1018
+ // Get model name for display
1019
+ const modelName = modelPath.split('/').pop() ?? 'Unknown';
1020
+ logger.status('loading', `Loading ${modelName}...`);
1021
+
1022
+ // Load model (auto-detects architecture from config.json)
1023
+ const model = await loadModel(modelPath);
1024
+
1025
+ logger.status('loading', `${modelName} loaded (${model.constructor.name})`);
1026
+
1027
+ // Create trainer with the pre-created logger
1028
+ // @ts-expect-error
1029
+ const trainer = new GRPOTrainer(model, config, logger);
1030
+
1031
+ // Always store the original model path (for tokenizer files when saving checkpoints)
1032
+ trainer.originalModelPath = config.modelPath;
1033
+
1034
+ // Restore training state if resuming
1035
+ if (resumedState) {
1036
+ trainer.currentStep = resumedState.step;
1037
+ trainer.currentEpoch = resumedState.epoch;
1038
+
1039
+ // Restore dataset metadata for resume validation
1040
+ if (resumedState.dataset) {
1041
+ trainer.datasetMetadata = {
1042
+ size: resumedState.dataset.size,
1043
+ contentHash: resumedState.dataset.contentHash,
1044
+ shuffleSeed: resumedState.dataset.shuffleSeed,
1045
+ };
1046
+
1047
+ // Restore processed batch indices
1048
+ if (resumedState.dataset.processedBatchIndices) {
1049
+ trainer.processedBatchIndices = new Set(resumedState.dataset.processedBatchIndices);
1050
+ }
1051
+
1052
+ logger.debug(
1053
+ `Restored dataset metadata: size=${resumedState.dataset.size}, hash=${resumedState.dataset.contentHash}, ` +
1054
+ `${trainer.processedBatchIndices.size} processed batches`,
1055
+ );
1056
+ }
1057
+
1058
+ // Restore optimizer state if available.
1059
+ //
1060
+ // If the checkpoint claims it has optimizer state (`hasOptimizerState`
1061
+ // set by `saveCheckpoint` only after verifying the file exists on
1062
+ // disk), any problem loading it is a HARD ERROR: the alternative is to
1063
+ // silently continue with a fresh optimizer, which masks corruption and
1064
+ // leaves the user wondering why their training dynamics drifted after
1065
+ // a resume. Missing file => corrupt checkpoint; throw failure =>
1066
+ // corrupt checkpoint. Either way, fail loud.
1067
+ if (resumedState.hasOptimizerState === true) {
1068
+ const optimizerStatePath = join(modelPath, 'optimizer_state.safetensors');
1069
+ if (!existsSync(optimizerStatePath)) {
1070
+ throw new Error(
1071
+ `Corrupt checkpoint at ${modelPath}: training_state.json declares ` +
1072
+ `hasOptimizerState=true but ${optimizerStatePath} does not exist. ` +
1073
+ `Refusing to silently continue with a fresh optimizer. ` +
1074
+ `To intentionally reset optimizer state on resume, set ` +
1075
+ `"hasOptimizerState": false in training_state.json.`,
1076
+ );
1077
+ }
1078
+ try {
1079
+ await trainer.engine.loadOptimizerState(optimizerStatePath);
1080
+ logger.info(`Restored optimizer state from checkpoint`);
1081
+ } catch (e) {
1082
+ throw new Error(
1083
+ `Failed to restore optimizer state from ${optimizerStatePath}: ${String(e)}. ` +
1084
+ `Refusing to silently continue with a fresh optimizer on a checkpoint that ` +
1085
+ `declared hasOptimizerState=true. ` +
1086
+ `To intentionally reset optimizer state on resume, set ` +
1087
+ `"hasOptimizerState": false in training_state.json and remove or rename ` +
1088
+ `the optimizer_state.safetensors file.`,
1089
+ );
1090
+ }
1091
+ }
1092
+
1093
+ // If resuming from a regular checkpoint (not emergency), track it as last known good
1094
+ // This allows recovery to fall back to this checkpoint if NaN gradients occur
1095
+ if (modelPath && !modelPath.includes('emergency-')) {
1096
+ trainer.lastGoodCheckpointPath = modelPath;
1097
+ trainer.lastGoodCheckpointStep = resumedState.step;
1098
+ logger.debug(`Initialized last good checkpoint from resumed state: step ${resumedState.step}`);
1099
+ }
1100
+ }
1101
+
1102
+ return trainer;
1103
+ }
1104
+
1105
+ /**
1106
+ * Find the latest checkpoint in the output directory
1107
+ */
1108
+ static findLatestCheckpoint(outputDir?: string): string | null {
1109
+ if (!outputDir || !existsSync(outputDir)) {
1110
+ return null;
1111
+ }
1112
+
1113
+ const entries = readdirSync(outputDir, { withFileTypes: true });
1114
+ const checkpoints = entries
1115
+ .filter((e) => e.isDirectory() && e.name.startsWith('checkpoint-'))
1116
+ .map((e) => ({
1117
+ name: e.name,
1118
+ step: parseInt(e.name.replace('checkpoint-', ''), 10),
1119
+ path: join(outputDir, e.name),
1120
+ }))
1121
+ .filter((c) => !isNaN(c.step))
1122
+ .sort((a, b) => b.step - a.step);
1123
+
1124
+ return checkpoints.length > 0 ? checkpoints[0].path : null;
1125
+ }
1126
+
1127
+ /**
1128
+ * Register a built-in reward function
1129
+ *
1130
+ * Built-in rewards run entirely in Rust with no FFI overhead.
1131
+ *
1132
+ * @example
1133
+ * ```typescript
1134
+ * // Tool use validation
1135
+ * trainer.registerBuiltinReward({
1136
+ * rewardType: 'ToolUse',
1137
+ * allowedTools: ['search', 'calculate'],
1138
+ * required: true,
1139
+ * weight: 1.0,
1140
+ * });
1141
+ *
1142
+ * // XML format validation
1143
+ * trainer.registerBuiltinReward({
1144
+ * rewardType: 'XmlFormat',
1145
+ * requiredTags: ['thinking', 'answer'],
1146
+ * weight: 0.5,
1147
+ * });
1148
+ *
1149
+ * // Length-based reward
1150
+ * trainer.registerBuiltinReward({
1151
+ * rewardType: 'Length',
1152
+ * minLength: 100,
1153
+ * maxLength: 500,
1154
+ * useChars: true,
1155
+ * });
1156
+ * ```
1157
+ */
1158
+ registerBuiltinReward(config: BuiltinRewardConfig): void {
1159
+ this.engine.registerBuiltinReward(config);
1160
+ }
1161
+
1162
+ /**
1163
+ * Set a custom JavaScript reward function
1164
+ *
1165
+ * The function will be called after generation to compute rewards.
1166
+ *
1167
+ * @param fn - Reward function that takes prompts and completions
1168
+ */
1169
+ setRewardFunction(fn: RewardFunction<T>): void {
1170
+ this.rewardFn = fn;
1171
+ }
1172
+
1173
+ /**
1174
+ * Generate completions for prompts
1175
+ *
1176
+ * Generates `groupSize` completions per prompt.
1177
+ * Returns all data needed for training, including tokens and log probabilities.
1178
+ *
1179
+ * @param prompts - Array of chat conversations
1180
+ * @returns GenerateBatchResult with completion texts and native generation data
1181
+ */
1182
+ async generateBatch(prompts: ChatMessage[][]): Promise<GenerateBatchResult> {
1183
+ if (prompts.length === 0) {
1184
+ return {
1185
+ completionTexts: [],
1186
+ nativeResult: {
1187
+ completionTexts: [],
1188
+ completionTokens: [],
1189
+ completionLogprobs: [],
1190
+ completionLengths: [],
1191
+ finishReasons: [],
1192
+ },
1193
+ tokenCounts: [],
1194
+ finishReasons: [],
1195
+ };
1196
+ }
1197
+
1198
+ // Call the native engine to generate completions with full data
1199
+ const nativeResult = await this.engine.generateBatchForTraining(prompts);
1200
+
1201
+ return {
1202
+ completionTexts: nativeResult.completionTexts,
1203
+ nativeResult,
1204
+ tokenCounts: nativeResult.completionLengths,
1205
+ finishReasons: nativeResult.finishReasons,
1206
+ };
1207
+ }
1208
+
1209
+ /**
1210
+ * Score completions using built-in rewards
1211
+ *
1212
+ * @param prompts - Prompt texts (one per completion)
1213
+ * @param completions - Completion texts
1214
+ * @returns Array of reward scores
1215
+ */
1216
+ scoreCompletions(prompts: string[], completions: string[]): number[] {
1217
+ return this.engine.scoreCompletions(prompts, completions);
1218
+ }
1219
+
1220
+ /**
1221
+ * Score generations using the configured reward function.
1222
+ *
1223
+ * Builds RewardOutput array with structured completion data and passes to reward function.
1224
+ *
1225
+ * @param prompts - Array of chat conversations
1226
+ * @param completions - Generated completion texts
1227
+ * @param context - Context for the reward function
1228
+ * @param groupSize - Number of completions per prompt (optional, defaults to config.groupSize)
1229
+ * @param tokenCounts - Token counts for each completion (optional, defaults to 0s)
1230
+ * @param finishReasons - Finish reasons from generation (optional, e.g. "stop", "length", "repetition")
1231
+ * @returns Promise<Float32Array> of reward scores
1232
+ */
1233
+ async scoreGenerations(
1234
+ prompts: ChatMessage[][],
1235
+ completions: string[],
1236
+ context: T,
1237
+ groupSize?: number,
1238
+ tokenCounts?: number[],
1239
+ finishReasons?: string[],
1240
+ ): Promise<Float32Array> {
1241
+ const effectiveGroupSize = groupSize ?? this.config.groupSize ?? 4;
1242
+ const expectedCompletions = prompts.length * effectiveGroupSize;
1243
+
1244
+ if (completions.length !== expectedCompletions) {
1245
+ throw new Error(
1246
+ `Expected ${expectedCompletions} completions (${prompts.length} prompts × ${effectiveGroupSize} groupSize) but got ${completions.length}`,
1247
+ );
1248
+ }
1249
+
1250
+ if (!this.rewardFn && !this.engine.hasBuiltinRewards) {
1251
+ throw new Error('No reward function configured. Set rewardFunction in config or call setRewardFunction()');
1252
+ }
1253
+
1254
+ // Convert ChatMessage[][] to string[] for Rust function
1255
+ const promptTexts = prompts.map((msgs) => msgs.map((m) => `${m.role}: ${m.content}`).join('\n'));
1256
+
1257
+ // Use provided token counts or default to 0
1258
+ const effectiveTokenCounts = tokenCounts ?? completions.map(() => 0);
1259
+
1260
+ // Use provided finish reasons or default to empty (triggers inference fallback in Rust)
1261
+ const effectiveFinishReasons = finishReasons ?? [];
1262
+
1263
+ // Build structured reward outputs using Rust function
1264
+ const rewardOutputs = buildRewardOutputs(
1265
+ promptTexts,
1266
+ completions,
1267
+ effectiveTokenCounts,
1268
+ effectiveFinishReasons,
1269
+ effectiveGroupSize,
1270
+ );
1271
+
1272
+ let rewards: number[] | Float32Array;
1273
+
1274
+ if (this.rewardFn) {
1275
+ // Get timeout from config (default 60 seconds, 0 = no timeout)
1276
+ const rewardTimeout = this.config.rewardTimeout ?? 60_000;
1277
+
1278
+ // Wrap reward function call with timeout
1279
+ const rewardPromise = Promise.resolve(this.rewardFn(rewardOutputs, context));
1280
+
1281
+ rewards = await withTimeout(
1282
+ rewardPromise,
1283
+ rewardTimeout,
1284
+ `Reward function timed out after ${rewardTimeout}ms. ` +
1285
+ `Consider increasing rewardTimeout in config or optimizing your reward function.`,
1286
+ );
1287
+ } else {
1288
+ // For built-in rewards, extract prompts and completions for legacy API
1289
+ const promptStrings = rewardOutputs.map((o) => o.prompt);
1290
+ const completionTexts = rewardOutputs.map((o) => o.completion.rawText);
1291
+ rewards = this.scoreCompletions(promptStrings, completionTexts);
1292
+ }
1293
+
1294
+ const rewardsArray = rewards instanceof Float32Array ? rewards : Float32Array.from(rewards);
1295
+
1296
+ if (rewardsArray.length !== expectedCompletions) {
1297
+ throw new Error(`Reward function returned ${rewardsArray.length} rewards but expected ${expectedCompletions}`);
1298
+ }
1299
+
1300
+ return rewardsArray;
1301
+ }
1302
+
1303
+ /**
1304
+ * Run a training step
1305
+ *
1306
+ * This method:
1307
+ * 1. Generates completions with tokens and log probabilities
1308
+ * 2. Computes rewards using the configured reward function
1309
+ * 3. Trains using the SAME completions that were scored (no double-generation)
1310
+ *
1311
+ * @param prompts - Array of chat conversations
1312
+ * @returns Training step metrics
1313
+ */
1314
+ async trainStep(prompts: ChatMessage[][], context?: T): Promise<TrainStepMetrics> {
1315
+ const { metrics } = await this.trainStepAuto(prompts, context);
1316
+ return metrics;
1317
+ }
1318
+
1319
+ /**
1320
+ * Run a complete training step with automatic reward computation
1321
+ *
1322
+ * This method combines generation, reward scoring, and training into a single
1323
+ * Rust call, eliminating FFI overhead by keeping token data in Rust memory.
1324
+ *
1325
+ * 1. Generates completions with full token/logprob data (stays in Rust)
1326
+ * 2. Calls JS reward function with RewardOutput[]
1327
+ * 3. Performs training update using the in-memory data
1328
+ *
1329
+ * @param prompts - Array of chat conversations
1330
+ * @param context - Context for the reward function
1331
+ * @returns Training metrics and generated completions
1332
+ */
1333
+ async trainStepAuto(
1334
+ prompts: ChatMessage[][],
1335
+ context?: T,
1336
+ ): Promise<{ metrics: TrainStepMetrics; completions: string[]; rewards: number[]; completionLengths: number[] }> {
1337
+ // Lazy initialize output store for low-level API users
1338
+ await this.ensureOutputStoreInitialized();
1339
+
1340
+ if (!this.rewardFn && !this.engine.hasBuiltinRewards) {
1341
+ throw new Error('No reward function configured. Set rewardFunction in config or call setRewardFunction()');
1342
+ }
1343
+
1344
+ // Create reward callback that parses JSON and converts output to number[]
1345
+ // The Rust side serializes Vec<RewardOutput> to JSON because complex nested types
1346
+ // don't convert properly through ThreadsafeFunction
1347
+ // Note: With CalleeHandled=true (default), callback receives (err, value) format
1348
+ // Using ThreadsafeFunction<T, Promise<R>> pattern so Rust can await the Promise
1349
+ const rewardCallback = async (err: Error | null, outputsJson: string): Promise<number[]> => {
1350
+ const rewardStart = Date.now();
1351
+ if (err) {
1352
+ throw new Error(`Reward callback error from Rust: ${err.message}`);
1353
+ }
1354
+
1355
+ if (!outputsJson || outputsJson === 'null') {
1356
+ throw new Error(`Invalid JSON received from Rust: ${outputsJson}`);
1357
+ }
1358
+ // Parse JSON and convert snake_case to camelCase for TypeScript compatibility
1359
+ // Rust's serde serializes as snake_case but TypeScript expects camelCase
1360
+ const rawOutputs = JSON.parse(outputsJson) as Array<{
1361
+ prompt: string;
1362
+ completion: {
1363
+ text: string;
1364
+ raw_text: string;
1365
+ tool_calls: unknown[];
1366
+ thinking: string | null;
1367
+ num_tokens: number;
1368
+ finish_reason: string;
1369
+ };
1370
+ expected_answer: string | null;
1371
+ }>;
1372
+
1373
+ // Convert to RewardOutput format with proper camelCase properties
1374
+ const outputs: RewardOutput[] = rawOutputs.map((o) => ({
1375
+ prompt: o.prompt,
1376
+ completion: {
1377
+ text: o.completion.text,
1378
+ rawText: o.completion.raw_text,
1379
+ toolCalls: (
1380
+ o.completion.tool_calls as Array<{
1381
+ id: string;
1382
+ name: string;
1383
+ arguments: Record<string, unknown>;
1384
+ status: string;
1385
+ error?: string;
1386
+ raw_content: string;
1387
+ }>
1388
+ ).map((tc) => ({
1389
+ id: tc.id,
1390
+ name: tc.name,
1391
+ arguments: tc.arguments,
1392
+ status: tc.status, // 'ok' | 'invalid_json' | 'missing_name'
1393
+ error: tc.error,
1394
+ rawContent: tc.raw_content,
1395
+ })),
1396
+ thinking: o.completion.thinking ?? undefined,
1397
+ numTokens: o.completion.num_tokens,
1398
+ finishReason: o.completion.finish_reason,
1399
+ },
1400
+ expectedAnswer: o.expected_answer ?? undefined,
1401
+ }));
1402
+
1403
+ this.logger.info(` → Computing rewards for ${outputs.length} completions...`);
1404
+
1405
+ let rewards: number[] | Float32Array;
1406
+ if (this.rewardFn) {
1407
+ // Get timeout from config (default 60 seconds, 0 = no timeout)
1408
+ const rewardTimeout = this.config.rewardTimeout ?? 60_000;
1409
+
1410
+ // Wrap reward function call with timeout
1411
+ const rewardPromise = Promise.resolve(
1412
+ // @ts-expect-error context is optional
1413
+ this.rewardFn(outputs, context),
1414
+ );
1415
+
1416
+ rewards = await withTimeout(
1417
+ rewardPromise,
1418
+ rewardTimeout,
1419
+ `Reward function timed out after ${rewardTimeout}ms. ` +
1420
+ `Consider increasing rewardTimeout in config or optimizing your reward function.`,
1421
+ );
1422
+ } else {
1423
+ // Use built-in rewards
1424
+ const promptStrings = outputs.map((o) => o.prompt);
1425
+ const completionTexts = outputs.map((o) => o.completion.rawText);
1426
+ rewards = this.scoreCompletions(promptStrings, completionTexts);
1427
+ }
1428
+
1429
+ // Convert Float32Array to plain number[] for NAPI compatibility
1430
+ let result: number[];
1431
+ if (rewards instanceof Float32Array) {
1432
+ result = Array.from(rewards, (v) => Number(v));
1433
+ } else {
1434
+ result = rewards.map((v) => Number(v));
1435
+ }
1436
+
1437
+ const rewardDuration = Date.now() - rewardStart;
1438
+ const avgReward = result.reduce((a, b) => a + b, 0) / result.length;
1439
+ this.logger.info(` → Rewards computed in ${rewardDuration}ms (avg=${avgReward.toFixed(2)})`);
1440
+
1441
+ return result;
1442
+ };
1443
+
1444
+ // Call unified Rust method - generation, scoring, and training in one FFI call
1445
+ // Use recording method if output store is enabled
1446
+ const recordOutputs = !!this.outputStore;
1447
+ const result: TrainStepResultWithOutputs = await this.engine.trainStepAuto(prompts, rewardCallback, recordOutputs);
1448
+
1449
+ this.currentStep++;
1450
+
1451
+ // Record outputs to database if enabled
1452
+ if (this.outputStore && result.outputsJson) {
1453
+ try {
1454
+ await this.outputStore.recordStepFromOutputs(
1455
+ this.currentStep,
1456
+ result.metrics,
1457
+ result.outputsJson,
1458
+ result.rewards,
1459
+ this.config.groupSize ?? 4,
1460
+ );
1461
+ } catch (err) {
1462
+ // Log error but don't fail training
1463
+ console.error('[OutputStore] Failed to record step:', err);
1464
+ }
1465
+ }
1466
+
1467
+ return {
1468
+ metrics: { ...result.metrics, epoch: this.currentEpoch },
1469
+ completions: result.completions,
1470
+ rewards: result.rewards,
1471
+ completionLengths: result.completionLengths,
1472
+ };
1473
+ }
1474
+
1475
+ /**
1476
+ * Increment the step counter (for custom training loops)
1477
+ *
1478
+ * Call this after each training step when using low-level APIs like
1479
+ * engine.trainStepWithGenerations() instead of trainer.trainStepAuto().
1480
+ */
1481
+ incrementStep(): void {
1482
+ this.currentStep++;
1483
+ }
1484
+
1485
+ /**
1486
+ * Get the current step number
1487
+ */
1488
+ getStep(): number {
1489
+ return this.currentStep;
1490
+ }
1491
+
1492
+ /**
1493
+ * Get the current epoch number
1494
+ */
1495
+ getEpoch(): number {
1496
+ return this.currentEpoch;
1497
+ }
1498
+
1499
+ /**
1500
+ * Record a training step to the output store database (for custom training loops)
1501
+ *
1502
+ * Use this when building custom training loops with engine.trainStepWithGenerations().
1503
+ * The step number should be the value after incrementStep() was called.
1504
+ *
1505
+ * @param step - Step number
1506
+ * @param metrics - Step metrics from the engine
1507
+ * @param completions - Generated completion texts
1508
+ * @param rewards - Reward values for each completion
1509
+ * @param prompts - Prompt messages for each completion
1510
+ */
1511
+ async recordStepToDatabase(
1512
+ step: number,
1513
+ metrics: {
1514
+ loss: number;
1515
+ meanReward: number;
1516
+ stdReward: number;
1517
+ meanAdvantage: number;
1518
+ stdAdvantage: number;
1519
+ totalTokens: number;
1520
+ },
1521
+ completions: string[],
1522
+ rewards: number[],
1523
+ prompts: string[],
1524
+ ): Promise<void> {
1525
+ if (!this.outputStore) return;
1526
+
1527
+ const groupSize = this.config.groupSize ?? 4;
1528
+
1529
+ // Build outputs JSON in the format expected by recordStepFromOutputs
1530
+ const outputs = completions.map((text, i) => ({
1531
+ prompt: prompts[Math.floor(i / groupSize)] ?? '',
1532
+ completion: {
1533
+ text,
1534
+ raw_text: text,
1535
+ tool_calls: [],
1536
+ thinking: null,
1537
+ num_tokens: text.length, // Approximate
1538
+ finish_reason: 'stop',
1539
+ },
1540
+ expected_answer: null,
1541
+ }));
1542
+
1543
+ const outputsJson = JSON.stringify(outputs);
1544
+
1545
+ try {
1546
+ await this.outputStore.recordStepFromOutputs(
1547
+ step,
1548
+ {
1549
+ step,
1550
+ loss: metrics.loss,
1551
+ totalTokens: metrics.totalTokens,
1552
+ meanReward: metrics.meanReward,
1553
+ stdReward: metrics.stdReward,
1554
+ meanAdvantage: metrics.meanAdvantage,
1555
+ stdAdvantage: metrics.stdAdvantage,
1556
+ generationTimeMs: 0,
1557
+ trainingTimeMs: 0,
1558
+ peakMemoryMb: 0,
1559
+ activeMemoryMb: 0,
1560
+ gradientsApplied: true,
1561
+ },
1562
+ outputsJson,
1563
+ rewards,
1564
+ groupSize,
1565
+ );
1566
+ } catch (err) {
1567
+ console.error('[OutputStore] Failed to record step:', err);
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * Run a full training loop over a dataset
1573
+ *
1574
+ * This is the high-level training API that handles:
1575
+ * - Epoch iteration
1576
+ * - Batching
1577
+ * - Generation and reward computation
1578
+ * - Logging (if configured)
1579
+ * - Checkpoint saving and resumption
1580
+ * - TUI mode support (pause/resume, sample reporting)
1581
+ *
1582
+ * @param dataset - Array of DatasetExample items
1583
+ */
1584
+ async train(dataset: DatasetExample[]): Promise<void> {
1585
+ if (dataset.length === 0) {
1586
+ return;
1587
+ }
1588
+
1589
+ const numEpochs = this.config.numEpochs ?? 1;
1590
+ const batchSize = this.config.batchSize ?? 1;
1591
+ const saveInterval = this.config.saveInterval ?? 100;
1592
+
1593
+ // Create output directory if needed
1594
+ if (this.config.outputDir && !existsSync(this.config.outputDir)) {
1595
+ mkdirSync(this.config.outputDir, { recursive: true });
1596
+ }
1597
+
1598
+ // Calculate total steps per epoch BEFORE initOutputStore (needed for accurate resume state)
1599
+ const stepsPerEpoch = Math.ceil(dataset.length / batchSize);
1600
+
1601
+ // Compute current dataset metadata
1602
+ const currentDatasetHash = computeDatasetHash(dataset);
1603
+ const currentDatasetMetadata: DatasetMetadata = {
1604
+ size: dataset.length,
1605
+ contentHash: currentDatasetHash,
1606
+ };
1607
+
1608
+ // Validate dataset on resume if we have previous metadata
1609
+ if (this.datasetMetadata && this.currentStep > 0) {
1610
+ const prevMeta = this.datasetMetadata;
1611
+
1612
+ if (prevMeta.size !== dataset.length) {
1613
+ this.logger.warn(
1614
+ `[Resume] Dataset size mismatch: checkpoint was trained on ${prevMeta.size} examples, ` +
1615
+ `current dataset has ${dataset.length} examples. Batch indices may not align correctly.`,
1616
+ );
1617
+ }
1618
+
1619
+ if (prevMeta.contentHash !== currentDatasetHash) {
1620
+ this.logger.warn(
1621
+ `[Resume] Dataset content mismatch: checkpoint dataset hash ${prevMeta.contentHash}, ` +
1622
+ `current dataset hash ${currentDatasetHash}. Dataset may have been modified or shuffled differently.`,
1623
+ );
1624
+ }
1625
+
1626
+ // Log validation result
1627
+ if (prevMeta.size === dataset.length && prevMeta.contentHash === currentDatasetHash) {
1628
+ this.logger.info(
1629
+ `[Resume] Dataset validated: ${dataset.length} examples, hash ${currentDatasetHash} (matches checkpoint)`,
1630
+ );
1631
+ }
1632
+ }
1633
+
1634
+ // Store current dataset metadata for future checkpoints
1635
+ this.datasetMetadata = currentDatasetMetadata;
1636
+
1637
+ // Initialize output store if enabled (pass stepsPerEpoch for accurate batch display on resume)
1638
+ await this.initOutputStore(stepsPerEpoch);
1639
+
1640
+ // Determine starting point based on resumed state
1641
+ const startEpoch = this.currentEpoch;
1642
+ const startStep = this.currentStep;
1643
+ const startBatchIdx = startStep > 0 ? startStep % stepsPerEpoch : 0;
1644
+
1645
+ // Get model name from path
1646
+ const modelName = this.originalModelPath?.split('/').pop() ?? this.config.modelPath?.split('/').pop() ?? 'Unknown';
1647
+
1648
+ // Log training start
1649
+ this.logger.init(
1650
+ modelName,
1651
+ {
1652
+ trainingType: 'grpo',
1653
+ numEpochs,
1654
+ batchSize,
1655
+ groupSize: this.config.groupSize ?? 4,
1656
+ learningRate: this.config.learningRate ?? 1e-6,
1657
+ },
1658
+ dataset.length,
1659
+ );
1660
+
1661
+ if (startStep > 0) {
1662
+ this.logger.info(
1663
+ `Resuming from step ${startStep} (epoch ${startEpoch + 1}, batch ${startBatchIdx + 1}/${stepsPerEpoch})`,
1664
+ );
1665
+ }
1666
+
1667
+ for (let epoch = startEpoch; epoch < numEpochs; epoch++) {
1668
+ // Check for stop request
1669
+ if (this.stopRequested) break;
1670
+
1671
+ this.currentEpoch = epoch;
1672
+ this.startEpoch();
1673
+ const epochStartTime = Date.now();
1674
+
1675
+ // Log epoch start
1676
+ this.logger.epochStart(epoch, numEpochs, stepsPerEpoch);
1677
+
1678
+ // Calculate starting batch index for this epoch
1679
+ const epochStartBatch = epoch === startEpoch && startStep > 0 ? startBatchIdx * batchSize : 0;
1680
+
1681
+ // Iterate through batches
1682
+ for (let i = epochStartBatch; i < dataset.length; i += batchSize) {
1683
+ // Check for stop request
1684
+ if (this.stopRequested) break;
1685
+
1686
+ // Wait if paused
1687
+ if (this.paused) {
1688
+ await this.waitForResume();
1689
+ if (this.stopRequested) break;
1690
+ }
1691
+
1692
+ // Calculate batch index (0-indexed within epoch)
1693
+ const batchIdx = Math.floor(i / batchSize);
1694
+
1695
+ // Skip already processed batches on resume (from checkpoint's processedBatchIndices)
1696
+ if (this.processedBatchIndices.has(batchIdx)) {
1697
+ this.logger.debug(`Skipping already processed batch ${batchIdx + 1}/${stepsPerEpoch} (from checkpoint)`);
1698
+ continue;
1699
+ }
1700
+
1701
+ const batch = dataset.slice(i, Math.min(i + batchSize, dataset.length));
1702
+
1703
+ // Extract prompts and answers from batch
1704
+ const prompts = batch.map((ex) => ex.prompt);
1705
+
1706
+ // Verbose logging for debugging stuck batches
1707
+ const batchNum = batchIdx + 1;
1708
+ this.logger.info(
1709
+ `Batch ${batchNum}/${stepsPerEpoch} starting (${prompts.length} prompts × ${this.config.groupSize ?? 4} groups)`,
1710
+ );
1711
+
1712
+ // Run training step with auto reward computation
1713
+ const stepStartTime = Date.now();
1714
+ const { metrics, completions, rewards, completionLengths } = await this.trainStepAuto(prompts);
1715
+ const stepDuration = Date.now() - stepStartTime;
1716
+ this.logger.info(
1717
+ `Batch ${batchNum}/${stepsPerEpoch} done in ${(stepDuration / 1000).toFixed(1)}s ` +
1718
+ `(gen=${metrics.generationTimeMs?.toFixed(0) ?? '?'}ms, train=${metrics.trainingTimeMs?.toFixed(0) ?? '?'}ms, loss=${metrics.loss.toFixed(4)})`,
1719
+ );
1720
+
1721
+ // Log step metrics (logger handles TUI/console mode internally)
1722
+ this.logger.step(metrics, batchIdx, stepsPerEpoch);
1723
+
1724
+ // Track processed batch for resume
1725
+ this.processedBatchIndices.add(batchIdx);
1726
+
1727
+ // Report generation samples to TUI based on display mode
1728
+ // In console mode, logger.generation() is a no-op
1729
+ const groupSize = this.config.groupSize ?? 4;
1730
+
1731
+ // Determine which sample indices to report based on display mode
1732
+ let indicesToReport: number[];
1733
+ if (this.sampleDisplayMode === 'all') {
1734
+ // Report all samples
1735
+ indicesToReport = Array.from({ length: completions.length }, (_, i) => i);
1736
+ } else if (this.sampleDisplayMode === 'best_worst') {
1737
+ // Find indices of best (max reward) and worst (min reward) samples
1738
+ let bestIdx = 0;
1739
+ let worstIdx = 0;
1740
+ let bestReward = rewards[0];
1741
+ let worstReward = rewards[0];
1742
+ for (let j = 1; j < rewards.length; j++) {
1743
+ if (rewards[j] > bestReward) {
1744
+ bestReward = rewards[j];
1745
+ bestIdx = j;
1746
+ }
1747
+ if (rewards[j] < worstReward) {
1748
+ worstReward = rewards[j];
1749
+ worstIdx = j;
1750
+ }
1751
+ }
1752
+ // Avoid duplicates if best and worst are the same
1753
+ indicesToReport = bestIdx === worstIdx ? [bestIdx] : [bestIdx, worstIdx];
1754
+ } else {
1755
+ // random: pick 2 random samples (or fewer if completions.length < 2)
1756
+ const numSamples = Math.min(2, completions.length);
1757
+ const shuffled = Array.from({ length: completions.length }, (_, i) => i);
1758
+ // Fisher-Yates partial shuffle for first numSamples
1759
+ for (let k = 0; k < numSamples; k++) {
1760
+ const randIdx = k + Math.floor(Math.random() * (shuffled.length - k));
1761
+ [shuffled[k], shuffled[randIdx]] = [shuffled[randIdx], shuffled[k]];
1762
+ }
1763
+ indicesToReport = shuffled.slice(0, numSamples);
1764
+ }
1765
+
1766
+ for (const j of indicesToReport) {
1767
+ // Get the prompt for this completion (each prompt has groupSize completions)
1768
+ const promptIdx = Math.floor(j / groupSize);
1769
+ const promptMessages = prompts[promptIdx] ?? [];
1770
+ // Format prompt as text (last user message is most relevant)
1771
+ const lastUserMsg = promptMessages.filter((m) => m.role === 'user').pop();
1772
+ const promptText = lastUserMsg?.content ?? '';
1773
+
1774
+ this.logger.generation({
1775
+ index: j,
1776
+ prompt: promptText,
1777
+ completion: completions[j],
1778
+ reward: rewards[j],
1779
+ tokens: completionLengths[j] ?? this.config.maxCompletionLength ?? 256,
1780
+ });
1781
+ }
1782
+
1783
+ // Save checkpoint periodically
1784
+ if (this.config.outputDir && this.currentStep > 0 && this.currentStep % saveInterval === 0) {
1785
+ const path = await this.saveCheckpoint();
1786
+ if (path) {
1787
+ this.logger.checkpoint(path, this.currentStep);
1788
+ }
1789
+ }
1790
+
1791
+ // Check for emergency checkpoint (triggered by consecutive NaN gradients)
1792
+ if (this.config.outputDir && this.engine.needsEmergencySave) {
1793
+ this.logger.warn(
1794
+ `[EMERGENCY] Emergency save triggered after ${this.engine.nanGradientCount} consecutive NaN gradients at step ${this.currentStep}`,
1795
+ );
1796
+
1797
+ // Save current (possibly corrupted) state for debugging
1798
+ const debugCheckpointPath = `emergency-debug-step-${this.currentStep}`;
1799
+ await this.saveCheckpoint(debugCheckpointPath, { isEmergency: true });
1800
+ this.logger.info(
1801
+ `[EMERGENCY] Saved debug checkpoint with current (possibly corrupted) state to ${debugCheckpointPath}`,
1802
+ );
1803
+
1804
+ // If we have a last known good checkpoint, copy it for recovery
1805
+ if (this.lastGoodCheckpointPath && existsSync(this.lastGoodCheckpointPath)) {
1806
+ this.logger.warn(
1807
+ `[EMERGENCY] Reverting to last good checkpoint from step ${this.lastGoodCheckpointStep}: ${this.lastGoodCheckpointPath}`,
1808
+ );
1809
+
1810
+ // Copy last good checkpoint to a recovery location
1811
+ const outputDir = this.config.outputDir ?? './outputs';
1812
+ const recoveryPath = join(outputDir, `emergency-recovery-step-${this.lastGoodCheckpointStep}`);
1813
+
1814
+ try {
1815
+ // Remove existing recovery checkpoint if it exists
1816
+ if (existsSync(recoveryPath)) {
1817
+ rmSync(recoveryPath, { recursive: true, force: true });
1818
+ }
1819
+
1820
+ // Copy the last good checkpoint to recovery location
1821
+ cpSync(this.lastGoodCheckpointPath, recoveryPath, { recursive: true });
1822
+ this.logger.info(`[EMERGENCY] Copied last good checkpoint to ${recoveryPath}`);
1823
+ this.logger.warn(
1824
+ `[EMERGENCY] Recovery checkpoint available at: ${recoveryPath}\n` +
1825
+ ` To resume from the last good state, use: resumeFromCheckpoint: '${recoveryPath}'`,
1826
+ );
1827
+ } catch (copyError) {
1828
+ this.logger.error(`[EMERGENCY] Failed to copy last good checkpoint: ${copyError as Error}`);
1829
+ }
1830
+ } else {
1831
+ this.logger.error(
1832
+ `[EMERGENCY] No previous good checkpoint available for recovery! ` +
1833
+ `The debug checkpoint contains the current (potentially corrupted) model state.`,
1834
+ );
1835
+ }
1836
+
1837
+ // Clear the emergency flag
1838
+ this.engine.clearEmergencySaveFlag();
1839
+
1840
+ // Log recovery guidance
1841
+ this.logger.warn(
1842
+ `[EMERGENCY] Training will continue, but model quality may be degraded.\n` +
1843
+ ` Recommendations:\n` +
1844
+ ` - Reduce learning rate (current: ${this.config.learningRate ?? 1e-6})\n` +
1845
+ ` - Check training data for anomalies\n` +
1846
+ ` - Consider stopping and resuming from the recovery checkpoint`,
1847
+ );
1848
+ }
1849
+ }
1850
+
1851
+ const epochEndTime = Date.now();
1852
+ const epochTimeSecs = (epochEndTime - epochStartTime) / 1000;
1853
+ this.endEpoch(epochTimeSecs);
1854
+
1855
+ this.logger.epochEnd(epoch, numEpochs, epochTimeSecs);
1856
+
1857
+ // Clear processed batch indices at epoch boundary (new epoch = new batches)
1858
+ this.processedBatchIndices.clear();
1859
+ }
1860
+
1861
+ // Save final checkpoint
1862
+ if (this.config.outputDir && !this.stopRequested) {
1863
+ const path = await this.saveCheckpoint('final');
1864
+ if (path) {
1865
+ this.logger.checkpoint(path, this.currentStep);
1866
+ }
1867
+ }
1868
+
1869
+ // Log completion
1870
+ this.logger.complete(this.currentStep);
1871
+
1872
+ // End output store run if active
1873
+ if (this.outputStore) {
1874
+ const status = this.stopRequested ? 'stopped' : 'completed';
1875
+ await this.outputStore.endRun(status);
1876
+ await this.outputStore.flush();
1877
+ }
1878
+
1879
+ // Cleanup stdin interface
1880
+ if (this.stdinInterface) {
1881
+ this.stdinInterface.close();
1882
+ }
1883
+ }
1884
+
1885
+ /**
1886
+ * Save a checkpoint with model weights and training state
1887
+ *
1888
+ * Regular checkpoints (non-emergency) are tracked as "last known good" checkpoints.
1889
+ * When NaN gradients occur, the emergency save logic can restore from the last good checkpoint.
1890
+ *
1891
+ * @param name - Checkpoint name (default: "checkpoint-{step}")
1892
+ * @param options - Optional settings for checkpoint save behavior
1893
+ * @param options.isEmergency - If true, this is an emergency checkpoint (debug state, not "good")
1894
+ * @returns Path to saved checkpoint, or empty string if save was skipped due to corruption
1895
+ */
1896
+ async saveCheckpoint(name?: string, options?: { isEmergency?: boolean }): Promise<string> {
1897
+ const isEmergency = options?.isEmergency ?? false;
1898
+ const checkpointName = name ?? `checkpoint-${this.currentStep}`;
1899
+ const outputDir = this.config.outputDir ?? './outputs';
1900
+ const checkpointPath = join(outputDir, checkpointName);
1901
+
1902
+ // Create checkpoint directory
1903
+ if (!existsSync(checkpointPath)) {
1904
+ mkdirSync(checkpointPath, { recursive: true });
1905
+ }
1906
+
1907
+ // Save training state with dataset metadata for resume validation
1908
+ const state: TrainingState = {
1909
+ step: this.currentStep,
1910
+ epoch: this.currentEpoch,
1911
+ timestamp: new Date().toISOString(),
1912
+ dataset: this.datasetMetadata
1913
+ ? {
1914
+ size: this.datasetMetadata.size,
1915
+ contentHash: this.datasetMetadata.contentHash,
1916
+ shuffleSeed: this.datasetMetadata.shuffleSeed,
1917
+ processedBatchIndices: Array.from(this.processedBatchIndices),
1918
+ }
1919
+ : undefined,
1920
+ };
1921
+ const statePath = join(checkpointPath, 'training_state.json');
1922
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
1923
+
1924
+ // Save model weights
1925
+ await this.model.saveModel(checkpointPath);
1926
+
1927
+ // Copy tokenizer files from original model path (required for loading checkpoints)
1928
+ const tokenizerSource = this.originalModelPath ?? this.config.modelPath;
1929
+ if (tokenizerSource) {
1930
+ const tokenizerFiles = ['tokenizer.json', 'tokenizer_config.json', 'vocab.json', 'merges.txt'];
1931
+ for (const file of tokenizerFiles) {
1932
+ const srcPath = join(tokenizerSource, file);
1933
+ const destPath = join(checkpointPath, file);
1934
+ if (existsSync(srcPath) && !existsSync(destPath)) {
1935
+ copyFileSync(srcPath, destPath);
1936
+ }
1937
+ }
1938
+ }
1939
+
1940
+ // Save optimizer state (AdamW moments + step counter)
1941
+ //
1942
+ // NOTE: `save_optimizer_state_sync` on the Rust side intentionally returns
1943
+ // `Ok(())` WITHOUT writing a file in two cases (see
1944
+ // crates/mlx-core/src/training_state.rs):
1945
+ // 1. No optimizer configured (SGD path — `self.optimizer.is_none()`).
1946
+ // 2. AdamW configured but the state map is empty because no training
1947
+ // step has ever run through `update_batch` (e.g. checkpoint taken
1948
+ // before any trainStep, or every rollout was filtered by the
1949
+ // degenerate-completion filter).
1950
+ //
1951
+ // Those are legitimate no-ops on the Rust side, but the TS trainer MUST
1952
+ // NOT lie about disk state: `hasOptimizerState` is the flag the resume
1953
+ // path reads to decide whether to call `loadOptimizerState`. If we set it
1954
+ // to `true` when no fresh file exists, the resume path will either load
1955
+ // a stale file from a previous save in the same directory, or crash on a
1956
+ // missing file — both are silent corruption paths.
1957
+ //
1958
+ // CRITICAL: checkpoint directories can be reused (e.g. emergency save into
1959
+ // an existing directory, or user-provided `outputDir` with a predictable
1960
+ // checkpoint name). An old `optimizer_state.safetensors` from a previous
1961
+ // save would make `existsSync` return true even when THIS save was a
1962
+ // no-op, so we would load stale state from a completely different step
1963
+ // on resume. Unlink the file up-front so that `existsSync` after the save
1964
+ // reflects only what the current save produced.
1965
+ const optimizerStatePath = join(checkpointPath, 'optimizer_state.safetensors');
1966
+ if (existsSync(optimizerStatePath)) {
1967
+ rmSync(optimizerStatePath, { force: true });
1968
+ }
1969
+ try {
1970
+ await this.engine.saveOptimizerState(optimizerStatePath);
1971
+ const wroteOptimizerState = existsSync(optimizerStatePath);
1972
+ state.hasOptimizerState = wroteOptimizerState;
1973
+ if (!wroteOptimizerState) {
1974
+ // Expected when SGD is configured or no training step has populated
1975
+ // AdamW moments yet. Logged at info so it's visible in normal runs
1976
+ // without requiring debug mode — the fact that a checkpoint has no
1977
+ // optimizer state is useful signal for anyone debugging resume.
1978
+ this.logger.info(
1979
+ `saveOptimizerState produced no file at ${optimizerStatePath} ` +
1980
+ `(SGD or empty AdamW state); hasOptimizerState=false`,
1981
+ );
1982
+ }
1983
+ // Re-write training_state.json with the accurate hasOptimizerState flag
1984
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
1985
+ } catch (e) {
1986
+ // A thrown error from saveOptimizerState is a real save failure (not the
1987
+ // legitimate no-op path above). Force hasOptimizerState=false so resume
1988
+ // doesn't try to load a file that may be missing or partially written,
1989
+ // and unlink any partial file the Rust side may have left behind.
1990
+ // Log loud — real save failures should be visible.
1991
+ this.logger.warn(`Failed to save optimizer state: ${String(e)}`);
1992
+ if (existsSync(optimizerStatePath)) {
1993
+ rmSync(optimizerStatePath, { force: true });
1994
+ }
1995
+ state.hasOptimizerState = false;
1996
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
1997
+ }
1998
+
1999
+ this.logger.info(`Checkpoint saved: ${checkpointPath}`);
2000
+
2001
+ // Track last checkpoint step for emergency save throttling
2002
+ this.lastCheckpointStep = this.currentStep;
2003
+
2004
+ // Track as "last known good" checkpoint (only for regular saves, not emergency saves)
2005
+ if (!isEmergency) {
2006
+ this.lastGoodCheckpointPath = checkpointPath;
2007
+ this.lastGoodCheckpointStep = this.currentStep;
2008
+ this.logger.debug(`Tracked as last good checkpoint: step ${this.currentStep}`);
2009
+ }
2010
+
2011
+ // Clean up old checkpoints to save disk space
2012
+ const maxCheckpoints = this.config.maxCheckpoints ?? 3;
2013
+ if (maxCheckpoints > 0) {
2014
+ this.cleanupOldCheckpoints(outputDir, maxCheckpoints);
2015
+ }
2016
+
2017
+ return checkpointPath;
2018
+ }
2019
+
2020
+ /**
2021
+ * Remove old checkpoints, keeping only the most recent ones
2022
+ * Preserves 'final' and 'emergency-*' checkpoints
2023
+ */
2024
+ private cleanupOldCheckpoints(outputDir: string, maxToKeep: number): void {
2025
+ try {
2026
+ const entries = readdirSync(outputDir, { withFileTypes: true });
2027
+
2028
+ // Find regular checkpoint directories (checkpoint-N pattern)
2029
+ const checkpoints: { name: string; step: number; mtime: Date }[] = [];
2030
+ for (const entry of entries) {
2031
+ if (!entry.isDirectory()) continue;
2032
+
2033
+ // Skip 'final' and 'emergency-*' checkpoints
2034
+ if (entry.name === 'final' || entry.name.startsWith('emergency-')) continue;
2035
+
2036
+ // Match checkpoint-N pattern
2037
+ const match = entry.name.match(/^checkpoint-(\d+)$/);
2038
+ if (match) {
2039
+ const checkpointPath = join(outputDir, entry.name);
2040
+ const stat = statSync(checkpointPath);
2041
+ checkpoints.push({
2042
+ name: entry.name,
2043
+ step: parseInt(match[1], 10),
2044
+ mtime: stat.mtime,
2045
+ });
2046
+ }
2047
+ }
2048
+
2049
+ // Sort by step number descending (newest first)
2050
+ checkpoints.sort((a, b) => b.step - a.step);
2051
+
2052
+ // Remove old checkpoints beyond maxToKeep
2053
+ if (checkpoints.length > maxToKeep) {
2054
+ const toRemove = checkpoints.slice(maxToKeep);
2055
+ for (const checkpoint of toRemove) {
2056
+ const checkpointPath = join(outputDir, checkpoint.name);
2057
+ rmSync(checkpointPath, { recursive: true, force: true });
2058
+ this.logger.debug(`Removed old checkpoint: ${checkpoint.name}`);
2059
+ }
2060
+ }
2061
+ } catch (error) {
2062
+ // Don't fail training if cleanup fails
2063
+ this.logger.warn(`Failed to cleanup old checkpoints: ${error as Error}`);
2064
+ }
2065
+ }
2066
+
2067
+ /**
2068
+ * Start a new training epoch
2069
+ */
2070
+ startEpoch(): void {
2071
+ this.engine.startEpoch();
2072
+ }
2073
+
2074
+ /**
2075
+ * End the current epoch and get metrics
2076
+ *
2077
+ * @param epochTimeSecs - Duration of the epoch in seconds
2078
+ */
2079
+ endEpoch(epochTimeSecs: number): EngineEpochMetrics {
2080
+ return this.engine.endEpoch(epochTimeSecs);
2081
+ }
2082
+
2083
+ /**
2084
+ * Reset the trainer for a new training run
2085
+ */
2086
+ reset(): void {
2087
+ this.engine.reset();
2088
+ }
2089
+
2090
+ /**
2091
+ * Get current training step
2092
+ */
2093
+ get step(): number {
2094
+ return Number(this.engine.step);
2095
+ }
2096
+
2097
+ /**
2098
+ * Get current epoch
2099
+ */
2100
+ get epoch(): number {
2101
+ return this.engine.epoch;
2102
+ }
2103
+
2104
+ /**
2105
+ * Get current micro-step within gradient accumulation
2106
+ */
2107
+ get microStep(): number {
2108
+ return this.engine.microStep;
2109
+ }
2110
+
2111
+ /**
2112
+ * Check if built-in rewards are configured
2113
+ */
2114
+ get hasBuiltinRewards(): boolean {
2115
+ return this.engine.hasBuiltinRewards;
2116
+ }
2117
+
2118
+ /**
2119
+ * Get names of registered reward functions
2120
+ */
2121
+ get rewardNames(): string[] {
2122
+ return this.engine.rewardNames;
2123
+ }
2124
+
2125
+ /**
2126
+ * Get the underlying native engine
2127
+ *
2128
+ * For advanced use cases that need direct access.
2129
+ */
2130
+ getNativeEngine(): GrpoTrainingEngine {
2131
+ return this.engine;
2132
+ }
2133
+ }
2134
+
2135
+ /**
2136
+ * Create a standalone reward registry for testing rewards
2137
+ *
2138
+ * @example
2139
+ * ```typescript
2140
+ * const registry = createRewardRegistry();
2141
+ * registry.register({
2142
+ * rewardType: 'ToolUse',
2143
+ * allowedTools: ['search'],
2144
+ * });
2145
+ *
2146
+ * const score = registry.score('prompt', 'completion with <tool_call>...</tool_call>');
2147
+ * ```
2148
+ */
2149
+ export function createRewardRegistry(): NativeRewardRegistry {
2150
+ return new NativeRewardRegistry();
2151
+ }