use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{GenerateRequest, InferenceError};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoraSpec {
pub rank: usize,
pub scale: f32,
pub num_layers: Option<usize>,
pub keys: Option<Vec<String>>,
pub weight_decay: f32,
pub init_adapter: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
impl LoraSpec {
pub fn paper() -> Self {
Self {
rank: 32,
scale: 1.0,
num_layers: None,
keys: None,
weight_decay: 0.0,
init_adapter: None,
seed: None,
}
}
}
impl LoraSpec {
pub fn from_adapter_dir(dir: &Path) -> Result<Self, InferenceError> {
#[derive(Deserialize)]
struct Saved {
num_layers: usize,
lora_parameters: SavedParameters,
}
#[derive(Deserialize)]
struct SavedParameters {
rank: usize,
scale: f32,
#[serde(default)]
keys: Option<Vec<String>>,
}
let path = dir.join("adapter_config.json");
let text = std::fs::read_to_string(&path)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
let saved: Saved = serde_json::from_str(&text)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
Ok(Self {
rank: saved.lora_parameters.rank,
scale: saved.lora_parameters.scale,
num_layers: Some(saved.num_layers),
keys: saved.lora_parameters.keys,
weight_decay: 0.0,
init_adapter: Some(dir.to_path_buf()),
seed: None,
})
}
}
pub fn learning_rate(step: usize, total: usize, peak: f32, warmup: usize) -> f32 {
if step < warmup {
return peak * (step + 1) as f32 / warmup as f32;
}
let decay_steps = total.saturating_sub(warmup).max(1);
let progress = ((step - warmup) as f32 / decay_steps as f32).min(1.0);
peak * 0.5 * (1.0 + (std::f32::consts::PI * progress).cos())
}
pub fn training_overhead_mb(max_sequence_tokens: usize, vocab_size: usize) -> u64 {
let logits = max_sequence_tokens as u64 * vocab_size as u64 * 4 * 3;
logits.div_ceil(1024 * 1024) + 1024
}
#[derive(Debug, Clone, PartialEq)]
pub enum Completion {
Text(String),
Tokens(Vec<u32>),
}
#[derive(Debug, Clone)]
pub struct TrainingExample {
pub request: GenerateRequest,
pub completion: Completion,
pub weight: f64,
}
#[derive(Debug, Clone)]
pub struct Sample {
pub text: String,
pub generated: Vec<u32>,
pub stop_reason: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StepReport {
pub trained: usize,
pub zero_weight: usize,
pub too_long: usize,
pub empty: usize,
pub completion_tokens: usize,
pub mean_loss: Option<f32>,
pub applied: bool,
}
#[derive(Debug, Clone, PartialEq)]
struct Sequence {
tokens: Vec<u32>,
completion_start: usize,
weight: f64,
temperature: f32,
}
fn plan_batch(
tokenized: Vec<(Vec<u32>, Vec<u32>, f64, f32)>,
max_sequence_tokens: usize,
report: &mut StepReport,
) -> Vec<(Sequence, f32)> {
let mut kept = Vec::new();
for (prompt, completion, weight, temperature) in tokenized {
if completion.is_empty() {
report.empty += 1;
continue;
}
if prompt.len() + completion.len() > max_sequence_tokens {
report.too_long += 1;
continue;
}
let completion_start = prompt.len();
let mut tokens = prompt;
tokens.extend(completion);
kept.push(Sequence {
tokens,
completion_start,
weight,
temperature,
});
}
let normalizer: usize = kept
.iter()
.map(|s| s.tokens.len() - s.completion_start)
.sum();
report.completion_tokens = normalizer;
kept.into_iter()
.filter_map(|s| {
if s.weight == 0.0 {
report.zero_weight += 1;
return None;
}
let scale = (s.weight / normalizer as f64) as f32;
Some((s, scale))
})
.collect()
}
fn scoring_temperature(example: &TrainingExample) -> f32 {
match example.completion {
Completion::Tokens(_) if example.request.params.temperature > 0.0 => {
example.request.params.temperature as f32
}
_ => 1.0,
}
}
pub fn training_available() -> bool {
imp::available()
}
pub struct ProposerTrainer {
inner: imp::Inner,
max_sequence_tokens: usize,
steps: usize,
}
impl ProposerTrainer {
pub fn open(
model_dir: &Path,
spec: &LoraSpec,
max_sequence_tokens: usize,
) -> Result<Self, InferenceError> {
Self::open_with_admission(
&crate::resource_policy::shared_local_admission(),
model_dir,
spec,
max_sequence_tokens,
)
}
pub(crate) fn open_with_admission(
admission: &std::sync::Arc<crate::LocalAdmissionCoordinator>,
model_dir: &Path,
spec: &LoraSpec,
max_sequence_tokens: usize,
) -> Result<Self, InferenceError> {
let config = serde_json::to_string(spec)
.map_err(|e| InferenceError::InferenceFailed(format!("LoRA spec: {e}")))?;
Ok(Self {
inner: imp::Inner::open(admission, model_dir, &config, max_sequence_tokens)?,
max_sequence_tokens,
steps: 0,
})
}
pub fn steps(&self) -> usize {
self.steps
}
pub fn release_memory(&mut self) -> Result<(), InferenceError> {
self.inner.release_memory()
}
pub fn prompt_tokens(&self, request: &GenerateRequest) -> Result<usize, InferenceError> {
self.inner.prompt_tokens(request)
}
pub fn fits(&self, request: &GenerateRequest) -> Result<bool, InferenceError> {
Ok(self.prompt_tokens(request)? + request.params.max_tokens <= self.max_sequence_tokens)
}
pub fn sample(&mut self, request: &GenerateRequest) -> Result<Sample, InferenceError> {
let sample = self.inner.sample(request);
self.inner.release_memory()?;
sample
}
pub fn step(
&mut self,
batch: &[TrainingExample],
learning_rate: f32,
) -> Result<StepReport, InferenceError> {
let mut report = StepReport::default();
let mut tokenized = Vec::with_capacity(batch.len());
for example in batch {
let (prompt, completion) = self.inner.tokenize(example)?;
tokenized.push((
prompt,
completion,
example.weight,
scoring_temperature(example),
));
}
let planned = plan_batch(tokenized, self.max_sequence_tokens, &mut report);
if planned.is_empty() {
return Ok(report);
}
let mut loss_sum = 0f64;
let mut loss_tokens = 0usize;
for (sequence, scale) in &planned {
let accumulated = self.inner.accumulate(
&sequence.tokens,
sequence.completion_start,
*scale,
sequence.temperature,
);
let loss = match accumulated {
Ok(loss) => loss,
Err(error) => {
let _ = self.inner.discard();
return Err(error);
}
};
let n = sequence.tokens.len() - sequence.completion_start;
loss_sum += loss as f64 * n as f64;
loss_tokens += n;
}
self.inner.apply(learning_rate)?;
self.inner.release_memory()?;
self.steps += 1;
report.trained = planned.len();
report.mean_loss = Some((loss_sum / loss_tokens as f64) as f32);
report.applied = true;
Ok(report)
}
pub fn loss(&mut self, examples: &[TrainingExample]) -> Result<Option<f32>, InferenceError> {
let mut report = StepReport::default();
let mut tokenized = Vec::with_capacity(examples.len());
for example in examples {
let (prompt, completion) = self.inner.tokenize(example)?;
tokenized.push((prompt, completion, 1.0, 1.0));
}
let mut sum = 0f64;
let mut tokens = 0usize;
for (sequence, _) in plan_batch(tokenized, self.max_sequence_tokens, &mut report) {
let n = sequence.tokens.len() - sequence.completion_start;
let loss = self
.inner
.loss(&sequence.tokens, sequence.completion_start)?;
sum += loss as f64 * n as f64;
tokens += n;
}
self.inner.release_memory()?;
Ok((tokens > 0).then(|| (sum / tokens as f64) as f32))
}
pub fn save_adapter(&self, dir: &Path) -> Result<(), InferenceError> {
self.inner.save(dir)
}
}
pub fn export_fused(
base_dir: &Path,
adapter_dir: &Path,
out_dir: &Path,
) -> Result<(), InferenceError> {
if let Some(mode) = quantization_mode(base_dir)? {
if mode != "affine" {
return Err(InferenceError::InferenceFailed(format!(
"{} is quantized in {mode} mode, and fusing would requantize it as affine; \
export from an unquantized or affine-quantized base",
base_dir.display()
)));
}
}
imp::export(base_dir, adapter_dir, out_dir)
}
fn quantization_mode(dir: &Path) -> Result<Option<String>, InferenceError> {
let path = dir.join("config.json");
let text = std::fs::read_to_string(&path)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
let config: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
let quantization = config
.get("quantization")
.or_else(|| config.get("quantization_config"));
Ok(quantization.map(|q| {
q.get("mode")
.and_then(|m| m.as_str())
.unwrap_or("affine")
.to_string()
}))
}
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
mod imp {
use std::path::Path;
use super::{Completion, Sample, TrainingExample};
use crate::backend::local::{LocalInferenceBackend, TextDecoder};
use crate::backend::swift_lm::{self, SwiftLmBackend};
use crate::{GenerateRequest, InferenceEngine, InferenceError};
pub(super) fn available() -> bool {
swift_lm::is_available()
}
pub(super) fn vocab_size(model_dir: &Path) -> Result<usize, InferenceError> {
let path = model_dir.join("config.json");
let text = std::fs::read_to_string(&path)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
let config: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| InferenceError::InferenceFailed(format!("{}: {e}", path.display())))?;
config
.get("vocab_size")
.or_else(|| config.get("text_config").and_then(|t| t.get("vocab_size")))
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| {
InferenceError::InferenceFailed(format!("{} names no vocab_size", path.display()))
})
}
pub(super) struct Inner {
backend: SwiftLmBackend,
_reservation: crate::resource_policy::LocalLoadReservation,
}
impl Inner {
pub(super) fn open(
admission: &std::sync::Arc<crate::LocalAdmissionCoordinator>,
model_dir: &Path,
config: &str,
max_sequence_tokens: usize,
) -> Result<Self, InferenceError> {
if !available() {
return Err(super::imp_unavailable());
}
static OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let weights = crate::backend_cache::estimate_model_size(model_dir);
let overhead = super::training_overhead_mb(max_sequence_tokens, vocab_size(model_dir)?);
let model_id = format!("training/{}", model_dir.display());
let allocation = OPENED.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let mut reservation = admission
.reserve_measured_host_allocation(
&model_id,
&format!("{model_id}#trainer-{allocation}"),
weights,
overhead,
)
.map_err(InferenceError::from)?;
let mut backend = SwiftLmBackend::load(model_dir)?;
reservation.publish_resident_weights(weights);
backend.lora_attach(config)?;
Ok(Self {
backend,
_reservation: reservation,
})
}
pub(super) fn sample(
&mut self,
request: &GenerateRequest,
) -> Result<Sample, InferenceError> {
let prompt = self.backend.render_prompt(request)?;
let prompt_tokens = TextDecoder::encode(&self.backend, &prompt)?.len();
let generation =
InferenceEngine::drive_generation(&mut self.backend, &prompt, &request.params)
.map_err(|e| e.into_inner())?;
if generation.prompt_tokens != prompt_tokens {
return Err(InferenceError::InferenceFailed(format!(
"the prompt was truncated from {prompt_tokens} to {} tokens; a sample of a \
truncated prompt does not answer the request it would be trained against",
generation.prompt_tokens
)));
}
Ok(Sample {
text: generation.text,
generated: generation.generated,
stop_reason: generation.stop_reason,
})
}
pub(super) fn prompt_tokens(
&self,
request: &GenerateRequest,
) -> Result<usize, InferenceError> {
let prompt = self.backend.render_prompt(request)?;
Ok(TextDecoder::encode(&self.backend, &prompt)?.len())
}
pub(super) fn tokenize(
&self,
example: &TrainingExample,
) -> Result<(Vec<u32>, Vec<u32>), InferenceError> {
let rendered = self.backend.render_prompt(&example.request)?;
let prompt = TextDecoder::encode(&self.backend, &rendered)?;
let completion = match &example.completion {
Completion::Tokens(ids) => ids.clone(),
Completion::Text(text) => {
let mut ids = self.backend.tokenize_raw(text)?;
let eos = self.backend.turn_end_id(&rendered).ok_or_else(|| {
InferenceError::InferenceFailed(
"the checkpoint names no eos token to end a completion with"
.to_string(),
)
})?;
ids.push(eos);
ids
}
};
Ok((prompt, completion))
}
pub(super) fn accumulate(
&mut self,
tokens: &[u32],
completion_start: usize,
scale: f32,
temperature: f32,
) -> Result<f32, InferenceError> {
self.backend
.lora_accumulate(tokens, completion_start, scale, temperature)
}
pub(super) fn loss(
&mut self,
tokens: &[u32],
completion_start: usize,
) -> Result<f32, InferenceError> {
self.backend.lora_loss(tokens, completion_start)
}
pub(super) fn apply(&mut self, learning_rate: f32) -> Result<(), InferenceError> {
self.backend.lora_apply(learning_rate)
}
pub(super) fn discard(&mut self) -> Result<(), InferenceError> {
self.backend.lora_discard()
}
pub(super) fn release_memory(&mut self) -> Result<(), InferenceError> {
self.backend.release_memory()
}
pub(super) fn save(&self, dir: &Path) -> Result<(), InferenceError> {
self.backend.lora_save(dir)
}
}
pub(super) fn export(
base_dir: &Path,
adapter_dir: &Path,
out_dir: &Path,
) -> Result<(), InferenceError> {
if !available() {
return Err(super::imp_unavailable());
}
swift_lm::lora_export(base_dir, adapter_dir, out_dir)
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
mod imp {
use std::path::Path;
use super::{Sample, TrainingExample};
use crate::{GenerateRequest, InferenceError};
pub(super) fn available() -> bool {
false
}
pub(super) enum Inner {}
impl Inner {
pub(super) fn open(
_admission: &std::sync::Arc<crate::LocalAdmissionCoordinator>,
_model_dir: &Path,
_config: &str,
_max_sequence_tokens: usize,
) -> Result<Self, InferenceError> {
Err(super::imp_unavailable())
}
pub(super) fn sample(
&mut self,
_request: &GenerateRequest,
) -> Result<Sample, InferenceError> {
match *self {}
}
pub(super) fn prompt_tokens(
&self,
_request: &GenerateRequest,
) -> Result<usize, InferenceError> {
match *self {}
}
pub(super) fn tokenize(
&self,
_example: &TrainingExample,
) -> Result<(Vec<u32>, Vec<u32>), InferenceError> {
match *self {}
}
pub(super) fn accumulate(
&mut self,
_tokens: &[u32],
_completion_start: usize,
_scale: f32,
_temperature: f32,
) -> Result<f32, InferenceError> {
match *self {}
}
pub(super) fn loss(
&mut self,
_tokens: &[u32],
_completion_start: usize,
) -> Result<f32, InferenceError> {
match *self {}
}
pub(super) fn apply(&mut self, _learning_rate: f32) -> Result<(), InferenceError> {
match *self {}
}
pub(super) fn discard(&mut self) -> Result<(), InferenceError> {
match *self {}
}
pub(super) fn release_memory(&mut self) -> Result<(), InferenceError> {
match *self {}
}
pub(super) fn save(&self, _dir: &Path) -> Result<(), InferenceError> {
match *self {}
}
}
pub(super) fn export(
_base_dir: &Path,
_adapter_dir: &Path,
_out_dir: &Path,
) -> Result<(), InferenceError> {
Err(super::imp_unavailable())
}
}
fn imp_unavailable() -> InferenceError {
InferenceError::InferenceFailed(
"LoRA training needs CAR's in-process MLX backend (Apple Silicon, built with the \
mlx-swift-lm package); this build has none"
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warmup_rises_linearly_then_cosine_falls_without_reaching_zero() {
let (total, warmup, peak) = (10, 2, 1e-4f32);
let rates: Vec<f32> = (0..total)
.map(|s| learning_rate(s, total, peak, warmup))
.collect();
assert_eq!(rates[0], peak / 2.0);
assert_eq!(rates[1], peak);
assert_eq!(rates[2], peak);
assert!(rates[2..].windows(2).all(|w| w[1] < w[0]), "{rates:?}");
assert!(rates[total - 1] > 0.0);
}
#[test]
fn export_refuses_a_base_it_would_requantize_differently() {
let dir = tempfile::tempdir().unwrap();
let config = |body: &str| std::fs::write(dir.path().join("config.json"), body).unwrap();
config(r#"{"model_type":"qwen3"}"#);
assert_eq!(quantization_mode(dir.path()).unwrap(), None);
config(r#"{"quantization":{"group_size":64,"bits":4}}"#);
assert_eq!(
quantization_mode(dir.path()).unwrap().as_deref(),
Some("affine")
);
config(r#"{"quantization":{"group_size":32,"bits":4,"mode":"mxfp4"}}"#);
let error = export_fused(dir.path(), dir.path(), dir.path()).unwrap_err();
assert!(error.to_string().contains("mxfp4"), "{error}");
}
#[test]
fn training_reserves_the_logits_of_its_longest_sequence() {
assert_eq!(training_overhead_mb(8192, 151_936), 14_244 + 1024);
assert_eq!(training_overhead_mb(0, 151_936), 1024);
}
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
#[test]
#[ignore = "requires the Swift LM training build"]
fn training_is_refused_by_local_admission_before_loading() {
if !training_available() {
panic!("missing what this test requires: the Swift LM training build");
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("config.json"),
r#"{"model_type":"qwen3","vocab_size":151936}"#,
)
.unwrap();
std::fs::write(dir.path().join("model.safetensors"), vec![0u8; 4096]).unwrap();
let admission = std::sync::Arc::new(crate::LocalAdmissionCoordinator::new(
crate::ResourcePolicy::custom_gb(0.0).unwrap(),
crate::hardware::HardwareInfo {
total_ram_mb: 32 * 1024,
..crate::hardware::HardwareInfo::detect()
},
));
let error =
ProposerTrainer::open_with_admission(&admission, dir.path(), &LoraSpec::paper(), 512)
.err()
.expect("a zero budget must refuse");
assert!(
matches!(error, InferenceError::LocalResourceBlocked { .. }),
"{error}"
);
}
#[test]
fn learning_rate_without_warmup_starts_at_the_peak() {
assert_eq!(learning_rate(0, 4, 2.0, 0), 2.0);
assert!((learning_rate(2, 4, 2.0, 0) - 1.0).abs() < 1e-6);
}
#[test]
fn scales_divide_each_weight_by_every_kept_completion_token() {
let mut report = StepReport::default();
let planned = plan_batch(
vec![
(vec![1, 2], vec![3, 4, 5], 1.5, 1.0),
(vec![1], vec![6], 0.0, 1.0),
(vec![1, 2, 3], vec![7, 8, 9, 10], -0.5, 0.6),
(vec![1], vec![], 2.0, 1.0),
(vec![1; 8], vec![2; 3], 1.0, 1.0),
],
10,
&mut report,
);
assert_eq!(report.completion_tokens, 3 + 1 + 4);
assert_eq!(
(report.zero_weight, report.empty, report.too_long),
(1, 1, 1)
);
let scales: Vec<f32> = planned.iter().map(|(_, s)| *s).collect();
assert_eq!(scales, vec![1.5 / 8.0, -0.5 / 8.0]);
let (first, _) = &planned[0];
assert_eq!(first.tokens, vec![1, 2, 3, 4, 5]);
assert_eq!(first.completion_start, 2);
}
#[test]
fn samples_are_scored_at_their_sampling_temperature_and_text_at_one() {
let at = |temperature: f64, completion: Completion| TrainingExample {
request: GenerateRequest {
params: crate::GenerateParams {
temperature,
..Default::default()
},
..Default::default()
},
completion,
weight: 1.0,
};
assert_eq!(
scoring_temperature(&at(0.6, Completion::Tokens(vec![1]))),
0.6
);
assert_eq!(
scoring_temperature(&at(0.6, Completion::Text("x".into()))),
1.0
);
assert_eq!(
scoring_temperature(&at(0.0, Completion::Tokens(vec![1]))),
1.0
);
}
#[test]
fn a_sequence_exactly_at_the_limit_is_kept() {
let mut report = StepReport::default();
let planned = plan_batch(vec![(vec![1; 4], vec![2; 6], 1.0, 1.0)], 10, &mut report);
assert_eq!((planned.len(), report.too_long), (1, 0));
}
#[test]
fn this_platform_reports_whether_it_can_train() {
let expected = cfg!(all(
target_os = "macos",
target_arch = "aarch64",
not(car_skip_mlx)
)) && cfg!(car_mlxlm_swift_built);
assert_eq!(training_available(), expected);
if !expected {
let error = ProposerTrainer::open(Path::new("/nonexistent"), &LoraSpec::paper(), 16)
.err()
.expect("an unavailable build must refuse");
assert!(
error.to_string().contains("in-process MLX backend"),
"{error}"
);
}
}
#[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
#[test]
#[ignore = "requires Qwen3-0.6B-MLX in ~/.car/models on Apple Silicon"]
fn a_trained_adapter_changes_samples_reloads_and_exports() {
let model = std::env::var_os("HOME")
.map(PathBuf::from)
.expect("HOME")
.join(".car/models/Qwen3-0.6B-MLX");
assert!(model.is_dir(), "{} is not installed", model.display());
let request = GenerateRequest {
prompt: "What is the secret word?".to_string(),
params: crate::GenerateParams {
temperature: 0.0,
max_tokens: 40,
..Default::default()
},
..Default::default()
};
let example = TrainingExample {
request: request.clone(),
completion: Completion::Text("The secret word is zanzibar.".to_string()),
weight: 1.0,
};
let spec = LoraSpec {
rank: 8,
scale: 20.0,
seed: Some(7),
..LoraSpec::paper()
};
let mut trainer = ProposerTrainer::open(&model, &spec, 512).unwrap();
let before = trainer.sample(&request).unwrap();
assert!(
!before.text.to_lowercase().contains("zanzibar"),
"{}",
before.text
);
let mut losses = Vec::new();
for _ in 0..12 {
let report = trainer.step(std::slice::from_ref(&example), 5e-5).unwrap();
assert!(report.applied && report.trained == 1, "{report:?}");
losses.push(report.mean_loss.unwrap());
}
assert!(losses[11] < losses[0] / 4.0, "{losses:?}");
let after = trainer.sample(&request).unwrap();
assert!(
after.text.to_lowercase().contains("zanzibar"),
"{}",
after.text
);
let measured = trainer
.loss(std::slice::from_ref(&example))
.unwrap()
.unwrap();
assert_eq!(
trainer
.loss(std::slice::from_ref(&example))
.unwrap()
.unwrap(),
measured
);
let dir = tempfile::tempdir().unwrap();
let adapter = dir.path().join("adapter");
trainer.save_adapter(&adapter).unwrap();
drop(trainer);
let resumed_spec = LoraSpec {
init_adapter: Some(adapter.clone()),
..spec.clone()
};
let mut resumed = ProposerTrainer::open(&model, &resumed_spec, 512).unwrap();
let reloaded = resumed
.loss(std::slice::from_ref(&example))
.unwrap()
.unwrap();
let (a, b) = (measured, reloaded);
assert!((a - b).abs() < 1e-3, "saved {a}, reloaded {b}");
drop(resumed);
let fused = dir.path().join("fused");
export_fused(&model, &adapter, &fused).unwrap();
let mut served = ProposerTrainer::open(&fused, &spec, 512).unwrap();
let text = served.sample(&request).unwrap().text;
assert!(text.to_lowercase().contains("zanzibar"), "{text}");
}
}