clawft_kernel/model_manifest/
locality.rs1use std::collections::BTreeSet;
20
21use clawft_types::placement::engine::Preference;
22use clawft_types::placement::{
23 AttrPredicate, AttrValue, Capability, CapabilityId, CapabilityState, Requirement,
24};
25
26use super::body::{MODEL_MARKER_PREFIX, ModelPackageBody};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TransferPolicy {
31 pub allow_weight_transfer: bool,
34 pub max_bytes: Option<u64>,
36 pub free_headroom_bytes: u64,
38}
39
40impl Default for TransferPolicy {
41 fn default() -> Self {
42 Self {
43 allow_weight_transfer: false,
44 max_bytes: None,
45 free_headroom_bytes: 1024 * 1024 * 1024,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Sharing {
54 Allowed,
56 Refused,
58}
59
60impl Sharing {
61 pub fn from_manifest(body: &ModelPackageBody) -> Self {
63 if body.redistributable { Self::Allowed } else { Self::Refused }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct NodeHolding {
70 pub node_id: String,
72 pub eligible: bool,
74 pub shards: BTreeSet<String>,
76 pub complete: bool,
78 pub free_bytes: Option<u64>,
80}
81
82impl NodeHolding {
83 pub fn from_capabilities(
87 node_id: &str,
88 package_id: &str,
89 body: &ModelPackageBody,
90 caps: &[Capability],
91 eligible: bool,
92 ) -> Self {
93 let marker = format!("{MODEL_MARKER_PREFIX}{package_id}");
94 let wanted: BTreeSet<&str> = body.shard_hashes().collect();
95 let mut shards = BTreeSet::new();
96 let mut complete = false;
97 for c in caps.iter().filter(|c| c.id.as_str() == "model.present") {
98 let list = match c.attrs.get("shards") {
99 Some(AttrValue::List(l)) => l,
100 _ => continue,
101 };
102 let items: Vec<&str> = list
103 .iter()
104 .filter_map(|v| match v {
105 AttrValue::Str(s) => Some(s.as_str()),
106 _ => None,
107 })
108 .collect();
109 let has_marker = items.contains(&marker.as_str());
110 match (c.state, has_marker) {
111 (CapabilityState::Available | CapabilityState::Busy, true) => {
112 complete = true;
113 shards.extend(wanted.iter().map(|s| s.to_string()));
114 }
115 (_, true) => {}
117 (_, false) => {
118 shards.extend(items.into_iter().filter(|s| wanted.contains(s)).map(String::from));
119 }
120 }
121 }
122 let free_bytes = caps
123 .iter()
124 .filter(|c| c.id.as_str().starts_with("store.tier."))
125 .filter(|c| c.state == CapabilityState::Available)
126 .filter_map(|c| c.attrs.get("free").and_then(|v| v.as_f64()))
127 .filter(|f| *f >= 0.0)
128 .map(|f| f as u64)
129 .max();
130 Self {
131 node_id: node_id.to_string(),
132 eligible,
133 shards,
134 complete,
135 free_bytes,
136 }
137 }
138
139 fn missing_bytes(&self, body: &ModelPackageBody) -> u64 {
140 body.shards
141 .iter()
142 .filter(|s| !self.shards.contains(&s.blake3))
143 .map(|s| s.size)
144 .fold(0, u64::saturating_add)
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum Unplaceable {
151 NoHolder,
153 NoEligibleNode,
155 TransferDisabled,
157 NotRedistributable,
159 OverCeiling {
161 bytes: u64,
163 max: u64,
165 },
166 NoSpace {
168 needed: u64,
170 free: Option<u64>,
172 },
173}
174
175impl std::fmt::Display for Unplaceable {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 Self::NoHolder => write!(f, "no node holds these weights; adopt them on a node first"),
179 Self::NoEligibleNode => write!(f, "no node passes the other placement constraints"),
180 Self::TransferDisabled => {
181 write!(f, "weights are held elsewhere and allow_weight_transfer is off")
182 }
183 Self::NotRedistributable => {
184 write!(f, "weights are held elsewhere and the manifest is not marked redistributable")
185 }
186 Self::OverCeiling { bytes, max } => {
187 write!(f, "transfer of {bytes} bytes exceeds the {max} byte ceiling")
188 }
189 Self::NoSpace { needed, free: Some(free) } => {
190 write!(f, "target needs {needed} bytes free, has {free}")
191 }
192 Self::NoSpace { needed, free: None } => {
193 write!(f, "target needs {needed} bytes free and its free space is unknown")
194 }
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum LocalityDecision {
202 PlaceAtHolder {
204 node_id: String,
206 },
207 FetchThenPlace {
209 target: String,
211 sources: Vec<String>,
213 bytes: u64,
215 missing_shards: usize,
217 },
218 Unplaceable(Unplaceable),
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct LocalityPlan {
225 pub decision: LocalityDecision,
227 pub notes: Vec<String>,
229}
230
231pub fn decide(
233 body: &ModelPackageBody,
234 nodes: &[NodeHolding],
235 policy: &TransferPolicy,
236 sharing: Sharing,
237) -> LocalityPlan {
238 let mut notes = vec![format!(
239 "model {} ({} shards, {} bytes)",
240 body.name,
241 body.shards.len(),
242 body.total_bytes()
243 )];
244 let mut sorted: Vec<&NodeHolding> = nodes.iter().collect();
245 sorted.sort_by(|a, b| a.node_id.cmp(&b.node_id));
246 for n in &sorted {
247 notes.push(format!(
248 "node {}: eligible={} complete={} holds {}/{} shards",
249 n.node_id,
250 n.eligible,
251 n.complete,
252 n.shards.len(),
253 body.shards.len()
254 ));
255 }
256 let done = |decision, mut notes: Vec<String>, line: String| {
257 notes.push(line);
258 LocalityPlan { decision, notes }
259 };
260 if let Some(n) = sorted.iter().find(|n| n.eligible && n.complete) {
261 return done(
262 LocalityDecision::PlaceAtHolder { node_id: n.node_id.clone() },
263 notes,
264 format!("relocate: {} already holds every shard, nothing is copied", n.node_id),
265 );
266 }
267 let sources: Vec<String> = sorted
268 .iter()
269 .filter(|n| n.complete)
270 .map(|n| n.node_id.clone())
271 .collect();
272 let fail = |u: Unplaceable, notes: Vec<String>| {
273 let line = format!("unplaceable: {u}");
274 done(LocalityDecision::Unplaceable(u), notes, line)
275 };
276 let eligible: Vec<&&NodeHolding> = sorted.iter().filter(|n| n.eligible).collect();
277 if eligible.is_empty() {
278 return fail(Unplaceable::NoEligibleNode, notes);
279 }
280 if sources.is_empty() {
281 return fail(Unplaceable::NoHolder, notes);
282 }
283 if !policy.allow_weight_transfer {
284 return fail(Unplaceable::TransferDisabled, notes);
285 }
286 if sharing != Sharing::Allowed {
287 return fail(Unplaceable::NotRedistributable, notes);
288 }
289 let mut targets: Vec<(&NodeHolding, u64)> =
291 eligible.iter().map(|n| (**n, n.missing_bytes(body))).collect();
292 targets.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.node_id.cmp(&b.0.node_id)));
293 let mut first_failure = None;
294 for (n, bytes) in targets {
295 let verdict = (|| {
296 if let Some(max) = policy.max_bytes
297 && bytes > max
298 {
299 return Err(Unplaceable::OverCeiling { bytes, max });
300 }
301 let needed = bytes.saturating_add(policy.free_headroom_bytes);
302 match n.free_bytes {
303 Some(free) if free >= needed => Ok(()),
304 free => Err(Unplaceable::NoSpace { needed, free }),
305 }
306 })();
307 match verdict {
308 Ok(()) => {
309 let missing_shards = body
310 .shards
311 .iter()
312 .filter(|s| !n.shards.contains(&s.blake3))
313 .count();
314 let line = format!(
315 "fetch: {} gets {missing_shards} shard(s), {bytes} bytes, from {}",
316 n.node_id,
317 sources.join(",")
318 );
319 return done(
320 LocalityDecision::FetchThenPlace {
321 target: n.node_id.clone(),
322 sources,
323 bytes,
324 missing_shards,
325 },
326 notes,
327 line,
328 );
329 }
330 Err(u) => {
331 notes.push(format!("target {} rejected: {u}", n.node_id));
332 first_failure.get_or_insert(u);
333 }
334 }
335 }
336 fail(first_failure.unwrap_or(Unplaceable::NoEligibleNode), notes)
337}
338
339pub fn model_present_requirement(package_id: &str) -> Requirement {
342 Requirement::exact(CapabilityId::new("model.present").expect("valid id"))
343 .with_where(AttrPredicate::has(
344 "shards",
345 AttrValue::Str(format!("{MODEL_MARKER_PREFIX}{package_id}")),
346 ))
347}
348
349pub fn locality_preference(body: &ModelPackageBody, package_id: &str, weight: f64) -> Preference {
353 Preference {
354 name: format!("model-local:{}", body.name),
355 requirement: model_present_requirement(package_id),
356 weight,
357 }
358}