Improve fine-tune UX: validate model, show adapter details
- Prevent fine-tuning if model is not downloaded or not in safetensors format (GGUF not supported). - Show error messages for unsupported models. - Redesign fine-tune progress UI to display adapter path, size, training config, and a list of all adapters for the model. - Fix Metal backend compatibility by flattening tensors before matmul in LoRA and output layers. - Print errors to stderr if fine-tune fails. - Rename "App Group" cache label to "Onde".
keypair34 committed
Apr 22, 2026 at 21:02 UTC
f4b460ff63c87764544fd5373333f91f5ffd7354
4 files changed
+238
-54
src/app.rs
+23
-9
@@ -941,13 +941,22 @@ fn handle_key_model_detail(app: &mut App, key: crossterm::event::KeyEvent) {
941
}
942
(Char('f'), KeyModifiers::NONE) => {
943
if let Some(model) = app.downloads.get(app.downloads_cursor) {
944
- app.finetune_model_id = model.model_id.clone();
945
- app.finetune_model_dir = resolve_hf_cache_path(&model.model_id);
946
- app.finetune_focus = FineTuneFocus::DataPath;
947
- app.finetune_running = false;
948
- app.finetune_progress = None;
949
- app.screen = Screen::FineTune;
950
- app.status = Status::neutral("Configure fine-tuning.");
944
+ let resolved = resolve_hf_cache_path(&model.model_id);
945
+ if resolved.is_empty() {
946
+ app.status = Status::error("Model not downloaded locally.");
947
+ } else if !std::path::Path::new(&resolved).join("config.json").exists() {
948
+ app.status = Status::error(
949
+ "Fine-tuning requires a safetensors model (GGUF not supported).",
950
+ );
951
+ } else {
952
+ app.finetune_model_id = model.model_id.clone();
953
+ app.finetune_model_dir = resolved;
954
+ app.finetune_focus = FineTuneFocus::DataPath;
955
+ app.finetune_running = false;
956
+ app.finetune_progress = None;
957
+ app.screen = Screen::FineTune;
958
+ app.status = Status::neutral("Configure fine-tuning.");
959
+ }
960
}
961
}
962
_ => {}
@@ -989,11 +998,16 @@ fn resolve_hf_cache_path(model_id: &str) -> String {
998
for hub in candidates {
999
let snapshots_dir = hub.join(&dir_name).join("snapshots");
1000
if let Ok(entries) = std::fs::read_dir(&snapshots_dir) {
992
- // Pick the first (usually only) snapshot hash directory
1001
+ // Pick the first directory that looks like a commit hash (hex string).
1002
+ // This skips non-hash dirs like "lora-adapter" that fine-tuning creates.
1003
for entry in entries.flatten() {
1004
let p = entry.path();
1005
if p.is_dir() {
996
- return p.to_string_lossy().to_string();
1006
+ let name = entry.file_name();
1007
+ let name = name.to_string_lossy();
1008
+ if name.len() >= 7 && name.chars().all(|c| c.is_ascii_hexdigit()) {
1009
+ return p.to_string_lossy().to_string();
1010
+ }
1011
}
1012
}
1013
}
src/finetune.rs
+29
-12
@@ -78,6 +78,7 @@ pub fn start_finetune(
78
) {
79
std::thread::spawn(move || {
80
if let Err(e) = run_finetune(&config, &tx) {
81
+ eprintln!("[finetune] error: {e:#}");
82
let _ = tx.send(FineTuneProgress::Failed(format!("{e:#}")));
83
}
84
});
@@ -435,21 +436,29 @@ impl LoraLinear {
436
}
437
438
/// `y = x W^T + b + (x A^T) B^T * scale`
439
+ ///
440
+ /// Flattens to 2D before matmul because the published candle-core 0.10.2
441
+ /// Metal kernel does not support 3D × 2D matmul.
442
fn forward(&self, x: &Tensor) -> candle_core::Result<Tensor> {
443
+ let (b, seq, in_f) = x.dims3()?;
444
+ let x_flat = x.reshape((b * seq, in_f))?;
445
+
446
// Base projection
440
- let base = x.matmul(&self.weight.t()?)?;
441
- let base = match &self.bias {
442
- Some(b) => base.broadcast_add(b)?,
443
- None => base,
444
- };
447
+ let base = x_flat.matmul(&self.weight.t()?)?;
448
449
// LoRA path: x A^T → x B^T scaled
447
- let lora = x
450
+ let lora = x_flat
451
.matmul(&self.lora_a.as_tensor().t()?)?
452
.matmul(&self.lora_b.as_tensor().t()?)?
453
.affine(self.scale, 0.0)?;
454
452
- base + lora
455
+ let out_f = self.weight.dim(0)?;
456
+ let combined = (base + lora)?.reshape((b, seq, out_f))?;
457
+
458
+ match &self.bias {
459
+ Some(bias) => combined.broadcast_add(bias),
460
+ None => Ok(combined),
461
+ }
462
}
463
464
/// Return the two trainable LoRA variables.
@@ -469,9 +478,13 @@ struct FrozenLinear {
478
479
impl FrozenLinear {
480
fn forward(&self, x: &Tensor) -> candle_core::Result<Tensor> {
472
- let out = x.matmul(&self.weight.t()?)?;
481
+ let (b, seq, in_f) = x.dims3()?;
482
+ let x_flat = x.reshape((b * seq, in_f))?;
483
+ let out_flat = x_flat.matmul(&self.weight.t()?)?;
484
+ let out_f = self.weight.dim(0)?;
485
+ let out = out_flat.reshape((b, seq, out_f))?;
486
match &self.bias {
474
- Some(b) => out.broadcast_add(b),
487
+ Some(bias) => out.broadcast_add(bias),
488
None => Ok(out),
489
}
490
}
@@ -578,7 +591,9 @@ impl TransformerLayer {
591
// (1 - tril) is 1 on upper triangle (blocked), 0 on lower triangle (allowed).
592
let attn_bias = (1.0f64 - &tril)?.affine(-1e9, 0.0)?;
593
let attn_weights = (attn_weights + attn_bias)?;
581
- let attn_weights = candle_nn::ops::softmax_last_dim(&attn_weights)?;
594
+ // Use the generic softmax (broadcast_sub/broadcast_div internally) — the
595
+ // CustomOp-based softmax_last_dim has no Metal implementation in candle 0.10.2.
596
+ let attn_weights = candle_nn::ops::softmax(&attn_weights, D::Minus1)?;
597
598
// Weighted sum of values
599
let attn_out = attn_weights.matmul(&v)?;
@@ -802,8 +817,10 @@ impl LoraQwenModel {
817
// Final RMSNorm
818
let hidden = rms_norm(&hidden, &self.norm, self.rms_norm_eps)?;
819
805
- // Language model head: [b, seq, vocab_size]
806
- hidden.matmul(&self.lm_head.t()?)
820
+ // Language model head: flatten → matmul → reshape (Metal 3D×2D workaround)
821
+ let hidden_flat = hidden.reshape((b * seq, self.hidden_size))?;
822
+ let logits_flat = hidden_flat.matmul(&self.lm_head.t()?)?;
823
+ logits_flat.reshape((b, seq, self.vocab_size))
824
}
825
826
/// Collect all trainable LoRA variables across every layer.
src/hf.rs
+1
-1
@@ -63,7 +63,7 @@ pub enum CacheSource {
63
impl CacheSource {
64
pub fn label(&self) -> &'static str {
65
match self {
66
- CacheSource::AppGroup => "App Group",
66
+ CacheSource::AppGroup => "Onde",
67
CacheSource::HfCache => "HF Cache",
68
}
69
}
src/ui.rs
+185
-32
@@ -1242,17 +1242,14 @@ fn render_finetune_input(frame: &mut Frame, value: &str, is_focused: bool, area:
1242
}
1243
1244
fn render_finetune_progress(frame: &mut Frame, app: &App, area: Rect) {
1245
- let rows = Layout::vertical([
1245
+ // Shared top: heading + status. The rest is state-dependent.
1246
+ let top = Layout::vertical([
1247
Constraint::Length(1), // 0 heading
1248
Constraint::Length(1), // 1 spacer
1249
Constraint::Length(1), // 2 status
1250
Constraint::Length(1), // 3 spacer
1250
- Constraint::Length(1), // 4 progress label
1251
- Constraint::Length(1), // 5 progress detail
1252
- Constraint::Length(1), // 6 spacer
1253
- Constraint::Length(1), // 7 progress bar
1254
- Constraint::Min(0), // 8 spacer
1255
- Constraint::Length(1), // 9 hint
1251
+ Constraint::Min(0), // 4 content (state-dependent)
1252
+ Constraint::Length(1), // 5 hint
1253
])
1254
.split(area);
1255
@@ -1267,30 +1264,32 @@ fn render_finetune_progress(frame: &mut Frame, app: &App, area: Rect) {
1264
heading,
1265
Style::new().fg(C_INK).bold(),
1266
))),
1270
- rows[0],
1267
+ top[0],
1268
);
1269
1273
- render_status(frame, app, rows[2]);
1270
+ render_status(frame, app, top[2]);
1271
+
1272
+ let content = top[4];
1273
1274
match &app.finetune_progress {
1275
Some(crate::finetune::FineTuneProgress::Validating) => {
1276
frame.render_widget(
1277
Paragraph::new("⠿ Validating model and data files…")
1278
.style(Style::new().fg(C_MUTED)),
1280
- rows[4],
1279
+ content,
1280
);
1281
}
1282
Some(crate::finetune::FineTuneProgress::LoadingModel) => {
1283
frame.render_widget(
1284
Paragraph::new("⠿ Loading model weights…").style(Style::new().fg(C_MUTED)),
1286
- rows[4],
1285
+ content,
1286
);
1287
}
1288
Some(crate::finetune::FineTuneProgress::Tokenizing { done, total }) => {
1289
frame.render_widget(
1290
Paragraph::new(format!("⠿ Tokenizing… {done}/{total}"))
1291
.style(Style::new().fg(C_MUTED)),
1293
- rows[4],
1292
+ content,
1293
);
1294
}
1295
Some(crate::finetune::FineTuneProgress::Training {
@@ -1300,62 +1299,69 @@ fn render_finetune_progress(frame: &mut Frame, app: &App, area: Rect) {
1299
total_steps,
1300
loss,
1301
}) => {
1302
+ let rows = Layout::vertical([
1303
+ Constraint::Length(1), // label
1304
+ Constraint::Length(1), // detail
1305
+ Constraint::Length(1), // spacer
1306
+ Constraint::Length(1), // progress bar
1307
+ Constraint::Min(0), // rest
1308
+ ])
1309
+ .split(content);
1310
+
1311
let detail =
1312
format!("epoch {epoch}/{total_epochs} step {step}/{total_steps} loss {loss:.3}");
1313
frame.render_widget(
1314
Paragraph::new("Training").style(Style::new().fg(C_NEON).bold()),
1307
- rows[4],
1315
+ rows[0],
1316
);
1317
frame.render_widget(
1318
Paragraph::new(detail).style(Style::new().fg(C_TEXT)),
1311
- rows[5],
1319
+ rows[1],
1320
);
1321
1314
- // Progress bar
1322
let progress = if *total_steps > 0 {
1323
*step as f64 / *total_steps as f64
1324
} else {
1325
0.0
1326
};
1320
- let bar_width = area.width.saturating_sub(4) as usize;
1327
+ let bar_width = content.width.saturating_sub(4) as usize;
1328
let filled = (progress * bar_width as f64) as usize;
1329
let empty = bar_width.saturating_sub(filled);
1330
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
1324
- frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), rows[7]);
1331
+ frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), rows[3]);
1332
}
1333
Some(crate::finetune::FineTuneProgress::Saving) => {
1334
frame.render_widget(
1335
Paragraph::new("⠿ Saving adapter weights…").style(Style::new().fg(C_MUTED)),
1329
- rows[4],
1336
+ content,
1337
);
1338
}
1339
Some(crate::finetune::FineTuneProgress::Done { adapter_path }) => {
1333
- frame.render_widget(
1334
- Paragraph::new("✓ Done").style(Style::new().fg(C_NEON).bold()),
1335
- rows[4],
1336
- );
1337
- frame.render_widget(
1338
- Paragraph::new(adapter_path.to_string_lossy().to_string())
1339
- .style(Style::new().fg(C_NEON)),
1340
- rows[5],
1341
- );
1340
+ render_finetune_done(frame, app, adapter_path, content);
1341
}
1342
Some(crate::finetune::FineTuneProgress::Failed(msg)) => {
1343
+ let rows = Layout::vertical([
1344
+ Constraint::Length(1), // label
1345
+ Constraint::Length(1), // spacer
1346
+ Constraint::Min(0), // error message (wraps)
1347
+ ])
1348
+ .split(content);
1349
+
1350
frame.render_widget(
1351
Paragraph::new("✗ Failed").style(Style::new().fg(C_DANGER).bold()),
1346
- rows[4],
1352
+ rows[0],
1353
);
1354
frame.render_widget(
1355
Paragraph::new(msg.as_str())
1356
.style(Style::new().fg(C_DANGER))
1357
.wrap(Wrap { trim: true }),
1352
- rows[5],
1358
+ rows[2],
1359
);
1360
}
1361
None => {
1362
frame.render_widget(
1363
Paragraph::new("⠿ Starting…").style(Style::new().fg(C_MUTED)),
1358
- rows[4],
1364
+ content,
1365
);
1366
}
1367
}
@@ -1366,10 +1372,157 @@ fn render_finetune_progress(frame: &mut Frame, app: &App, area: Rect) {
1372
} else {
1373
"Esc · back"
1374
};
1375
+ frame.render_widget(Paragraph::new(hint).style(Style::new().fg(C_MUTED)), top[5]);
1376
+}
1377
+
1378
+/// Render the fine-tune "Done" state with full adapter details.
1379
+fn render_finetune_done(frame: &mut Frame, app: &App, adapter_path: &std::path::Path, area: Rect) {
1380
+ let rows = Layout::vertical([
1381
+ Constraint::Length(1), // 0 ✓ label
1382
+ Constraint::Length(1), // 1 spacer
1383
+ Constraint::Length(1), // 2 "Adapter" label
1384
+ Constraint::Length(3), // 3 path (wrapped in bordered box)
1385
+ Constraint::Length(1), // 4 spacer
1386
+ Constraint::Length(1), // 5 size
1387
+ Constraint::Length(1), // 6 spacer
1388
+ Constraint::Length(1), // 7 config heading
1389
+ Constraint::Length(1), // 8 rank + epochs + lr
1390
+ Constraint::Length(1), // 9 spacer
1391
+ Constraint::Length(1), // 10 adapters heading
1392
+ Constraint::Min(0), // 11 adapters list
1393
+ ])
1394
+ .split(area);
1395
+
1396
+ // ✓ label
1397
frame.render_widget(
1370
- Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
1371
- rows[9],
1398
+ Paragraph::new(Line::from(vec![
1399
+ Span::styled("✓ ", Style::new().fg(C_NEON)),
1400
+ Span::styled("Fine-tuning complete", Style::new().fg(C_NEON).bold()),
1401
+ ])),
1402
+ rows[0],
1403
+ );
1404
+
1405
+ // Adapter path in a bordered box so it wraps visibly
1406
+ frame.render_widget(
1407
+ Paragraph::new("Adapter").style(Style::new().fg(C_MUTED)),
1408
+ rows[2],
1409
+ );
1410
+ let path_block = Block::new()
1411
+ .borders(Borders::ALL)
1412
+ .border_type(BorderType::Rounded)
1413
+ .border_style(Style::new().fg(C_LINE))
1414
+ .style(Style::new().bg(C_SURFACE_STRONG));
1415
+ let path_inner = path_block.inner(rows[3]);
1416
+ frame.render_widget(path_block, rows[3]);
1417
+ frame.render_widget(
1418
+ Paragraph::new(adapter_path.to_string_lossy().to_string())
1419
+ .style(Style::new().fg(C_NEON))
1420
+ .wrap(Wrap { trim: true }),
1421
+ path_inner,
1422
+ );
1423
+
1424
+ // File size
1425
+ let size_str = std::fs::metadata(adapter_path)
1426
+ .map(|m| fmt_bytes(m.len() as i64))
1427
+ .unwrap_or_else(|_| "–".to_string());
1428
+ frame.render_widget(
1429
+ Paragraph::new(Line::from(vec![
1430
+ Span::styled("Size ", Style::new().fg(C_MUTED)),
1431
+ Span::styled(size_str, Style::new().fg(C_TEXT).bold()),
1432
+ ])),
1433
+ rows[5],
1434
+ );
1435
+
1436
+ // Training config
1437
+ frame.render_widget(
1438
+ Paragraph::new("Training Config").style(Style::new().fg(C_MUTED)),
1439
+ rows[7],
1440
+ );
1441
+ let config_line = format!(
1442
+ "rank {} epochs {} lr {}",
1443
+ app.finetune_rank, app.finetune_epochs, app.finetune_lr
1444
+ );
1445
+ frame.render_widget(
1446
+ Paragraph::new(config_line).style(Style::new().fg(C_TEXT)),
1447
+ rows[8],
1448
+ );
1449
+
1450
+ // Scan for all adapters under this model's snapshots directory
1451
+ frame.render_widget(
1452
+ Paragraph::new("All Adapters").style(Style::new().fg(C_MUTED)),
1453
+ rows[10],
1454
);
1455
+ render_adapter_list(frame, app, rows[11]);
1456
+}
1457
+
1458
+/// Scan the model's cache directory for existing LoRA adapter files and list them.
1459
+fn render_adapter_list(frame: &mut Frame, app: &App, area: Rect) {
1460
+ // Resolve the model's snapshots dir from the model_dir field.
1461
+ // model_dir points to .../snapshots/{hash}/, so parent is .../snapshots/
1462
+ let snapshots_dir = std::path::Path::new(&app.finetune_model_dir)
1463
+ .parent()
1464
+ .unwrap_or_else(|| std::path::Path::new(""));
1465
+
1466
+ let mut adapters: Vec<(String, String, String)> = Vec::new(); // (name, size, date)
1467
+
1468
+ // Look for lora_adapter.safetensors in any subdirectory of snapshots
1469
+ if let Ok(entries) = std::fs::read_dir(snapshots_dir) {
1470
+ for entry in entries.flatten() {
1471
+ let dir = entry.path();
1472
+ if !dir.is_dir() {
1473
+ continue;
1474
+ }
1475
+ let adapter_file = dir.join("lora_adapter.safetensors");
1476
+ if adapter_file.exists() {
1477
+ let dir_name = entry.file_name().to_string_lossy().to_string();
1478
+ let meta = std::fs::metadata(&adapter_file);
1479
+ let size = meta
1480
+ .as_ref()
1481
+ .map(|m| fmt_bytes(m.len() as i64))
1482
+ .unwrap_or_else(|_| "–".to_string());
1483
+ let modified = meta
1484
+ .ok()
1485
+ .and_then(|m| m.modified().ok())
1486
+ .map(|t| {
1487
+ let elapsed = t.elapsed().unwrap_or_default();
1488
+ let secs = elapsed.as_secs();
1489
+ if secs < 60 {
1490
+ "just now".to_string()
1491
+ } else if secs < 3600 {
1492
+ format!("{}m ago", secs / 60)
1493
+ } else if secs < 86400 {
1494
+ format!("{}h ago", secs / 3600)
1495
+ } else {
1496
+ format!("{}d ago", secs / 86400)
1497
+ }
1498
+ })
1499
+ .unwrap_or_else(|| "–".to_string());
1500
+ adapters.push((dir_name, size, modified));
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ if adapters.is_empty() {
1506
+ frame.render_widget(
1507
+ Paragraph::new(" No adapters found.").style(Style::new().fg(C_MUTED)),
1508
+ area,
1509
+ );
1510
+ return;
1511
+ }
1512
+
1513
+ let max_rows = area.height as usize;
1514
+ for (i, (name, size, modified)) in adapters.iter().enumerate().take(max_rows) {
1515
+ let row_y = area.y + i as u16;
1516
+ let row_area = Rect::new(area.x, row_y, area.width, 1);
1517
+
1518
+ let line = Line::from(vec![
1519
+ Span::styled(" ◆ ", Style::new().fg(C_NEON)),
1520
+ Span::styled(format!("{:<20}", name), Style::new().fg(C_TEXT)),
1521
+ Span::styled(format!("{:<10}", size), Style::new().fg(C_MUTED)),
1522
+ Span::styled(modified.as_str(), Style::new().fg(C_MUTED)),
1523
+ ]);
1524
+ frame.render_widget(Paragraph::new(line), row_area);
1525
+ }
1526
}
1527
1528
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {