Switch classifier sessions onto bread-onnx v0.7.2
All checks were successful
check / check (push) Successful in 3m42s
dev release / build (push) Successful in 2m28s

Replace local ort Session::builder/ROCm wiring with bread_onnx::build_session
(MIGraphX + CPU fallback, same pin as breadmill). Drop the breadman
chip/init_adw shim now that bread-theme v0.7.4 exports those helpers.
This commit is contained in:
Breadway 2026-08-23 14:42:57 +08:00
parent 51338af7eb
commit 39e26a1b9f
11 changed files with 254 additions and 137 deletions

View file

@ -20,7 +20,7 @@ tokio.workspace = true
zbus.workspace = true
ort.workspace = true
tokenizers.workspace = true
ndarray.workspace = true
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
toml.workspace = true
dirs.workspace = true
regex.workspace = true

View file

@ -2,6 +2,8 @@ use crate::ai::OllamaClient;
use crate::config::OllamaConfig;
use crate::parser::parse_rule_based;
use crate::types::{ClassificationResult, NoteType};
use bread_onnx::{build_session, Provider};
use ort::session::builder::GraphOptimizationLevel;
use std::path::PathBuf;
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
@ -16,7 +18,7 @@ pub enum ExecutionProvider {
impl ExecutionProvider {
pub fn as_str(&self) -> &str {
match self {
ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
ExecutionProvider::Cpu => "CPU",
}
}
@ -107,9 +109,7 @@ impl Classifier {
// ── Tier 2 ───────────────────────────────────────────────────────────
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
let tier2 = if let (Some(session), Some(tokenizer)) =
(&mut self.session, &self.tokenizer)
{
let tier2 = if let (Some(session), Some(tokenizer)) = (&mut self.session, &self.tokenizer) {
match run_onnx(session, tokenizer, text) {
Ok(r) => {
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
@ -163,9 +163,18 @@ impl Classifier {
// entailment score across all five passes.
const HYPOTHESES: [(&str, &str); 5] = [
("This note is a task or action item to complete.", "todo"),
("This note is a reminder with a specific time or deadline.", "reminder"),
("This note is an idea, suggestion, or creative thought.", "idea"),
("This note is a general observation or piece of information.", "note"),
(
"This note is a reminder with a specific time or deadline.",
"reminder",
),
(
"This note is an idea, suggestion, or creative thought.",
"idea",
),
(
"This note is a general observation or piece of information.",
"note",
),
("This note is a question that needs an answer.", "question"),
];
@ -184,15 +193,17 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
let mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect();
let len = ids.len();
let ids_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], ids)
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
let mask_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], mask)
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let ids_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], ids))
.map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
let mask_tensor = ort::value::Tensor::<i64>::from_array((vec![1i64, len as i64], mask))
.map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let inputs = ort::inputs![
"input_ids" => ids_tensor,
@ -207,10 +218,7 @@ fn run_onnx(
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
let (_, logits_slice) = logits;
entailment_scores[i] = logits_slice
.get(ENTAILMENT_IDX)
.copied()
.unwrap_or(0.0);
entailment_scores[i] = logits_slice.get(ENTAILMENT_IDX).copied().unwrap_or(0.0);
}
let best_idx = entailment_scores
@ -243,42 +251,30 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
exps[idx] / sum
}
fn try_load_session(
path: &std::path::Path,
) -> (Option<ort::session::Session>, ExecutionProvider) {
// Try ROCm (iGPU) first, fall back to CPU.
let rocm_available = {
use ort::execution_providers::ExecutionProvider as _;
ort::ep::ROCm::default().is_available().unwrap_or(false)
};
if rocm_available {
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
Ok(s) => {
tracing::info!("ONNX session loaded (ROCm iGPU)");
return (Some(s), ExecutionProvider::Gpu);
}
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
}
}
match build_onnx_session(path, ort::ep::CPU::default().build()) {
fn try_load_session(path: &std::path::Path) -> (Option<ort::session::Session>, ExecutionProvider) {
// WHY: distro onnxruntime-rocm is MIGraphX, not classic ROCm; bread-onnx
// appends CPU so a missing GPU EP does not disable Tier 2.
match build_session(
path,
GraphOptimizationLevel::Level3,
&[Provider::MiGraphX { device_id: 0 }],
) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
tracing::info!("ONNX session loaded (MIGraphX, CPU fallback)");
(Some(s), ExecutionProvider::Gpu)
}
Err(e) => {
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
(None, ExecutionProvider::Cpu)
tracing::debug!("MIGraphX session failed: {}; trying CPU", e);
match build_session(path, GraphOptimizationLevel::Level3, &[Provider::Cpu]) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
}
Err(e) => {
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
(None, ExecutionProvider::Cpu)
}
}
}
}
}
fn build_onnx_session(
path: &std::path::Path,
ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> {
let mut builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
.with_execution_providers([ep])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
}

View file

@ -1,17 +1,24 @@
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
use breadpad_shared::types::NoteType;
use chrono::Timelike;
use std::path::PathBuf;
/// Rule-based path only — a present `~/.local/share/breadpad/model` must not
/// change these assertions.
fn cl() -> Classifier {
Classifier::load("08:00")
Classifier::load_with_paths(
"08:00",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
)
}
#[test]
fn active_provider_is_valid() {
// The active provider depends on the host: a machine with the ONNX model present and
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
// a working MIGraphX iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
// model is available we must be on CPU (no session => no GPU EP in use).
let c = cl();
let c = Classifier::load("08:00");
assert!(matches!(
c.active_provider,
ExecutionProvider::Cpu | ExecutionProvider::Gpu
@ -49,19 +56,28 @@ fn classify_reminder_via_fallback() {
#[test]
fn classify_idea_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
assert_eq!(
c.classify("what if we added a calendar view").note_type,
NoteType::Idea
);
}
#[test]
fn classify_question_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
assert_eq!(
c.classify("why does this fail?").note_type,
NoteType::Question
);
}
#[test]
fn classify_note_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
assert_eq!(
c.classify("meeting went well today").note_type,
NoteType::Note
);
}
#[test]
@ -74,7 +90,11 @@ fn classify_recurrence_via_fallback() {
#[test]
fn classify_custom_morning_time() {
let mut c = Classifier::load("07:15");
let mut c = Classifier::load_with_paths(
"07:15",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
);
let r = c.classify("sync tomorrow morning");
let t = r.time.expect("should have a time for tomorrow morning");
let local: chrono::DateTime<chrono::Local> = t.into();
@ -114,12 +134,16 @@ fn classify_returns_cleaned_body() {
let mut c = cl();
let r = c.classify("call mum at 6pm");
assert!(r.body.contains("call mum"), "body: {}", r.body);
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
assert!(
!r.body.contains("6pm"),
"time phrase should be stripped from body: {}",
r.body
);
}
#[test]
fn model_path_points_to_expected_location() {
let c = cl();
let c = Classifier::load("08:00");
assert!(
c.model_path.to_str().unwrap().contains("breadpad"),
"model path: {:?}",

View file

@ -9,12 +9,18 @@ use breadpad_shared::classifier::Classifier;
use breadpad_shared::store::Store;
use breadpad_shared::types::{Note, NoteType};
use chrono::Timelike;
use std::path::PathBuf;
use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs.
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
let mut classifier = Classifier::load("08:00");
// WHY: pipeline tests cover classify→save→reload, not a host ONNX model.
let mut classifier = Classifier::load_with_paths(
"08:00",
PathBuf::from("/nonexistent/classifier.onnx"),
PathBuf::from("/nonexistent/tokenizer.json"),
);
let result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None);
@ -61,7 +67,11 @@ fn todo_note_appears_in_store() {
#[test]
fn idea_note_appears_in_store() {
let (dir, store) = setup();
capture(&store, "what if we added dark mode", NoteType::from_str("note"));
capture(
&store,
"what if we added dark mode",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
@ -71,7 +81,11 @@ fn idea_note_appears_in_store() {
#[test]
fn question_note_appears_in_store() {
let (dir, store) = setup();
capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
capture(
&store,
"why does the cache miss on cold start?",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
@ -97,7 +111,10 @@ fn reminder_has_time_set() {
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
assert!(
notes[0].time.is_some(),
"reminder should have a scheduled time"
);
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
assert_eq!(local.hour(), 18);
}
@ -108,14 +125,21 @@ fn reminder_body_has_time_stripped() {
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
assert!(
!notes[0].body.contains("6pm"),
"time phrase should be removed from body"
);
assert!(notes[0].body.contains("call mum"));
}
#[test]
fn in_duration_reminder_has_time() {
let (dir, store) = setup();
capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
capture(
&store,
"check on the build in 30 minutes",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -127,7 +151,11 @@ fn in_duration_reminder_has_time() {
#[test]
fn recurring_reminder_has_rrule() {
let (dir, store) = setup();
capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
capture(
&store,
"standup every monday at 9am",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
@ -139,11 +167,20 @@ fn recurring_reminder_has_rrule() {
#[test]
fn daily_reminder_has_rrule() {
let (dir, store) = setup();
capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
capture(
&store,
"drink water every day at 8am",
NoteType::from_str("note"),
);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
assert!(notes[0]
.rrule
.as_ref()
.unwrap()
.as_str()
.contains("FREQ=DAILY"));
}
// ---- user-forced type is respected ----
@ -155,7 +192,11 @@ fn user_selected_type_overrides_classifier() {
capture(&store, "fix the login bug", NoteType::Idea);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
assert_eq!(
notes[0].note_type,
NoteType::Idea,
"user chip selection should win over classifier"
);
}
#[test]
@ -173,7 +214,11 @@ fn user_selected_reminder_overrides_classifier() {
fn three_notes_all_visible_to_breadman() {
let (dir, store) = setup();
capture(&store, "buy milk", NoteType::from_str("note"));
capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
capture(
&store,
"what if we rewrote in Zig",
NoteType::from_str("note"),
);
capture(&store, "team standup went well", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();