diff --git a/native/web/ds4_web.c b/native/web/ds4_web.c index fb1b666..209ef00 100644 --- a/native/web/ds4_web.c +++ b/native/web/ds4_web.c @@ -38,7 +38,6 @@ typedef struct { } web_buf; struct ds4_web { - char home[PATH_MAX]; char profile_dir[PATH_MAX]; int port; pid_t chrome_pid; @@ -1324,11 +1323,9 @@ static char *web_run_page_js(ds4_web *web, const char *url, const char *js, ds4_web *ds4_web_create(const ds4_web_config *cfg) { ds4_web *web = web_xmalloc(sizeof(*web)); memset(web, 0, sizeof(*web)); - const char *home = cfg && cfg->home_dir && cfg->home_dir[0] ? - cfg->home_dir : getenv("HOME"); - if (!home || !home[0]) home = "."; - snprintf(web->home, sizeof(web->home), "%s", home); - snprintf(web->profile_dir, sizeof(web->profile_dir), "%s/.ds4/browser", home); + const char *profile = cfg && cfg->profile_dir && cfg->profile_dir[0] ? + cfg->profile_dir : "browser"; + snprintf(web->profile_dir, sizeof(web->profile_dir), "%s", profile); web->port = cfg && cfg->port > 0 ? cfg->port : DS4_WEB_DEFAULT_PORT; web->chrome_pid = 0; web->next_cdp_id = 1; diff --git a/native/web/ds4_web.h b/native/web/ds4_web.h index 338f1c6..8ef1441 100644 --- a/native/web/ds4_web.h +++ b/native/web/ds4_web.h @@ -10,7 +10,7 @@ typedef void (*ds4_web_log_fn)(void *privdata, const char *message); typedef bool (*ds4_web_cancel_fn)(void *privdata); typedef struct { - const char *home_dir; + const char *profile_dir; int port; ds4_web_confirm_fn confirm; void *confirm_privdata; diff --git a/src/agent.rs b/src/agent.rs index bffa145..749d04c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -32,7 +32,7 @@ pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after co #[repr(C)] struct WebConfig { - home_dir: *const c_char, + profile_dir: *const c_char, port: c_int, confirm: Option c_int>, confirm_data: *mut c_void, @@ -77,16 +77,15 @@ impl Browser { let mut callbacks = Box::new(WebCallbacks { cancel: AtomicPtr::new(std::ptr::null_mut()), }); - let home = CString::new( - std::env::var_os("HOME") - .unwrap_or_else(|| ".".into()) + let profile = CString::new( + crate::app::browser_profile_path() .to_string_lossy() .as_bytes(), ) - .map_err(|_| "The home directory contains a NUL byte.".to_owned())?; + .map_err(|_| "The browser profile path contains a NUL byte.".to_owned())?; let data = (&mut *callbacks) as *mut WebCallbacks as *mut c_void; let config = WebConfig { - home_dir: home.as_ptr(), + profile_dir: profile.as_ptr(), port: 9333, confirm: Some(web_confirm), confirm_data: data, diff --git a/src/app.rs b/src/app.rs index f63f02c..14b080c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2453,7 +2453,7 @@ fn export_markdown(title: &str, conversation: &[ChatMessage]) -> String { output } -fn application_support_path() -> PathBuf { +pub(crate) fn application_support_path() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) @@ -2462,10 +2462,14 @@ fn application_support_path() -> PathBuf { .join(APP_ID) } -fn models_path() -> PathBuf { +pub(crate) fn models_path() -> PathBuf { application_support_path().join("models") } +pub(crate) fn browser_profile_path() -> PathBuf { + application_support_path().join("browser") +} + /// The settings file, beside the project database. pub(crate) fn config_path() -> PathBuf { application_support_path().join("config.yaml") @@ -2911,4 +2915,36 @@ mod tests { ); assert!(!exported.contains("private")); } + + #[test] + fn owned_runtime_paths_stay_in_application_support() { + let root = application_support_path(); + assert!(models_path().starts_with(&root)); + assert!(browser_profile_path().starts_with(&root)); + assert!(config_path().starts_with(&root)); + assert!(kv_cache_path().starts_with(&root)); + } + + #[test] + fn source_does_not_depend_on_a_sibling_ds4_checkout() { + fn scan(path: &Path, forbidden: &str) { + if path.is_dir() { + for entry in fs::read_dir(path).unwrap() { + scan(&entry.unwrap().path(), forbidden); + } + } else if let Ok(source) = fs::read_to_string(path) { + assert!( + !source.contains(forbidden), + "{} references a sibling DS4 checkout", + path.display() + ); + } + } + + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let forbidden = format!("{}/", ["..", "ds4"].join("/")); + for path in ["src", "native", "build.rs", "Cargo.toml", "Makefile"] { + scan(&root.join(path), &forbidden); + } + } } diff --git a/src/engine.rs b/src/engine.rs index dde9d8e..d19187c 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1838,9 +1838,13 @@ mod sampling_tests { #[ignore = "requires the 80 GiB Flash checkpoint and Apple Metal"] fn metal_executes_real_flash_token() { configure_metal_sources().unwrap(); - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); + let path = crate::model::engine_artifacts( + ModelChoice::DeepSeekV4Flash, + false, + false, + &crate::app::models_path(), + ) + .model; let model = Model::open_main(&path, ModelChoice::DeepSeekV4Flash).unwrap(); let tokens = model.render_prompt( "You are a helpful assistant", diff --git a/src/engine/metal.rs b/src/engine/metal.rs index 688803e..e1da086 100644 --- a/src/engine/metal.rs +++ b/src/engine/metal.rs @@ -2687,7 +2687,7 @@ impl Session { // `session` must release every Buffer before `_context` calls ds4_gpu_cleanup(), // and `_context` must drop before `model` unmaps memory wrapped without copying // by native/metal/ds4_metal.m:10329. This intentionally differs from -// ../ds4/ds4.c:56287-56288; do not reorder these fields to match it. +// DS4's `ds4.c` consumes this exact field order; do not reorder it. #[derive(Clone, Copy, Default)] pub(super) struct ExecutionStats { pub(super) speculative_mode: u8, @@ -7068,6 +7068,14 @@ mod tests { }; use crate::engine::{FLASH, PRO}; + fn installed_artifacts( + model: crate::model::ModelChoice, + legacy_mtp: bool, + dspark: bool, + ) -> crate::model::EngineArtifacts { + crate::model::engine_artifacts(model, legacy_mtp, dspark, &crate::app::models_path()) + } + #[test] fn compression_schedule_tracks_the_deepseek_model_shape() { assert_eq!(compression_ratio(FLASH, 0), 0); @@ -7165,16 +7173,14 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; use std::sync::atomic::AtomicBool; - let main_path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); + let artifacts = installed_artifacts(ModelChoice::DeepSeekV4Flash, true, false); + let main_path = artifacts.model; configure_sources().unwrap(); - let support_path = Path::new("../ds4/gguf/DeepSeek-V4-Flash-MTP-Q4K-Q8_0-F32.gguf"); - let mut model = Model::open_main(main_path, ModelChoice::DeepSeekV4Flash).unwrap(); - let support = Gguf::open(support_path).unwrap(); + let support_path = artifacts.mtp.unwrap(); + let mut model = Model::open_main(&main_path, ModelChoice::DeepSeekV4Flash).unwrap(); + let support = Gguf::open(&support_path).unwrap(); model.support_kind = Some(validate_support(&support, &model.shape).unwrap()); model.support = Some(support); let prompt = model.render_conversation( @@ -7304,16 +7310,14 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; use std::sync::atomic::AtomicBool; configure_sources().unwrap(); - let main_path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); - let support_path = Path::new("../ds4/gguf/DeepSeek-V4-Flash-DSpark-support.gguf"); - let mut model = Model::open_main(main_path, ModelChoice::DeepSeekV4Flash).unwrap(); - let support = Gguf::open(support_path).unwrap(); + let artifacts = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, true); + let main_path = artifacts.model; + let support_path = artifacts.mtp.unwrap(); + let mut model = Model::open_main(&main_path, ModelChoice::DeepSeekV4Flash).unwrap(); + let support = Gguf::open(&support_path).unwrap(); model.support_kind = Some(validate_support(&support, &model.shape).unwrap()); model.support = Some(support); let prompt = model.render_conversation( @@ -7446,24 +7450,19 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; use std::sync::atomic::AtomicBool; configure_sources().unwrap(); - let main_path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); + let legacy = installed_artifacts(ModelChoice::DeepSeekV4Flash, true, false); + let dspark_artifacts = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, true); + let main_path = legacy.model; let cases = [ - ( - "../ds4/gguf/DeepSeek-V4-Flash-MTP-Q4K-Q8_0-F32.gguf", - false, - 4, - ), - ("../ds4/gguf/DeepSeek-V4-Flash-DSpark-support.gguf", true, 1), + (legacy.mtp.unwrap(), false, 4), + (dspark_artifacts.mtp.unwrap(), true, 1), ]; for (support_path, dspark, draft_tokens) in cases { - let mut model = Model::open_main(main_path, ModelChoice::DeepSeekV4Flash).unwrap(); - let support = Gguf::open(Path::new(support_path)).unwrap(); + let mut model = Model::open_main(&main_path, ModelChoice::DeepSeekV4Flash).unwrap(); + let support = Gguf::open(&support_path).unwrap(); model.support_kind = Some(validate_support(&support, &model.shape).unwrap()); model.support = Some(support); let prompt = model.render_conversation( @@ -7528,7 +7527,8 @@ mod tests { assert_eq!( generated, [19_923, 3, 1_730, 588, 342, 1_694, 440, 4_316], - "SSD speculative output differed for {support_path}" + "SSD speculative output differed for {}", + support_path.display() ); } } @@ -7604,12 +7604,10 @@ mod tests { } configure_sources().unwrap(); - let path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); - let resident = run(path, false); + let path = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, false).model; + let resident = run(&path, false); assert_eq!(resident, [19_923, 3, 1_730, 588]); - assert_eq!(resident, run(path, true)); + assert_eq!(resident, run(&path, true)); } #[test] @@ -7621,16 +7619,10 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; configure_sources().unwrap(); - let model = Model::open_main( - Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ), - ModelChoice::DeepSeekV4Flash, - ) - .unwrap(); + let path = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, false).model; + let model = Model::open_main(&path, ModelChoice::DeepSeekV4Flash).unwrap(); let prompt = model.render_conversation( "", &[crate::engine::ChatTurn { @@ -7690,16 +7682,10 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, ReasoningMode, }; - use std::path::Path; configure_sources().unwrap(); - let model = Model::open_main( - Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ), - ModelChoice::DeepSeekV4Flash, - ) - .unwrap(); + let path = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, false).model; + let model = Model::open_main(&path, ModelChoice::DeepSeekV4Flash).unwrap(); let prompts = ["Reply with A.", "Reply with B."].map(|content| { model.render_conversation( "", @@ -7775,19 +7761,17 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; configure_sources().unwrap(); - let model_path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); - let steering_path = "../ds4/dir-steering/out/verbosity.f32"; + let model_path = installed_artifacts(ModelChoice::DeepSeekV4Flash, false, false).model; + let steering_path = std::env::var("DS4_STEERING_FILE") + .expect("set DS4_STEERING_FILE to the DS4 verbosity direction fixture"); let cases = [ (1.0, 0.0, [19_923, 3, 1_730, 588, 342, 8_233, 440, 4_316]), (0.0, 1.0, [19_923, 3, 1_730, 588, 342, 1_694, 440, 4_316]), ]; for (ffn_scale, attention_scale, expected) in cases { - let model = Model::open_main(model_path, ModelChoice::DeepSeekV4Flash).unwrap(); + let model = Model::open_main(&model_path, ModelChoice::DeepSeekV4Flash).unwrap(); let prompt = model.render_conversation( "", &[crate::engine::ChatTurn { @@ -7827,7 +7811,7 @@ mod tests { preload_experts: 0, }, EngineSteeringSettings { - file: Some(steering_path.into()), + file: Some(steering_path.clone()), ffn_scale, attention_scale, }, @@ -7853,18 +7837,15 @@ mod tests { use crate::settings::{ EngineSpeculativeSettings, EngineSsdSettings, EngineSteeringSettings, }; - use std::path::Path; configure_sources().unwrap(); - let path = std::env::var("DS4_PRO_MODEL").unwrap_or_else(|_| { - "../ds4/models/DeepSeek-V4-Pro-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-Instruct-imatrix.gguf".into() - }); - if !Path::new(&path).is_file() { - eprintln!("skipping unavailable Pro fixture: {path}"); + let path = installed_artifacts(ModelChoice::DeepSeekV4Pro, false, false).model; + if !path.is_file() { + eprintln!("skipping unavailable Pro fixture: {}", path.display()); return; } let run = |cache_experts| { - let model = Model::open_main(Path::new(&path), ModelChoice::DeepSeekV4Pro).unwrap(); + let model = Model::open_main(&path, ModelChoice::DeepSeekV4Pro).unwrap(); let prompt = model.render_conversation( "", &[crate::engine::ChatTurn { diff --git a/src/engine/metal/glm.rs b/src/engine/metal/glm.rs index 474e601..7886c0a 100644 --- a/src/engine/metal/glm.rs +++ b/src/engine/metal/glm.rs @@ -3221,7 +3221,11 @@ mod tests { use crate::engine::{GLM, Model, ReasoningMode}; use crate::model::ModelChoice; use crate::settings::EngineSsdSettings; - use std::path::Path; + + fn installed_glm_path() -> std::path::PathBuf { + crate::model::engine_artifacts(ModelChoice::Glm52, false, false, &crate::app::models_path()) + .model + } #[test] fn dsa_indexer_schedule_matches_the_reference() { @@ -3258,16 +3262,14 @@ mod tests { #[test] #[ignore = "requires the 197 GiB GLM 5.2 checkpoint and Apple Metal"] fn resident_and_streamed_glm_match_ds4_decode_oracles() { - let path = std::env::var("DS4_GLM_MODEL").unwrap_or_else(|_| { - "../ds4/models/GLM-5.2-UD-IQ2_XXS_RoutedIQ2XXS_blk78Q2K.gguf".into() - }); - if !Path::new(&path).is_file() { - eprintln!("skipping unavailable GLM fixture: {path}"); + let path = installed_glm_path(); + if !path.is_file() { + eprintln!("skipping unavailable GLM fixture: {}", path.display()); return; } let prompt = b"Complete the C statement with the next exact token only:\nreturn snprintf(buf, sizeof(buf), \"%d\", value"; for streamed in [false, true] { - let model = Model::open_main(Path::new(&path), ModelChoice::Glm52).unwrap(); + let model = Model::open_main(&path, ModelChoice::Glm52).unwrap(); let tokens = model.tokenize(std::str::from_utf8(prompt).unwrap()); let executor = GlmExecutor::open( model, @@ -3329,15 +3331,13 @@ mod tests { #[test] #[ignore = "requires the 197 GiB GLM 5.2 checkpoint and Apple Metal"] fn streamed_glm_uses_ds4_indexed_prefill_for_long_prompts() { - let path = std::env::var("DS4_GLM_MODEL").unwrap_or_else(|_| { - "../ds4/models/GLM-5.2-UD-IQ2_XXS_RoutedIQ2XXS_blk78Q2K.gguf".into() - }); - if !Path::new(&path).is_file() { - eprintln!("skipping unavailable GLM fixture: {path}"); + let path = installed_glm_path(); + if !path.is_file() { + eprintln!("skipping unavailable GLM fixture: {}", path.display()); return; } let prompt = "Complete each C statement. Example: return snprintf(buf, sizeof(buf), \"%d\", value); Example: return snprintf(buf, sizeof(buf), \"%d\", value); Example: return snprintf(buf, sizeof(buf), \"%d\", value); Example: return snprintf(buf, sizeof(buf), \"%d\", value); Example: return snprintf(buf, sizeof(buf), \"%d\", value); Now complete exactly: return snprintf(buf, sizeof(buf), \"%d\", value"; - let model = Model::open_main(Path::new(&path), ModelChoice::Glm52).unwrap(); + let model = Model::open_main(&path, ModelChoice::Glm52).unwrap(); let tokens = model.render_prompt("You are a helpful assistant", prompt, ReasoningMode::Direct); assert_eq!(tokens.len(), 102); @@ -3382,15 +3382,13 @@ mod tests { use crate::settings::EngineSpeculativeSettings; use std::sync::atomic::AtomicBool; - let path = std::env::var("DS4_GLM_MODEL").unwrap_or_else(|_| { - "../ds4/models/GLM-5.2-UD-IQ2_XXS_RoutedIQ2XXS_blk78Q2K.gguf".into() - }); - if !Path::new(&path).is_file() { - eprintln!("skipping unavailable GLM fixture: {path}"); + let path = installed_glm_path(); + if !path.is_file() { + eprintln!("skipping unavailable GLM fixture: {}", path.display()); return; } let run = |enabled| { - let model = Model::open_main(Path::new(&path), ModelChoice::Glm52).unwrap(); + let model = Model::open_main(&path, ModelChoice::Glm52).unwrap(); let tokens = model.tokenize("Write one short greeting."); let mut executor = GlmExecutor::open_profile( model, diff --git a/src/engine/validation.rs b/src/engine/validation.rs index a23f7d2..28527b2 100644 --- a/src/engine/validation.rs +++ b/src/engine/validation.rs @@ -1019,13 +1019,17 @@ mod tests { #[test] fn installed_ds4_fixture_opens_and_renders_a_prompt() { - let path = Path::new( - "../ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf", - ); + let path = crate::model::engine_artifacts( + ModelChoice::DeepSeekV4Flash, + false, + false, + &crate::app::models_path(), + ) + .model; if !path.exists() { return; } - let model = Model::open_main(path, ModelChoice::DeepSeekV4Flash).unwrap(); + let model = Model::open_main(&path, ModelChoice::DeepSeekV4Flash).unwrap(); let summary = model.summary(); assert_eq!(summary.model, ModelChoice::DeepSeekV4Flash); assert_eq!(summary.vocabulary_size, 129_280); @@ -1223,18 +1227,32 @@ mod tests { #[test] fn installed_dspark_fixture_passes_the_target_layout() { - let path = Path::new("../ds4/gguf/DeepSeek-V4-Flash-DSpark-support.gguf"); + let path = crate::model::engine_artifacts( + ModelChoice::DeepSeekV4Flash, + false, + true, + &crate::app::models_path(), + ) + .mtp + .unwrap(); if path.exists() { - validate_model_artifact(path, ModelChoice::DeepSeekV4Flash, true).unwrap(); + validate_model_artifact(&path, ModelChoice::DeepSeekV4Flash, true).unwrap(); } } #[test] fn installed_legacy_mtp_fixture_passes_the_target_layout() { - let path = Path::new("../ds4/gguf/DeepSeek-V4-Flash-MTP-Q4K-Q8_0-F32.gguf"); + let path = crate::model::engine_artifacts( + ModelChoice::DeepSeekV4Flash, + true, + false, + &crate::app::models_path(), + ) + .mtp + .unwrap(); if path.exists() { assert_eq!( - validate_support(&Gguf::open(path).unwrap(), &FLASH).unwrap(), + validate_support(&Gguf::open(&path).unwrap(), &FLASH).unwrap(), SupportKind::LegacyMtp ); }