@ondeinference / cli / commits / f4b460f

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) {