@mlx-node/trl 0.0.13 → 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,679 @@
1
+ /**
2
+ * SFT (Supervised Fine-Tuning) Trainer
3
+ *
4
+ * This module provides a Rust-native SFT training engine for training
5
+ * models on fixed prompt-completion pairs using cross-entropy loss.
6
+ *
7
+ * ## Key Features
8
+ * - Training loop runs in Rust (eliminates FFI overhead)
9
+ * - Cross-entropy loss with completion masking (ignore_index=-100)
10
+ * - Label smoothing support
11
+ * - Gradient accumulation and clipping
12
+ * - High-level train() method for full training runs
13
+ * - Low-level trainStep() for custom training loops
14
+ *
15
+ * ## Usage
16
+ * ```typescript
17
+ * const trainer = await SFTTrainer.create({
18
+ * modelPath: './model',
19
+ * learningRate: 2e-5,
20
+ * numEpochs: 3,
21
+ * });
22
+ * await trainer.train(dataset);
23
+ * ```
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, rmSync } from 'node:fs';
27
+ import { join, parse } from 'node:path';
28
+ import * as readline from 'node:readline';
29
+
30
+ import {
31
+ SftTrainingEngine,
32
+ Qwen3Model,
33
+ Qwen35Model,
34
+ Qwen35MoeModel,
35
+ Qwen3Tokenizer,
36
+ MxArray,
37
+ type SftEngineConfig,
38
+ type SftStepMetrics,
39
+ type SftEpochMetrics,
40
+ } from '@mlx-node/core';
41
+ import { loadModel, type TrainableModel } from '@mlx-node/lm';
42
+
43
+ import { SFTDataset, loadSFTDataset, type SFTBatch } from '../data/sft-dataset.js';
44
+ import type { SFTTrainerConfig } from './sft-config.js';
45
+ import { getDefaultSFTConfig, mergeSFTConfig } from './sft-config.js';
46
+ import { createTrainingLogger, type TrainingLogger } from './training-logger.js';
47
+
48
+ // Re-export types
49
+ export { SftTrainingEngine } from '@mlx-node/core';
50
+ export type { SftEngineConfig, SftStepMetrics, SftEpochMetrics } from '@mlx-node/core';
51
+
52
+ /**
53
+ * Training state saved with checkpoints for resumption
54
+ */
55
+ export interface SFTTrainingState {
56
+ step: number;
57
+ epoch: number;
58
+ timestamp: string;
59
+ trainerType: 'sft';
60
+ }
61
+
62
+ /**
63
+ * Training step result
64
+ */
65
+ export interface SFTTrainStepResult {
66
+ /** Step metrics */
67
+ metrics: SftStepMetrics;
68
+ /** Current epoch */
69
+ epoch: number;
70
+ }
71
+
72
+ /**
73
+ * SFT Trainer - Rust-Native Training Engine
74
+ *
75
+ * Provides a TypeScript-friendly interface to the Rust SFT training engine.
76
+ */
77
+ export class SFTTrainer {
78
+ private engine: SftTrainingEngine;
79
+ private model: TrainableModel;
80
+ private tokenizer: Qwen3Tokenizer;
81
+ private config: SFTTrainerConfig;
82
+ private currentEpoch: number = 0;
83
+ private currentStep: number = 0;
84
+ /** Original model path (for tokenizer files when saving checkpoints) */
85
+ private originalModelPath?: string;
86
+
87
+ // TUI state
88
+ private paused: boolean = false;
89
+ private stopRequested: boolean = false;
90
+ private stdinInterface?: readline.Interface;
91
+ private logger: TrainingLogger;
92
+ private sampleDisplayMode: 'all' | 'best_worst' | 'random' = 'all';
93
+ private signalHandlersInstalled: boolean = false;
94
+
95
+ /**
96
+ * Create a new SFT trainer from a model
97
+ *
98
+ * @param model - Pre-loaded Qwen3 model
99
+ * @param tokenizer - Pre-loaded tokenizer
100
+ * @param config - Training configuration
101
+ * @param logger - Optional custom logger
102
+ */
103
+ constructor(
104
+ model: TrainableModel,
105
+ tokenizer: Qwen3Tokenizer,
106
+ config: Partial<SFTTrainerConfig> = {},
107
+ logger?: TrainingLogger,
108
+ ) {
109
+ // Auto-detect TUI mode from environment variable
110
+ const tuiModeFromEnv = process.env.MLX_TUI_MODE === '1';
111
+ if (tuiModeFromEnv && config.tuiMode === undefined) {
112
+ config.tuiMode = true;
113
+ }
114
+
115
+ this.config = mergeSFTConfig(getDefaultSFTConfig(), config);
116
+ this.model = model;
117
+ this.tokenizer = tokenizer;
118
+
119
+ // Create or use provided logger
120
+ this.logger =
121
+ logger ??
122
+ createTrainingLogger({
123
+ logConsole: !this.config.tuiMode,
124
+ logJsonl: this.config.logJsonl,
125
+ outputDir: this.config.outputDir,
126
+ runName: this.config.runName,
127
+ logInterval: this.config.loggingSteps,
128
+ });
129
+
130
+ // Convert to native config
131
+ const engineConfig: SftEngineConfig = {
132
+ learningRate: this.config.learningRate,
133
+ gradientAccumulationSteps: this.config.gradientAccumulationSteps,
134
+ gradientClipNorm: this.config.maxGradNorm,
135
+ weightDecay: this.config.weightDecay,
136
+ labelSmoothing: this.config.labelSmoothing,
137
+ gradientCheckpointing: this.config.gradientCheckpointing,
138
+ };
139
+
140
+ if (model instanceof Qwen35Model) {
141
+ this.engine = SftTrainingEngine.fromQwen35(model, engineConfig);
142
+ } else if (model instanceof Qwen35MoeModel) {
143
+ this.engine = SftTrainingEngine.fromQwen35Moe(model, engineConfig);
144
+ } else if (model instanceof Qwen3Model) {
145
+ this.engine = new SftTrainingEngine(model, engineConfig);
146
+ } else {
147
+ throw new Error(`Unsupported model type: ${(model as object).constructor?.name ?? typeof model}`);
148
+ }
149
+
150
+ // Setup stdin handler if TUI mode
151
+ if (this.config.tuiMode) {
152
+ this.setupStdinHandler();
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Setup OS signal handlers for graceful shutdown on interrupt.
158
+ * Saves an emergency checkpoint before exiting to prevent progress loss.
159
+ */
160
+ private setupSignalHandlers(): void {
161
+ if (this.signalHandlersInstalled) return;
162
+ this.signalHandlersInstalled = true;
163
+
164
+ const gracefulShutdown = async (signal: string) => {
165
+ this.logger.warn(`Received ${signal}, initiating graceful shutdown...`);
166
+ this.stopRequested = true;
167
+
168
+ try {
169
+ if (this.config.outputDir && this.currentStep > 0) {
170
+ this.logger.info(`Saving emergency checkpoint at step ${this.currentStep}...`);
171
+ await this.saveCheckpoint(`emergency-checkpoint-${this.currentStep}`);
172
+ this.logger.info('Emergency checkpoint saved.');
173
+ }
174
+ } catch (e) {
175
+ console.error('Failed to save emergency checkpoint:', e);
176
+ }
177
+
178
+ process.exit(0);
179
+ };
180
+
181
+ process.on('SIGTERM', () => {
182
+ gracefulShutdown('SIGTERM').catch(console.error);
183
+ });
184
+
185
+ process.on('SIGINT', () => {
186
+ gracefulShutdown('SIGINT').catch(console.error);
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Setup stdin handler for TUI control commands
192
+ */
193
+ private setupStdinHandler(): void {
194
+ if (!this.config.tuiMode) return;
195
+
196
+ this.stdinInterface = readline.createInterface({
197
+ input: process.stdin,
198
+ output: process.stdout,
199
+ terminal: false,
200
+ });
201
+
202
+ this.stdinInterface.on('line', (line: string) => {
203
+ const cmd = line.trim();
204
+ this.handleStdinCommand(cmd);
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Handle a command received from stdin
210
+ */
211
+ private handleStdinCommand(cmd: string): void {
212
+ switch (cmd) {
213
+ case 'PAUSE':
214
+ this.paused = true;
215
+ this.logger.paused(this.currentStep);
216
+ break;
217
+ case 'RESUME':
218
+ this.paused = false;
219
+ this.logger.resumed(this.currentStep);
220
+ break;
221
+ case 'SAVE_CHECKPOINT':
222
+ this.saveCheckpoint().catch(() => {});
223
+ break;
224
+ case 'STOP':
225
+ this.stopRequested = true;
226
+ break;
227
+ default:
228
+ // Handle SET commands (e.g., SET sample_display=best_worst)
229
+ if (cmd.startsWith('SET ')) {
230
+ const keyValue = cmd.slice(4); // Remove 'SET ' prefix
231
+ const eqIdx = keyValue.indexOf('=');
232
+ if (eqIdx > 0) {
233
+ const key = keyValue.slice(0, eqIdx);
234
+ const value = keyValue.slice(eqIdx + 1);
235
+ if (key === 'sample_display') {
236
+ if (value === 'all' || value === 'best_worst' || value === 'random') {
237
+ this.sampleDisplayMode = value;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ break;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Wait for resume if paused
248
+ */
249
+ private async waitForResume(): Promise<void> {
250
+ while (this.paused && !this.stopRequested) {
251
+ await new Promise((resolve) => setTimeout(resolve, 100));
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Create a trainer by loading a model from disk
257
+ *
258
+ * @param config - Configuration including modelPath
259
+ * @returns Promise<SFTTrainer>
260
+ */
261
+ static async create(config: Partial<SFTTrainerConfig>): Promise<SFTTrainer> {
262
+ if (!config.modelName) {
263
+ throw new Error('modelName is required when using SFTTrainer.create()');
264
+ }
265
+
266
+ // Create logger early
267
+ const logger = createTrainingLogger({
268
+ logConsole: !config.tuiMode,
269
+ logJsonl: config.logJsonl ?? true,
270
+ outputDir: config.outputDir,
271
+ runName: config.runName,
272
+ logInterval: config.loggingSteps ?? 10,
273
+ });
274
+
275
+ let modelPath = config.modelName;
276
+ let resumedState: SFTTrainingState | null = null;
277
+
278
+ // Handle checkpoint resumption
279
+ if (config.resumeFromCheckpoint) {
280
+ const checkpointPath =
281
+ config.resumeFromCheckpoint === 'latest'
282
+ ? SFTTrainer.findLatestCheckpoint(config.outputDir)
283
+ : config.resumeFromCheckpoint;
284
+
285
+ if (checkpointPath) {
286
+ const statePath = join(checkpointPath, 'training_state.json');
287
+ if (existsSync(statePath)) {
288
+ resumedState = JSON.parse(readFileSync(statePath, 'utf-8'));
289
+ logger.info(
290
+ `Resuming from checkpoint: ${checkpointPath} (step ${resumedState?.step}, epoch ${resumedState?.epoch})`,
291
+ );
292
+ }
293
+ modelPath = checkpointPath;
294
+ } else if (config.resumeFromCheckpoint === 'latest') {
295
+ logger.info('No checkpoint found, starting fresh training');
296
+ }
297
+ }
298
+
299
+ // Get model name for display
300
+ const modelName = parse(modelPath).base || 'Unknown';
301
+ logger.status('loading', `Loading ${modelName}...`);
302
+
303
+ // Load model (auto-detects architecture from config.json)
304
+ const model = await loadModel(modelPath);
305
+
306
+ const tokenizer = await Qwen3Tokenizer.fromPretrained(join(modelPath, 'tokenizer.json'));
307
+
308
+ logger.status('loading', `${modelName} loaded (${model.constructor.name})`);
309
+
310
+ // Create trainer
311
+ // @ts-expect-error
312
+ const trainer = new SFTTrainer(model, tokenizer, config, logger);
313
+ trainer.originalModelPath = config.modelName;
314
+
315
+ // Restore training state if resuming
316
+ if (resumedState) {
317
+ trainer.currentStep = resumedState.step;
318
+ trainer.currentEpoch = resumedState.epoch;
319
+ // Also restore engine state to sync step/epoch accounting
320
+ trainer.engine.restoreState(resumedState.step, resumedState.epoch);
321
+ }
322
+
323
+ return trainer;
324
+ }
325
+
326
+ /**
327
+ * Find the latest checkpoint in the output directory
328
+ */
329
+ static findLatestCheckpoint(outputDir?: string): string | null {
330
+ if (!outputDir || !existsSync(outputDir)) {
331
+ return null;
332
+ }
333
+
334
+ const entries = readdirSync(outputDir, { withFileTypes: true });
335
+ const checkpoints = entries
336
+ .filter((e) => e.isDirectory() && e.name.startsWith('checkpoint-'))
337
+ .map((e) => ({
338
+ name: e.name,
339
+ step: parseInt(e.name.replace('checkpoint-', ''), 10),
340
+ path: join(outputDir, e.name),
341
+ }))
342
+ .filter((c) => !isNaN(c.step))
343
+ .sort((a, b) => b.step - a.step);
344
+
345
+ return checkpoints.length > 0 ? checkpoints[0].path : null;
346
+ }
347
+
348
+ /**
349
+ * Run a single training step
350
+ *
351
+ * @param batch - Tokenized batch with input_ids and labels
352
+ * @returns Training step metrics
353
+ */
354
+ async trainStep(batch: SFTBatch): Promise<SFTTrainStepResult> {
355
+ // Convert Int32Array to MxArray
356
+ const inputIds = MxArray.fromInt32(batch.inputIds, BigInt64Array.from(batch.shape.map(BigInt)));
357
+ const labels = MxArray.fromInt32(batch.labels, BigInt64Array.from(batch.shape.map(BigInt)));
358
+
359
+ // Call native engine
360
+ const metrics = await this.engine.trainStep(inputIds, labels);
361
+
362
+ // Sync step with engine when gradients are applied (fixes gradient accumulation accounting)
363
+ // Note: metrics.step is i64 from Rust; JS number may lose precision beyond 2^53-1,
364
+ // but such step counts are unrealistic for any practical training run.
365
+ if (metrics.gradientsApplied) {
366
+ this.currentStep = Number(metrics.step);
367
+ }
368
+
369
+ return {
370
+ metrics,
371
+ epoch: this.currentEpoch,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Run a full training loop over a dataset
377
+ *
378
+ * @param dataset - SFT dataset or path to JSONL file
379
+ */
380
+ async train(dataset: SFTDataset | string): Promise<void> {
381
+ // Load dataset if path provided
382
+ let sftDataset: SFTDataset;
383
+ if (typeof dataset === 'string') {
384
+ sftDataset = await loadSFTDataset(dataset, this.tokenizer, {
385
+ maxSeqLength: this.config.maxSeqLength,
386
+ completionOnly: this.config.completionOnly,
387
+ seed: this.config.seed,
388
+ limit: this.config.maxTrainSamples > 0 ? this.config.maxTrainSamples : undefined,
389
+ });
390
+ } else {
391
+ sftDataset = dataset;
392
+ }
393
+
394
+ if (sftDataset.length === 0) {
395
+ return;
396
+ }
397
+
398
+ // Setup signal handlers for crash recovery
399
+ this.setupSignalHandlers();
400
+
401
+ const numEpochs = this.config.numEpochs;
402
+ const batchSize = this.config.batchSize;
403
+ const saveInterval = this.config.saveSteps;
404
+
405
+ // Create output directory
406
+ if (this.config.outputDir && !existsSync(this.config.outputDir)) {
407
+ mkdirSync(this.config.outputDir, { recursive: true });
408
+ }
409
+
410
+ // Calculate steps per epoch (in batches)
411
+ const stepsPerEpoch = sftDataset.numBatches(batchSize);
412
+
413
+ // Compute resume position (all logic centralized in Rust)
414
+ const resumePos = this.engine.computeResumePosition(stepsPerEpoch);
415
+ const effectiveStartEpoch = resumePos.startEpoch;
416
+ const effectiveStartBatchIdx = resumePos.startBatchIdx;
417
+
418
+ // Get model name
419
+ const modelName =
420
+ (this.originalModelPath ? parse(this.originalModelPath).base : null) ??
421
+ (this.config.modelName ? parse(this.config.modelName).base : null) ??
422
+ 'Unknown';
423
+
424
+ // Log training start
425
+ this.logger.init(
426
+ modelName,
427
+ {
428
+ trainingType: 'sft',
429
+ numEpochs,
430
+ batchSize,
431
+ groupSize: 1, // SFT doesn't use groups
432
+ learningRate: this.config.learningRate,
433
+ },
434
+ sftDataset.length,
435
+ );
436
+
437
+ if (this.currentStep > 0) {
438
+ if (resumePos.isEpochBoundary) {
439
+ this.logger.info(`Resuming at epoch boundary, advancing to epoch ${effectiveStartEpoch + 1}`);
440
+ } else {
441
+ this.logger.info(
442
+ `Resuming from step ${this.currentStep} (epoch ${effectiveStartEpoch + 1}, batch ${effectiveStartBatchIdx + 1}/${stepsPerEpoch})`,
443
+ );
444
+ }
445
+ }
446
+
447
+ for (let epoch = effectiveStartEpoch; epoch < numEpochs; epoch++) {
448
+ if (this.stopRequested) break;
449
+
450
+ this.currentEpoch = epoch;
451
+ this.engine.startEpoch(epoch);
452
+ const epochStartTime = Date.now();
453
+
454
+ // Use epoch-based shuffle (deterministic, reproducible via seed + epoch)
455
+ sftDataset.shuffleForEpoch(epoch);
456
+
457
+ // Log epoch start
458
+ this.logger.epochStart(epoch, numEpochs, stepsPerEpoch);
459
+
460
+ // Determine batch start position for this epoch
461
+ const batchStart = epoch === effectiveStartEpoch ? effectiveStartBatchIdx : 0;
462
+
463
+ // Iterate through batches
464
+ let batchIdx = 0;
465
+ for await (const batch of sftDataset.batches(batchSize)) {
466
+ if (this.stopRequested) break;
467
+
468
+ // Skip batches if resuming mid-epoch
469
+ if (batchIdx < batchStart) {
470
+ batchIdx++;
471
+ continue;
472
+ }
473
+
474
+ // Wait if paused
475
+ if (this.paused) {
476
+ await this.waitForResume();
477
+ if (this.stopRequested) break;
478
+ }
479
+
480
+ // Run training step
481
+ const { metrics } = await this.trainStep(batch);
482
+
483
+ // Log step metrics (only when gradients are applied to avoid duplicate logs during accumulation)
484
+ if (metrics.gradientsApplied) {
485
+ this.logger.step(
486
+ {
487
+ step: this.currentStep,
488
+ loss: metrics.loss,
489
+ totalTokens: metrics.totalTokens,
490
+ // SFT-specific metrics (no reward/advantage!)
491
+ perplexity: Math.exp(metrics.loss),
492
+ // Token accuracy is not currently tracked in the SFT engine
493
+ // Could be added later if the Rust engine exposes it
494
+ trainingTimeMs: metrics.trainingTimeMs,
495
+ },
496
+ batchIdx,
497
+ stepsPerEpoch,
498
+ );
499
+
500
+ // Save checkpoint periodically
501
+ if (this.config.outputDir && this.currentStep > 0 && this.currentStep % saveInterval === 0) {
502
+ const path = await this.saveCheckpoint();
503
+ if (path) {
504
+ this.logger.checkpoint(path, this.currentStep);
505
+ }
506
+ }
507
+ }
508
+
509
+ // Check for emergency checkpoint
510
+ if (this.config.outputDir && this.engine.needsEmergencySave()) {
511
+ this.logger.warn(`[EMERGENCY] Saving emergency checkpoint at step ${this.currentStep} due to NaN gradients`);
512
+ await this.saveCheckpoint(`emergency-checkpoint-${this.currentStep}`);
513
+ this.engine.clearEmergencySave();
514
+ }
515
+
516
+ batchIdx++;
517
+ }
518
+
519
+ // Flush any remaining accumulated gradients (TRL parity)
520
+ const flushed = this.engine.flushGradients();
521
+ if (flushed) {
522
+ this.currentStep = this.engine.getStep();
523
+
524
+ // Check if flush step aligns with save interval
525
+ if (this.config.outputDir && this.currentStep > 0 && this.currentStep % saveInterval === 0) {
526
+ const path = await this.saveCheckpoint();
527
+ if (path) {
528
+ this.logger.checkpoint(path, this.currentStep);
529
+ }
530
+ }
531
+ }
532
+
533
+ const epochEndTime = Date.now();
534
+ const epochTimeSecs = (epochEndTime - epochStartTime) / 1000;
535
+ this.engine.endEpoch(epochTimeSecs);
536
+
537
+ this.logger.epochEnd(epoch, numEpochs, epochTimeSecs);
538
+ }
539
+
540
+ // Save final checkpoint
541
+ if (this.config.outputDir && !this.stopRequested) {
542
+ const path = await this.saveCheckpoint('final');
543
+ if (path) {
544
+ this.logger.checkpoint(path, this.currentStep);
545
+ }
546
+ }
547
+
548
+ // Log completion
549
+ this.logger.complete(this.currentStep);
550
+
551
+ // Cleanup
552
+ if (this.stdinInterface) {
553
+ this.stdinInterface.close();
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Save a checkpoint with model weights and training state
559
+ *
560
+ * @param name - Checkpoint name (default: "checkpoint-{step}")
561
+ * @returns Path to saved checkpoint
562
+ */
563
+ async saveCheckpoint(name?: string): Promise<string> {
564
+ const checkpointName = name ?? `checkpoint-${this.currentStep}`;
565
+ const outputDir = this.config.outputDir ?? './outputs';
566
+ const checkpointPath = join(outputDir, checkpointName);
567
+
568
+ // Create checkpoint directory
569
+ if (!existsSync(checkpointPath)) {
570
+ mkdirSync(checkpointPath, { recursive: true });
571
+ }
572
+
573
+ // Save training state
574
+ const state: SFTTrainingState = {
575
+ step: this.currentStep,
576
+ epoch: this.currentEpoch,
577
+ timestamp: new Date().toISOString(),
578
+ trainerType: 'sft',
579
+ };
580
+ const statePath = join(checkpointPath, 'training_state.json');
581
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
582
+
583
+ // Save model weights
584
+ await this.model.saveModel(checkpointPath);
585
+
586
+ // Copy tokenizer files
587
+ const tokenizerSource = this.originalModelPath ?? this.config.modelName;
588
+ if (tokenizerSource) {
589
+ const tokenizerFiles = ['tokenizer.json', 'tokenizer_config.json', 'vocab.json', 'merges.txt'];
590
+ for (const file of tokenizerFiles) {
591
+ const srcPath = join(tokenizerSource, file);
592
+ const destPath = join(checkpointPath, file);
593
+ if (existsSync(srcPath) && !existsSync(destPath)) {
594
+ copyFileSync(srcPath, destPath);
595
+ }
596
+ }
597
+ }
598
+
599
+ this.logger.info(`Checkpoint saved: ${checkpointPath}`);
600
+
601
+ // Clean up old checkpoints
602
+ const maxCheckpoints = this.config.maxCheckpoints;
603
+ if (maxCheckpoints > 0) {
604
+ this.cleanupOldCheckpoints(outputDir, maxCheckpoints);
605
+ }
606
+
607
+ return checkpointPath;
608
+ }
609
+
610
+ /**
611
+ * Remove old checkpoints, keeping only the most recent ones
612
+ */
613
+ private cleanupOldCheckpoints(outputDir: string, maxToKeep: number): void {
614
+ try {
615
+ const entries = readdirSync(outputDir, { withFileTypes: true });
616
+
617
+ const checkpoints: { name: string; step: number }[] = [];
618
+ for (const entry of entries) {
619
+ if (!entry.isDirectory()) continue;
620
+ if (entry.name === 'final' || entry.name.startsWith('emergency-')) continue;
621
+
622
+ const match = entry.name.match(/^checkpoint-(\d+)$/);
623
+ if (match) {
624
+ checkpoints.push({
625
+ name: entry.name,
626
+ step: parseInt(match[1], 10),
627
+ });
628
+ }
629
+ }
630
+
631
+ checkpoints.sort((a, b) => b.step - a.step);
632
+
633
+ if (checkpoints.length > maxToKeep) {
634
+ const toRemove = checkpoints.slice(maxToKeep);
635
+ for (const checkpoint of toRemove) {
636
+ const checkpointPath = join(outputDir, checkpoint.name);
637
+ rmSync(checkpointPath, { recursive: true, force: true });
638
+ this.logger.debug(`Removed old checkpoint: ${checkpoint.name}`);
639
+ }
640
+ }
641
+ } catch (error) {
642
+ this.logger.warn(`Failed to cleanup old checkpoints: ${error as Error}`);
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Get current training step
648
+ */
649
+ get step(): number {
650
+ return this.engine.getStep();
651
+ }
652
+
653
+ /**
654
+ * Get current epoch
655
+ */
656
+ get epoch(): number {
657
+ return this.engine.getEpoch();
658
+ }
659
+
660
+ /**
661
+ * Get the underlying model for inference
662
+ */
663
+ getModel(): TrainableModel {
664
+ if (this.model instanceof Qwen35MoeModel) {
665
+ return this.engine.getQwen35MoeModel();
666
+ }
667
+ if (this.model instanceof Qwen35Model) {
668
+ return this.engine.getQwen35Model();
669
+ }
670
+ return this.engine.getModel();
671
+ }
672
+
673
+ /**
674
+ * Get the tokenizer
675
+ */
676
+ getTokenizer(): Qwen3Tokenizer {
677
+ return this.tokenizer;
678
+ }
679
+ }