@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,830 @@
1
+ /**
2
+ * Unified Training Logger
3
+ *
4
+ * A high-level logger abstraction that handles TUI/console mode automatically.
5
+ * Eliminates verbose conditional checks throughout the codebase.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const logger = createTrainingLogger();
10
+ *
11
+ * logger.info('Loading model...'); // Console OR TUI log
12
+ * logger.status('loading', 'Loading...'); // TUI header update
13
+ * logger.step(metrics); // Training step
14
+ * logger.checkpoint(path, step); // Checkpoint saved
15
+ * ```
16
+ */
17
+
18
+ import { appendFileSync, mkdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ // ============================================================================
22
+ // Types
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Training metrics from a training step
27
+ *
28
+ * Supports both SFT and GRPO training:
29
+ * - SFT: loss, perplexity, tokenAccuracy (no reward/advantage)
30
+ * - GRPO: loss, meanReward, stdReward, stdAdvantage
31
+ */
32
+ export interface TrainingMetrics {
33
+ step: number;
34
+ loss: number;
35
+ totalTokens: number;
36
+ // GRPO-specific (optional for SFT)
37
+ meanReward?: number;
38
+ stdReward?: number;
39
+ meanAdvantage?: number;
40
+ /** Std of advantages - indicates reward variance within groups */
41
+ stdAdvantage?: number;
42
+ // SFT-specific (optional for GRPO)
43
+ perplexity?: number;
44
+ tokenAccuracy?: number;
45
+ // Timing (optional)
46
+ generationTimeMs?: number;
47
+ trainingTimeMs?: number;
48
+ // Memory (optional)
49
+ peakMemoryMb?: number;
50
+ activeMemoryMb?: number;
51
+ }
52
+
53
+ /**
54
+ * Generation sample for TUI display
55
+ */
56
+ export interface GenerationSample {
57
+ index: number;
58
+ prompt: string;
59
+ completion: string;
60
+ reward: number;
61
+ tokens: number;
62
+ rewardDetails?: Record<string, number>;
63
+ }
64
+
65
+ /**
66
+ * Training configuration fields for logging
67
+ */
68
+ export interface TrainingConfigFields {
69
+ numEpochs: number;
70
+ batchSize: number;
71
+ groupSize: number;
72
+ learningRate: number;
73
+ /** Training type: "sft" or "grpo" (default: "grpo") */
74
+ trainingType?: 'sft' | 'grpo';
75
+ [key: string]: unknown;
76
+ }
77
+
78
+ /**
79
+ * Configuration for the training logger
80
+ */
81
+ export interface TrainingLoggerConfig {
82
+ /** Enable TUI mode (JSONL to stdout) */
83
+ tuiMode: boolean;
84
+ /** Enable console logging (non-TUI) */
85
+ logConsole: boolean;
86
+ /** Enable JSONL file logging */
87
+ logJsonl: boolean;
88
+ /** Output directory for JSONL files */
89
+ outputDir?: string;
90
+ /** Run name for log files */
91
+ runName?: string;
92
+ /** Logging frequency (every N steps) */
93
+ logInterval: number;
94
+ }
95
+
96
+ /**
97
+ * TUI message types for structured stdout output
98
+ */
99
+ export type TuiMessage =
100
+ | { type: 'init'; model: string; config: Record<string, unknown> }
101
+ | { type: 'epoch_start'; epoch: number; totalEpochs: number; numBatches: number }
102
+ | {
103
+ type: 'step';
104
+ step: number;
105
+ loss: number;
106
+ totalTokens: number;
107
+ // GRPO-specific (optional for SFT)
108
+ meanReward?: number;
109
+ stdReward?: number;
110
+ stdAdvantage?: number;
111
+ // SFT-specific (optional for GRPO)
112
+ perplexity?: number;
113
+ tokenAccuracy?: number;
114
+ // Timing (optional)
115
+ generationTimeMs?: number;
116
+ trainingTimeMs?: number;
117
+ // Memory (optional)
118
+ peakMemoryMb?: number;
119
+ activeMemoryMb?: number;
120
+ }
121
+ | {
122
+ type: 'generation';
123
+ index: number;
124
+ prompt: string;
125
+ completion: string;
126
+ reward: number;
127
+ tokens: number;
128
+ rewardDetails?: Record<string, number>;
129
+ }
130
+ | { type: 'checkpoint'; path: string; step: number }
131
+ | { type: 'epoch_end'; epoch: number; avgLoss: number; avgReward: number; epochTimeSecs: number }
132
+ | { type: 'complete'; totalSteps: number; totalTimeSecs: number }
133
+ | { type: 'log'; level: 'info' | 'warn' | 'error' | 'debug'; message: string }
134
+ | { type: 'paused'; step: number }
135
+ | { type: 'resumed'; step: number }
136
+ | { type: 'status'; phase: string; message: string }
137
+ | { type: 'database_path'; path: string; runId: string; runName?: string }
138
+ | {
139
+ type: 'prompt';
140
+ id: string;
141
+ message: string;
142
+ choices: PromptChoice[];
143
+ default?: number[];
144
+ multiSelect: boolean;
145
+ }
146
+ | {
147
+ type: 'resume_state';
148
+ step: number;
149
+ epoch: number;
150
+ totalEpochs: number;
151
+ stepInEpoch: number;
152
+ totalStepsInEpoch: number;
153
+ metricsHistory: ResumeMetric[];
154
+ aggregates: ResumeAggregates;
155
+ };
156
+
157
+ /**
158
+ * Choice for interactive TUI prompt
159
+ */
160
+ export interface PromptChoice {
161
+ /** Value to return when selected */
162
+ value: string;
163
+ /** Display label */
164
+ label: string;
165
+ /** Optional description */
166
+ description?: string;
167
+ }
168
+
169
+ /**
170
+ * Options for TUI prompts
171
+ */
172
+ export interface PromptOptions {
173
+ /** Default selection index (single) or indices (multi-select) */
174
+ default?: number | number[];
175
+ /** Enable multi-select mode (checkbox style) */
176
+ multiSelect?: boolean;
177
+ }
178
+
179
+ /**
180
+ * Historical metric for sparkline restoration
181
+ */
182
+ export interface ResumeMetric {
183
+ step: number;
184
+ loss: number;
185
+ meanReward: number;
186
+ /** Std of advantages - indicates reward variance within groups */
187
+ stdAdvantage: number;
188
+ perplexity?: number;
189
+ tokenAccuracy?: number;
190
+ /** Time for generation phase (milliseconds) */
191
+ generationTimeMs?: number;
192
+ /** Time for training phase (milliseconds) */
193
+ trainingTimeMs?: number;
194
+ }
195
+
196
+ /**
197
+ * Aggregate statistics for resume state
198
+ */
199
+ export interface ResumeAggregates {
200
+ bestReward: number;
201
+ avgReward: number;
202
+ rewardCount: number;
203
+ bestLoss: number;
204
+ avgLoss: number;
205
+ lossCount: number;
206
+ totalTokens: number;
207
+ /** Average generation time (milliseconds) */
208
+ avgGenerationTimeMs: number;
209
+ /** Average training time (milliseconds) */
210
+ avgTrainingTimeMs: number;
211
+ }
212
+
213
+ /**
214
+ * Resume state data sent when resuming from checkpoint
215
+ */
216
+ export interface ResumeState {
217
+ step: number;
218
+ epoch: number;
219
+ totalEpochs: number;
220
+ /** Current batch within epoch */
221
+ stepInEpoch: number;
222
+ /** Total batches per epoch */
223
+ totalStepsInEpoch: number;
224
+ metricsHistory: ResumeMetric[];
225
+ aggregates: ResumeAggregates;
226
+ }
227
+
228
+ /**
229
+ * JSONL log event types
230
+ */
231
+ export type LogEvent =
232
+ | { event: 'training_start'; timestamp: string; config: TrainingLoggerConfig }
233
+ | { event: 'training_config'; num_examples: number; config: Record<string, unknown>; timestamp: string }
234
+ | {
235
+ event: 'step';
236
+ step: number;
237
+ loss: number;
238
+ mean_reward: number;
239
+ std_reward: number;
240
+ mean_advantage: number;
241
+ total_tokens: number;
242
+ timestamp: string;
243
+ }
244
+ | {
245
+ event: 'epoch';
246
+ epoch: number;
247
+ avg_loss: number;
248
+ avg_reward: number;
249
+ avg_advantage: number;
250
+ total_tokens: number;
251
+ timestamp: string;
252
+ }
253
+ | { event: 'epoch_start'; epoch: number; num_batches: number; timestamp: string }
254
+ | { event: 'training_complete'; final_step: number; total_time_ms: number; timestamp: string }
255
+ | { event: 'checkpoint'; step: number; path: string; timestamp: string };
256
+
257
+ // ============================================================================
258
+ // Metrics Aggregator
259
+ // ============================================================================
260
+
261
+ /**
262
+ * Aggregator for computing running statistics over an epoch
263
+ */
264
+ class MetricsAggregator {
265
+ private values: number[] = [];
266
+
267
+ add(value: number): void {
268
+ this.values.push(value);
269
+ }
270
+
271
+ mean(): number {
272
+ if (this.values.length === 0) return 0;
273
+ return this.values.reduce((a, b) => a + b, 0) / this.values.length;
274
+ }
275
+
276
+ sum(): number {
277
+ return this.values.reduce((a, b) => a + b, 0);
278
+ }
279
+
280
+ reset(): void {
281
+ this.values = [];
282
+ }
283
+ }
284
+
285
+ // ============================================================================
286
+ // Training Logger
287
+ // ============================================================================
288
+
289
+ /**
290
+ * Unified training logger that handles TUI/console mode automatically
291
+ */
292
+ export class TrainingLogger {
293
+ private config: TrainingLoggerConfig;
294
+ private jsonlPath?: string;
295
+ private startTime: number;
296
+ private lastLogTime: number;
297
+
298
+ // Epoch-level aggregators
299
+ private epochLoss = new MetricsAggregator();
300
+ private epochReward = new MetricsAggregator();
301
+ private epochAdvantage = new MetricsAggregator();
302
+ private epochTokens = new MetricsAggregator();
303
+
304
+ constructor(config: Partial<TrainingLoggerConfig> = {}) {
305
+ // TUI mode is ONLY enabled via environment variable (set by mlx-tui)
306
+ // This ensures users don't accidentally miss output in CLI mode
307
+ const tuiMode = process.env.MLX_TUI_MODE === '1';
308
+
309
+ this.config = {
310
+ tuiMode,
311
+ logConsole: config.logConsole ?? true,
312
+ logJsonl: config.logJsonl ?? false,
313
+ logInterval: config.logInterval ?? 1,
314
+ outputDir: config.outputDir,
315
+ runName: config.runName,
316
+ };
317
+ this.startTime = Date.now();
318
+ this.lastLogTime = this.startTime;
319
+
320
+ this.setupJsonl();
321
+ }
322
+
323
+ private setupJsonl(): void {
324
+ if (!this.config.logJsonl || this.config.tuiMode) return;
325
+ if (!this.config.outputDir) return;
326
+
327
+ try {
328
+ mkdirSync(this.config.outputDir, { recursive: true });
329
+ const runName = this.config.runName || 'grpo-training';
330
+ this.jsonlPath = join(this.config.outputDir, `${runName}.jsonl`);
331
+
332
+ this.writeJsonl({
333
+ event: 'training_start',
334
+ timestamp: new Date().toISOString(),
335
+ config: this.config,
336
+ });
337
+ } catch {
338
+ // Silently ignore - logging shouldn't crash training
339
+ }
340
+ }
341
+
342
+ // ==========================================================================
343
+ // Core Logging Methods
344
+ // ==========================================================================
345
+
346
+ /** Log info message */
347
+ info(message: string): void {
348
+ if (this.config.tuiMode) {
349
+ this.writeTui({ type: 'log', level: 'info', message });
350
+ } else if (this.config.logConsole) {
351
+ console.log(message);
352
+ }
353
+ }
354
+
355
+ /** Log warning message */
356
+ warn(message: string): void {
357
+ if (this.config.tuiMode) {
358
+ this.writeTui({ type: 'log', level: 'warn', message });
359
+ } else {
360
+ console.warn(message);
361
+ }
362
+ }
363
+
364
+ /** Log error message */
365
+ error(message: string): void {
366
+ if (this.config.tuiMode) {
367
+ this.writeTui({ type: 'log', level: 'error', message });
368
+ } else {
369
+ console.error(message);
370
+ }
371
+ }
372
+
373
+ /** Log debug message (only in verbose mode) */
374
+ debug(message: string): void {
375
+ if (this.config.tuiMode) {
376
+ this.writeTui({ type: 'log', level: 'debug', message });
377
+ } else if (this.config.logConsole && process.env.DEBUG) {
378
+ console.log(`[DEBUG] ${message}`);
379
+ }
380
+ }
381
+
382
+ /** Update status (updates TUI header, shows in console) */
383
+ status(phase: string, message: string): void {
384
+ if (this.config.tuiMode) {
385
+ this.writeTui({ type: 'status', phase, message });
386
+ } else if (this.config.logConsole) {
387
+ console.log(message);
388
+ }
389
+ }
390
+
391
+ /** Print decorative banner (console only, suppressed in TUI mode) */
392
+ banner(...lines: string[]): void {
393
+ if (this.config.tuiMode) return;
394
+ if (!this.config.logConsole) return;
395
+ for (const line of lines) {
396
+ console.log(line);
397
+ }
398
+ }
399
+
400
+ // ==========================================================================
401
+ // Training Event Methods
402
+ // ==========================================================================
403
+
404
+ /** Log training initialization */
405
+ init(model: string, config: TrainingConfigFields, numExamples?: number): void {
406
+ const trainingType = config.trainingType ?? 'grpo';
407
+
408
+ if (this.config.tuiMode) {
409
+ this.writeTui({ type: 'init', model, config: { ...config, trainingType } });
410
+ } else if (this.config.logConsole) {
411
+ const trainingLabel = trainingType === 'sft' ? 'SFT' : 'GRPO';
412
+ console.log(`\n Starting ${trainingLabel} training`);
413
+ if (numExamples) console.log(` Examples: ${numExamples}`);
414
+ console.log(` Epochs: ${config.numEpochs}`);
415
+ console.log(` Batch size: ${config.batchSize}`);
416
+ if (trainingType !== 'sft') {
417
+ console.log(` Group size: ${config.groupSize}`);
418
+ }
419
+ console.log(` Learning rate: ${config.learningRate}`);
420
+ }
421
+
422
+ if (this.jsonlPath && numExamples !== undefined) {
423
+ this.writeJsonl({
424
+ event: 'training_config',
425
+ num_examples: numExamples,
426
+ config: { ...config, trainingType },
427
+ timestamp: new Date().toISOString(),
428
+ });
429
+ }
430
+ }
431
+
432
+ /** Log epoch start */
433
+ epochStart(epoch: number, totalEpochs: number, numBatches: number): void {
434
+ if (this.config.tuiMode) {
435
+ this.writeTui({
436
+ type: 'epoch_start',
437
+ epoch: epoch + 1,
438
+ totalEpochs,
439
+ numBatches,
440
+ });
441
+ } else if (this.config.logConsole) {
442
+ console.log(`\n=== Epoch ${epoch + 1}/${totalEpochs} (${numBatches} batches) ===`);
443
+ }
444
+
445
+ if (this.jsonlPath) {
446
+ this.writeJsonl({
447
+ event: 'epoch_start',
448
+ epoch: epoch + 1,
449
+ num_batches: numBatches,
450
+ timestamp: new Date().toISOString(),
451
+ });
452
+ }
453
+ }
454
+
455
+ /** Log training step */
456
+ step(metrics: TrainingMetrics, batchIdx?: number, numBatches?: number): void {
457
+ // Aggregate for epoch (use available metrics)
458
+ this.epochLoss.add(metrics.loss);
459
+ if (metrics.meanReward !== undefined) {
460
+ this.epochReward.add(metrics.meanReward);
461
+ }
462
+ if (metrics.meanAdvantage !== undefined) {
463
+ this.epochAdvantage.add(metrics.meanAdvantage);
464
+ }
465
+ this.epochTokens.add(metrics.totalTokens);
466
+
467
+ if (this.config.tuiMode) {
468
+ // Build step message with only available fields (no fake values!)
469
+ const stepMsg: TuiMessage & { type: 'step' } = {
470
+ type: 'step',
471
+ step: metrics.step,
472
+ loss: metrics.loss,
473
+ totalTokens: metrics.totalTokens,
474
+ };
475
+
476
+ // Add GRPO-specific fields if present
477
+ if (metrics.meanReward !== undefined) {
478
+ stepMsg.meanReward = metrics.meanReward;
479
+ }
480
+ if (metrics.stdReward !== undefined) {
481
+ stepMsg.stdReward = metrics.stdReward;
482
+ }
483
+ if (metrics.stdAdvantage !== undefined) {
484
+ stepMsg.stdAdvantage = metrics.stdAdvantage;
485
+ }
486
+
487
+ // Add SFT-specific fields if present
488
+ if (metrics.perplexity !== undefined) {
489
+ stepMsg.perplexity = metrics.perplexity;
490
+ }
491
+ if (metrics.tokenAccuracy !== undefined) {
492
+ stepMsg.tokenAccuracy = metrics.tokenAccuracy;
493
+ }
494
+
495
+ // Add timing if present
496
+ if (metrics.generationTimeMs !== undefined) {
497
+ stepMsg.generationTimeMs = metrics.generationTimeMs;
498
+ }
499
+ if (metrics.trainingTimeMs !== undefined) {
500
+ stepMsg.trainingTimeMs = metrics.trainingTimeMs;
501
+ }
502
+
503
+ // Add memory if present
504
+ if (metrics.peakMemoryMb !== undefined) {
505
+ stepMsg.peakMemoryMb = metrics.peakMemoryMb;
506
+ }
507
+ if (metrics.activeMemoryMb !== undefined) {
508
+ stepMsg.activeMemoryMb = metrics.activeMemoryMb;
509
+ }
510
+
511
+ this.writeTui(stepMsg);
512
+ } else if (this.config.logConsole && metrics.step % this.config.logInterval === 0) {
513
+ const now = Date.now();
514
+ const stepTime = (now - this.lastLogTime) / this.config.logInterval;
515
+ this.lastLogTime = now;
516
+
517
+ const batchInfo =
518
+ batchIdx !== undefined && numBatches !== undefined ? ` | Batch ${batchIdx + 1}/${numBatches}` : '';
519
+
520
+ // Build log message based on available metrics (SFT vs GRPO)
521
+ let logMsg = `Step ${metrics.step}${batchInfo} | Loss: ${metrics.loss.toFixed(4)}`;
522
+
523
+ if (metrics.perplexity !== undefined) {
524
+ // SFT format
525
+ logMsg += ` | Perplexity: ${metrics.perplexity.toFixed(2)}`;
526
+ }
527
+ if (metrics.tokenAccuracy !== undefined) {
528
+ logMsg += ` | Acc: ${(metrics.tokenAccuracy * 100).toFixed(1)}%`;
529
+ }
530
+ if (metrics.meanReward !== undefined) {
531
+ // GRPO format
532
+ logMsg += ` | Reward: ${metrics.meanReward.toFixed(4)}`;
533
+ }
534
+ if (metrics.meanAdvantage !== undefined) {
535
+ logMsg += ` | Adv: ${metrics.meanAdvantage.toFixed(4)}`;
536
+ }
537
+
538
+ logMsg += ` | Tokens: ${metrics.totalTokens} | Time: ${stepTime.toFixed(0)}ms/step`;
539
+ console.log(logMsg);
540
+ }
541
+
542
+ if (this.jsonlPath && metrics.step % this.config.logInterval === 0) {
543
+ this.writeJsonl({
544
+ event: 'step',
545
+ step: metrics.step,
546
+ loss: metrics.loss,
547
+ mean_reward: metrics.meanReward ?? 0,
548
+ std_reward: metrics.stdReward ?? 0,
549
+ mean_advantage: metrics.meanAdvantage ?? 0,
550
+ total_tokens: metrics.totalTokens,
551
+ timestamp: new Date().toISOString(),
552
+ });
553
+ }
554
+ }
555
+
556
+ /** Log epoch end/summary */
557
+ epochEnd(epoch: number, totalEpochs: number, epochTimeSecs?: number): void {
558
+ const avgLoss = this.epochLoss.mean();
559
+ const avgReward = this.epochReward.mean();
560
+ const avgAdvantage = this.epochAdvantage.mean();
561
+ const totalTokens = this.epochTokens.sum();
562
+
563
+ if (this.config.tuiMode) {
564
+ this.writeTui({
565
+ type: 'epoch_end',
566
+ epoch: epoch + 1,
567
+ avgLoss,
568
+ avgReward,
569
+ epochTimeSecs: epochTimeSecs ?? 0,
570
+ });
571
+ } else if (this.config.logConsole) {
572
+ console.log(
573
+ `\nEpoch ${epoch + 1}/${totalEpochs} Summary | ` +
574
+ `Avg Loss: ${avgLoss.toFixed(4)} | ` +
575
+ `Avg Reward: ${avgReward.toFixed(4)} | ` +
576
+ `Avg Advantage: ${avgAdvantage.toFixed(4)} | ` +
577
+ `Total Tokens: ${totalTokens.toFixed(0)}`,
578
+ );
579
+ }
580
+
581
+ if (this.jsonlPath) {
582
+ this.writeJsonl({
583
+ event: 'epoch',
584
+ epoch: epoch + 1,
585
+ avg_loss: avgLoss,
586
+ avg_reward: avgReward,
587
+ avg_advantage: avgAdvantage,
588
+ total_tokens: totalTokens,
589
+ timestamp: new Date().toISOString(),
590
+ });
591
+ }
592
+
593
+ // Reset aggregators
594
+ this.epochLoss.reset();
595
+ this.epochReward.reset();
596
+ this.epochAdvantage.reset();
597
+ this.epochTokens.reset();
598
+ }
599
+
600
+ /** Log checkpoint saved */
601
+ checkpoint(path: string, step: number): void {
602
+ if (this.config.tuiMode) {
603
+ this.writeTui({ type: 'checkpoint', path, step });
604
+ } else if (this.config.logConsole) {
605
+ console.log(`💾 Checkpoint saved: ${path}`);
606
+ }
607
+
608
+ if (this.jsonlPath) {
609
+ this.writeJsonl({
610
+ event: 'checkpoint',
611
+ step,
612
+ path,
613
+ timestamp: new Date().toISOString(),
614
+ });
615
+ }
616
+ }
617
+
618
+ /** Log training completion */
619
+ complete(totalSteps: number): void {
620
+ const totalTime = Date.now() - this.startTime;
621
+ const totalMinutes = totalTime / 60000;
622
+ const totalTimeSecs = totalTime / 1000;
623
+
624
+ if (this.config.tuiMode) {
625
+ this.writeTui({ type: 'complete', totalSteps, totalTimeSecs });
626
+ } else if (this.config.logConsole) {
627
+ console.log(`\n✓ Training complete! Final step: ${totalSteps} | Total time: ${totalMinutes.toFixed(2)} minutes`);
628
+ }
629
+
630
+ if (this.jsonlPath) {
631
+ this.writeJsonl({
632
+ event: 'training_complete',
633
+ final_step: totalSteps,
634
+ total_time_ms: totalTime,
635
+ timestamp: new Date().toISOString(),
636
+ });
637
+ }
638
+ }
639
+
640
+ /** Log generation sample (TUI only) */
641
+ generation(sample: GenerationSample): void {
642
+ if (!this.config.tuiMode) return;
643
+ this.writeTui({
644
+ type: 'generation',
645
+ index: sample.index,
646
+ prompt: sample.prompt,
647
+ completion: sample.completion,
648
+ reward: sample.reward,
649
+ tokens: sample.tokens,
650
+ rewardDetails: sample.rewardDetails,
651
+ });
652
+ }
653
+
654
+ /** Log training paused (TUI only) */
655
+ paused(step: number): void {
656
+ if (!this.config.tuiMode) return;
657
+ this.writeTui({ type: 'paused', step });
658
+ }
659
+
660
+ /** Log training resumed (TUI only) */
661
+ resumed(step: number): void {
662
+ if (!this.config.tuiMode) return;
663
+ this.writeTui({ type: 'resumed', step });
664
+ }
665
+
666
+ /** Log database path for TUI DB tab */
667
+ databasePath(path: string, runId: string, runName?: string): void {
668
+ if (!this.config.tuiMode) return;
669
+ this.writeTui({ type: 'database_path', path, runId, runName });
670
+ }
671
+
672
+ /**
673
+ * Send resume state to TUI for sparkline and aggregate restoration.
674
+ * Called when resuming from checkpoint to restore TUI history.
675
+ */
676
+ resumeState(state: ResumeState): void {
677
+ if (!this.config.tuiMode) return;
678
+ this.writeTui({
679
+ type: 'resume_state',
680
+ step: state.step,
681
+ epoch: state.epoch,
682
+ totalEpochs: state.totalEpochs,
683
+ stepInEpoch: state.stepInEpoch,
684
+ totalStepsInEpoch: state.totalStepsInEpoch,
685
+ metricsHistory: state.metricsHistory,
686
+ aggregates: state.aggregates,
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Send an interactive prompt to the TUI and wait for response.
692
+ * Only works in TUI mode - returns null in non-TUI mode.
693
+ *
694
+ * @param id - Unique ID for this prompt
695
+ * @param message - Message to display
696
+ * @param choices - Available choices
697
+ * @param options - Prompt options
698
+ * @returns The selected value(s), or null if not in TUI mode.
699
+ * For multi-select, returns comma-separated values (use promptMulti for array).
700
+ */
701
+ async prompt(id: string, message: string, choices: PromptChoice[], options?: PromptOptions): Promise<string | null> {
702
+ if (!this.config.tuiMode) {
703
+ return null; // Caller should handle non-TUI mode
704
+ }
705
+
706
+ const defaultIndices =
707
+ options?.default !== undefined
708
+ ? Array.isArray(options.default)
709
+ ? options.default
710
+ : [options.default]
711
+ : undefined;
712
+
713
+ // Send prompt to TUI
714
+ this.writeTui({
715
+ type: 'prompt',
716
+ id,
717
+ message,
718
+ choices,
719
+ default: defaultIndices,
720
+ multiSelect: options?.multiSelect ?? false,
721
+ });
722
+
723
+ // Wait for response via stdin
724
+ return new Promise((resolve) => {
725
+ const onData = (data: Buffer) => {
726
+ const line = data.toString().trim();
727
+ // Expected format: PROMPT:<id>:<value>
728
+ // For multi-select, value is comma-separated
729
+ if (line.startsWith('PROMPT:')) {
730
+ const parts = line.split(':');
731
+ if (parts.length >= 3 && parts[1] === id) {
732
+ const value = parts.slice(2).join(':'); // Handle values with colons
733
+ process.stdin.removeListener('data', onData);
734
+ process.stdin.pause();
735
+ resolve(value);
736
+ }
737
+ }
738
+ };
739
+
740
+ process.stdin.resume();
741
+ process.stdin.on('data', onData);
742
+ });
743
+ }
744
+
745
+ /**
746
+ * Send a multi-select prompt to the TUI and wait for response.
747
+ * Convenience wrapper that returns an array of selected values.
748
+ *
749
+ * @param id - Unique ID for this prompt
750
+ * @param message - Message to display
751
+ * @param choices - Available choices
752
+ * @param defaultIndices - Optional default selection indices
753
+ * @returns Array of selected values, or null if not in TUI mode
754
+ */
755
+ async promptMulti(
756
+ id: string,
757
+ message: string,
758
+ choices: PromptChoice[],
759
+ defaultIndices?: number[],
760
+ ): Promise<string[] | null> {
761
+ const result = await this.prompt(id, message, choices, {
762
+ multiSelect: true,
763
+ default: defaultIndices,
764
+ });
765
+
766
+ if (result === null) return null;
767
+ if (result === '') return []; // No selections
768
+ return result.split(',');
769
+ }
770
+
771
+ // ==========================================================================
772
+ // Accessors
773
+ // ==========================================================================
774
+
775
+ /** Check if TUI mode is enabled */
776
+ get isTuiMode(): boolean {
777
+ return this.config.tuiMode;
778
+ }
779
+
780
+ /** Get the log interval */
781
+ get logInterval(): number {
782
+ return this.config.logInterval;
783
+ }
784
+
785
+ // ==========================================================================
786
+ // Internal Methods
787
+ // ==========================================================================
788
+
789
+ private writeTui(msg: TuiMessage): void {
790
+ if (!this.config.tuiMode) return;
791
+ process.stdout.write(JSON.stringify(msg) + '\n');
792
+ }
793
+
794
+ private writeJsonl(data: LogEvent): void {
795
+ if (!this.jsonlPath) return;
796
+
797
+ try {
798
+ const line = JSON.stringify(data) + '\n';
799
+ appendFileSync(this.jsonlPath, line, 'utf8');
800
+ } catch {
801
+ // Silently ignore - logging shouldn't crash training
802
+ }
803
+ }
804
+ }
805
+
806
+ // ============================================================================
807
+ // Factory Function
808
+ // ============================================================================
809
+
810
+ /**
811
+ * Create a training logger instance
812
+ *
813
+ * @example
814
+ * ```typescript
815
+ * // Auto-detect TUI mode from environment
816
+ * const logger = createTrainingLogger();
817
+ *
818
+ * // Explicit configuration
819
+ * const logger = createTrainingLogger({
820
+ * tuiMode: false,
821
+ * logConsole: true,
822
+ * logJsonl: true,
823
+ * outputDir: './outputs',
824
+ * logInterval: 10,
825
+ * });
826
+ * ```
827
+ */
828
+ export function createTrainingLogger(config?: Partial<TrainingLoggerConfig>): TrainingLogger {
829
+ return new TrainingLogger(config);
830
+ }