1use std::fs;
4use std::path::PathBuf;
5
6use thiserror::Error;
7
8use crate::artifact::{AcquiredFile, ArtifactError, ArtifactFetcher, ArtifactSpec, verify_file};
9use crate::inference::ModelCapabilities;
10use crate::profile::{NEOHORSE_V1, ProfileError};
11
12const DOWNLOAD_SPACE_RESERVE: u64 = 512 * 1024 * 1024;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct ModelDownloadMetadata {
16 pub repository: &'static str,
17 pub revision: &'static str,
18 pub artifact: &'static str,
19 pub url: &'static str,
20 pub sha256: &'static str,
21 pub size_bytes: u64,
22}
23
24impl ModelDownloadMetadata {
25 #[must_use]
26 pub fn artifact_spec(self) -> ArtifactSpec {
27 ArtifactSpec {
28 id: "neohorse-1-9b-q8".to_owned(),
29 version: self.revision.to_owned(),
30 file_name: self.artifact.to_owned(),
31 url: self.url.to_owned(),
32 sha256: self.sha256.to_owned(),
33 size_bytes: self.size_bytes,
34 }
35 }
36}
37
38pub const NEOHORSE_V1_DOWNLOAD: ModelDownloadMetadata = ModelDownloadMetadata {
39 repository: "TokenRhythm/NeoHorse-1-9B-GGUF",
40 revision: "ddcb4c939b5392c86a9d2733c7c0ed30db2554fd",
41 artifact: "NeoHorse-1-9B-Q8_0.gguf",
42 url: "https://huggingface.co/TokenRhythm/NeoHorse-1-9B-GGUF/resolve/ddcb4c939b5392c86a9d2733c7c0ed30db2554fd/NeoHorse-1-9B-Q8_0.gguf?download=true",
43 sha256: "519869730bda973ec50bb3ac42cd874569e5f3a57c3e0d5d23a2b5040e93310f",
44 size_bytes: 9_527_501_632,
45};
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ModelInstall {
49 pub path: PathBuf,
50 pub capabilities: ModelCapabilities,
51 pub reused: bool,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ModelCacheInspection {
56 pub path: PathBuf,
57 pub present: bool,
58 pub size_matches: bool,
59 pub digest_verified: Option<bool>,
60}
61
62#[derive(Debug, Clone)]
63pub struct NeoHorseModelManager {
64 cache_root: PathBuf,
65 fetcher: ArtifactFetcher,
66}
67
68impl NeoHorseModelManager {
69 #[must_use]
70 pub fn new(cache_root: impl Into<PathBuf>, fetcher: ArtifactFetcher) -> Self {
71 Self {
72 cache_root: cache_root.into(),
73 fetcher,
74 }
75 }
76
77 pub fn ensure(&self) -> Result<ModelInstall, ModelAcquireError> {
78 validate_frozen_metadata()?;
79 let directory = self
80 .cache_root
81 .join("models")
82 .join("TokenRhythm--NeoHorse-1-9B-GGUF")
83 .join(NEOHORSE_V1_DOWNLOAD.revision);
84 fs::create_dir_all(&directory).map_err(|source| ModelAcquireError::Io {
85 path: directory.clone(),
86 source,
87 })?;
88 let destination = directory.join(NEOHORSE_V1_DOWNLOAD.artifact);
89 let requires_download = match fs::metadata(&destination) {
90 Ok(metadata) => {
91 let is_symlink = fs::symlink_metadata(&destination)
92 .is_ok_and(|metadata| metadata.file_type().is_symlink());
93 is_symlink
94 || !metadata.is_file()
95 || metadata.len() != NEOHORSE_V1_DOWNLOAD.size_bytes
96 }
97 Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
98 Err(source) => {
99 return Err(ModelAcquireError::Io {
100 path: destination.clone(),
101 source,
102 });
103 }
104 };
105 if requires_download {
106 let available =
107 fs2::available_space(&directory).map_err(|source| ModelAcquireError::Io {
108 path: directory.clone(),
109 source,
110 })?;
111 let required = NEOHORSE_V1_DOWNLOAD
112 .size_bytes
113 .saturating_add(DOWNLOAD_SPACE_RESERVE);
114 if available < required {
115 return Err(ModelAcquireError::InsufficientSpace {
116 path: directory,
117 required,
118 available,
119 });
120 }
121 }
122 let AcquiredFile {
123 path,
124 reused,
125 sha256,
126 ..
127 } = self
128 .fetcher
129 .ensure_file(&NEOHORSE_V1_DOWNLOAD.artifact_spec(), &destination)?;
130 let capabilities = NEOHORSE_V1.capabilities_from_verified_artifact(&path, &sha256)?;
131 Ok(ModelInstall {
132 path,
133 capabilities,
134 reused,
135 })
136 }
137
138 pub fn inspect(&self, verify_digest: bool) -> Result<ModelCacheInspection, ModelAcquireError> {
140 validate_frozen_metadata()?;
141 let path = self
142 .cache_root
143 .join("models")
144 .join("TokenRhythm--NeoHorse-1-9B-GGUF")
145 .join(NEOHORSE_V1_DOWNLOAD.revision)
146 .join(NEOHORSE_V1_DOWNLOAD.artifact);
147 let metadata = match fs::symlink_metadata(&path) {
148 Ok(metadata) => Some(metadata),
149 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
150 Err(source) => {
151 return Err(ModelAcquireError::Io {
152 path: path.clone(),
153 source,
154 });
155 }
156 };
157 let present = metadata
158 .as_ref()
159 .is_some_and(|metadata| metadata.file_type().is_file());
160 let size_matches = metadata
161 .as_ref()
162 .is_some_and(|metadata| metadata.len() == NEOHORSE_V1_DOWNLOAD.size_bytes);
163 let digest_verified = (present && size_matches && verify_digest)
164 .then(|| verify_file(&path, &NEOHORSE_V1_DOWNLOAD.artifact_spec()).is_ok());
165 Ok(ModelCacheInspection {
166 path,
167 present,
168 size_matches,
169 digest_verified,
170 })
171 }
172}
173
174#[derive(Debug, Error)]
175pub enum ModelAcquireError {
176 #[error(transparent)]
177 Artifact(#[from] ArtifactError),
178 #[error(transparent)]
179 Profile(#[from] ProfileError),
180 #[error(
181 "not enough free space for NeoHorse at {path}: need {required} bytes, have {available}"
182 )]
183 InsufficientSpace {
184 path: PathBuf,
185 required: u64,
186 available: u64,
187 },
188 #[error("could not prepare model cache at {path}: {source}")]
189 Io {
190 path: PathBuf,
191 #[source]
192 source: std::io::Error,
193 },
194 #[error("managed model metadata no longer matches the frozen NeoHorse V1 profile")]
195 FrozenMetadataMismatch,
196}
197
198fn validate_frozen_metadata() -> Result<(), ModelAcquireError> {
199 if NEOHORSE_V1_DOWNLOAD.repository != NEOHORSE_V1.repository
200 || NEOHORSE_V1_DOWNLOAD.revision != NEOHORSE_V1.revision
201 || NEOHORSE_V1_DOWNLOAD.artifact != NEOHORSE_V1.artifact
202 || NEOHORSE_V1_DOWNLOAD.sha256 != NEOHORSE_V1.artifact_sha256
203 {
204 return Err(ModelAcquireError::FrozenMetadataMismatch);
205 }
206 Ok(())
207}
208
209#[cfg(test)]
210mod tests {
211 use super::{NEOHORSE_V1_DOWNLOAD, validate_frozen_metadata};
212 use crate::profile::NEOHORSE_V1;
213
214 #[test]
215 fn download_is_pinned_to_the_qualified_identity() {
216 validate_frozen_metadata().expect("metadata");
217 assert_eq!(NEOHORSE_V1_DOWNLOAD.sha256, NEOHORSE_V1.artifact_sha256);
218 assert!(NEOHORSE_V1_DOWNLOAD.url.contains(NEOHORSE_V1.revision));
219 assert!(NEOHORSE_V1_DOWNLOAD.url.ends_with("?download=true"));
220 assert_eq!(NEOHORSE_V1_DOWNLOAD.size_bytes, 9_527_501_632);
221 }
222}