Skip to main content

falsegreen_agent/
model.rs

1//! Pinned NeoHorse V1 model acquisition.
2
3use 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    /// Inspect the pinned cache location without creating directories or downloading bytes.
139    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}