1use super::*;
30use crate::knowledge::{
31 KNOWLEDGE_BITS, seal_genome, skill_kind, validate_expert_append_values, validate_knowledge,
32};
33use std::fs::OpenOptions;
34
35#[derive(Debug, Clone)]
37pub struct AppendReport {
38 pub path: PathBuf,
39 pub old_len: u64,
41 pub new_len: u64,
42 pub data_len_before: u64,
43 pub data_len_after: u64,
44 pub tensors_added: usize,
45 pub segment: Option<Segment>,
47 pub required_features: u32,
48 pub header_hash: u64,
49 pub dir_hash: u64,
50 pub trunk_hash: Option<u64>,
52}
53
54pub struct PendingAppend {
58 file: File,
59 envelope: [u8; ENVELOPE_LEN],
60 old_envelope: [u8; ENVELOPE_LEN],
61 report: AppendReport,
62}
63
64impl PendingAppend {
65 pub fn report(&self) -> &AppendReport {
66 &self.report
67 }
68
69 pub fn commit(mut self) -> Result<AppendReport, CmfError> {
74 write_envelope(&mut self.file, &self.envelope)?;
75 match CmfModel::open(&self.report.path) {
76 Ok(_) => Ok(self.report),
77 Err(e) => {
78 write_envelope(&mut self.file, &self.old_envelope)?;
79 Err(CmfError::Parse(format!(
80 "append rolled back — the committed file did not re-open: {e}"
81 )))
82 }
83 }
84 }
85}
86
87fn write_envelope(file: &mut File, env: &[u8; ENVELOPE_LEN]) -> Result<(), CmfError> {
88 file.seek(SeekFrom::Start(0))?;
89 file.write_all(env)?;
90 file.sync_all()?;
91 Ok(())
92}
93
94fn lock_exclusive(file: &File, path: &Path) -> Result<(), CmfError> {
104 #[cfg(unix)]
105 {
106 use std::os::unix::io::AsRawFd;
107 const TRIES: u32 = 50;
108 for attempt in 0..TRIES {
109 let r = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
111 if r == 0 {
112 break;
113 }
114 let err = std::io::Error::last_os_error();
115 let busy = err.raw_os_error() == Some(libc::EWOULDBLOCK);
116 if !busy || attempt + 1 == TRIES {
117 return Err(CmfError::Parse(format!(
118 "{}: another writer holds the append lock{}",
119 path.display(),
120 if busy {
121 String::new()
122 } else {
123 format!(" ({err})")
124 }
125 )));
126 }
127 std::thread::sleep(std::time::Duration::from_millis(40));
128 }
129 }
130 #[cfg(not(unix))]
131 let _ = (file, path);
132 Ok(())
133}
134
135fn open_for_append(path: &Path) -> Result<(File, CmfModel, u64), CmfError> {
137 let file = OpenOptions::new().read(true).write(true).open(path)?;
138 lock_exclusive(&file, path)?;
139 let model = CmfModel::open(path)?;
140 if model.header.shard.is_some() {
141 return Err(CmfError::Parse(format!(
142 "{}: tail append into a sharded file is not supported",
143 path.display()
144 )));
145 }
146 if model.required_features & features::SKILL_FILE != 0 {
147 return Err(CmfError::Parse(format!(
148 "{}: a standalone SKILL_FILE takes no appends — append into a runnable model",
149 path.display()
150 )));
151 }
152 let file_len = file.metadata()?.len();
153 Ok((file, model, file_len))
154}
155
156fn envelope_bytes(model: &CmfModel) -> [u8; ENVELOPE_LEN] {
157 let mut e = [0u8; ENVELOPE_LEN];
158 e.copy_from_slice(&model.primary_bytes()[..ENVELOPE_LEN]);
159 e
160}
161
162fn appended_bits(old: u32, header: &CmfHeader, entries: &[TensorEntry], masks: bool) -> u32 {
166 let derived = derive_required_features(header, entries, masks);
167 ((old | derived) & !KNOWLEDGE_BITS) | (derived & KNOWLEDGE_BITS)
168}
169
170fn next_lineage(
171 header: &CmfHeader,
172 ev: Option<LineageEvent>,
173 default: &str,
174 detail: serde_json::Value,
175) -> LineageEvent {
176 let seq = header.lineage.last().map(|e| e.seq + 1).unwrap_or(0);
177 match ev {
178 Some(mut e) => {
179 e.seq = seq;
180 if e.event.is_empty() {
181 e.event = default.into();
182 }
183 if e.ts.is_empty() {
184 e.ts = crate::knowledge::utc_now_rfc3339();
185 }
186 if e.detail.is_null() {
187 e.detail = detail;
188 }
189 e
190 }
191 None => LineageEvent::now(seq, default, detail),
192 }
193}
194
195impl CmfModel {
196 pub fn append_skill(
212 path: impl AsRef<Path>,
213 record: SkillRecord,
214 tensors: &[TensorSpec],
215 router: Option<RouterPolicy>,
216 routing: Option<RoutingCalibration>,
217 lineage_event: Option<LineageEvent>,
218 ) -> Result<AppendReport, CmfError> {
219 Self::prepare_append_skill(path, record, tensors, router, routing, lineage_event)?.commit()
220 }
221
222 pub fn prepare_append_skill(
227 path: impl AsRef<Path>,
228 mut record: SkillRecord,
229 tensors: &[TensorSpec],
230 router: Option<RouterPolicy>,
231 routing: Option<RoutingCalibration>,
232 lineage_event: Option<LineageEvent>,
233 ) -> Result<PendingAppend, CmfError> {
234 let path = path.as_ref();
235 let (mut file, model, file_len) = open_for_append(path)?;
236 let env = model.envelope;
237 let id = record.id.clone();
238
239 if id.is_empty() {
240 return Err(CmfError::Parse("append: the skill record has no id".into()));
241 }
242 if record.base_dir_hash.is_some() {
243 return Err(CmfError::Parse(format!(
244 "append: skill '{id}' carries base_dir_hash — that makes a standalone skill \
245 file, not a record inside a model"
246 )));
247 }
248 if model.header.skills.iter().any(|s| s.id == id) {
249 return Err(CmfError::Parse(format!(
250 "append: skill '{id}' already exists — re-bake under a new id or retire it"
251 )));
252 }
253 let prefix = format!("skill.{id}.");
254 if model.tensors.iter().any(|t| t.name.starts_with(&prefix)) {
255 return Err(CmfError::Parse(format!(
256 "append: the directory already holds '{prefix}*' tensors"
257 )));
258 }
259 if !tensors.is_empty() && (env.data.0 % DATA_ALIGNMENT != 0 || env.data.0 > file_len) {
260 return Err(CmfError::Parse(format!(
261 "append: data section offset {} cannot anchor tail payloads",
262 env.data.0
263 )));
264 }
265
266 let data_off = env.data.0;
268 let mut cursor = file_len.saturating_sub(data_off).max(env.data.1);
269 let mut entries = model.tensors.clone();
270 let mut new_entries = Vec::with_capacity(tensors.len());
271 for t in tensors {
272 if !t.name.starts_with(&prefix) || t.name.len() == prefix.len() {
273 return Err(CmfError::Parse(format!(
274 "append: tensor '{}' is outside the skill's namespace '{prefix}'",
275 t.name
276 )));
277 }
278 if t.shape.len() > DIR_MAX_NDIM {
279 return Err(CmfError::Parse(format!(
280 "tensor '{}': ndim {} > {DIR_MAX_NDIM}",
281 t.name,
282 t.shape.len()
283 )));
284 }
285 if let Some(expect) = expected_nbytes(t.dtype, &t.shape) {
286 if expect != t.data.len() {
287 return Err(CmfError::Bounds(format!(
288 "tensor '{}': data {} bytes != expected {expect} for {:?}{:?}",
289 t.name,
290 t.data.len(),
291 t.dtype,
292 t.shape
293 )));
294 }
295 }
296 if new_entries.iter().any(|e: &TensorEntry| e.name == t.name) {
297 return Err(CmfError::Parse(format!(
298 "append: duplicate tensor '{}'",
299 t.name
300 )));
301 }
302 let align = if t.data.len() as u64 >= LARGE_TENSOR_MIN {
303 LARGE_TENSOR_ALIGN
304 } else {
305 TENSOR_ALIGNMENT
306 };
307 let off = align_to(cursor, align);
308 new_entries.push(TensorEntry {
309 name: t.name.clone(),
310 dtype: t.dtype,
311 shape: t.shape.clone(),
312 off,
313 nbytes: t.data.len() as u64,
314 shard: 0,
315 hash: hash64(&t.data),
316 });
317 cursor = off + t.data.len() as u64;
318 }
319 let data_len_before = env.data.1;
320 let data_len_after = if tensors.is_empty() {
321 env.data.1
322 } else {
323 cursor
324 };
325 entries.extend(new_entries.iter().cloned());
326
327 let mut header = model.header.clone();
329 if record.state_effect.is_none() {
330 match record.kind.as_deref() {
331 Some(skill_kind::FFN_REPLACE) => {
332 record.state_effect =
333 Some(ffn_replace_state_effect(&header.arch, &record.layers));
334 }
335 Some(skill_kind::EXPERT_APPEND) => {
336 record.state_effect = Some(expert_append_state_effect(&record.layers));
337 }
338 _ => {}
339 }
340 }
341 header.skills.push(record);
342 if let Some(r) = router {
343 header.router = Some(r);
344 }
345 if let Some(c) = routing {
346 header.routing = Some(c);
347 }
348 if header.segments.is_empty() {
349 let (kind, sid) = match &header.genome {
350 Some(g) => ("genome", g.id.clone()),
351 None => ("base", header.arch.arch_name.clone()),
352 };
353 header.segments.push(Segment {
354 kind: kind.into(),
355 id: sid,
356 data_start: 0,
357 data_end: data_len_before,
358 });
359 }
360 let segment = new_entries.first().map(|first| Segment {
361 kind: "skill".into(),
362 id: id.clone(),
363 data_start: first.off,
364 data_end: data_len_after,
365 });
366 if let Some(s) = &segment {
367 header.segments.push(s.clone());
368 }
369 let detail = serde_json::json!({
370 "skill": id,
371 "tensors": new_entries.len(),
372 "data": segment.as_ref().map(|s| [s.data_start, s.data_end]),
373 });
374 let ev = next_lineage(&header, lineage_event, "skill_committed", detail);
375 header.lineage.push(ev);
376
377 let vocab = model.vocab.as_deref();
378 let exec = model.exec_hashes();
384 seal_genome(&mut header, &entries, vocab, exec)?;
385 let bits = appended_bits(env.required_features, &header, &entries, env.masks.1 > 0);
386 header
387 .arch
388 .validate_operator_metadata()
389 .map_err(CmfError::Parse)?;
390 validate_knowledge(
391 &header,
392 &entries,
393 vocab,
394 bits,
395 Some(data_len_after),
396 None,
397 exec,
398 )?;
399 crate::knowledge::check_lookup_policies(&header)?;
400 let n_old = model.tensors.len();
403 validate_expert_append_values(&header, &entries, |e| {
404 let i = entries.iter().position(|x| std::ptr::eq(x, e))?;
405 match i.checked_sub(n_old) {
406 Some(j) => crate::knowledge::f32_le_head(&tensors[j].data),
407 None => crate::knowledge::f32_le_head(model.entry_bytes(e)),
408 }
409 })?;
410 crate::knowledge::validate_lookup_values(&header, &entries, true, |e| {
413 let i = entries.iter().position(|x| std::ptr::eq(x, e))?;
414 Some(std::borrow::Cow::Borrowed(match i.checked_sub(n_old) {
415 Some(j) => tensors[j].data.as_slice(),
416 None => model.entry_bytes(e),
417 }))
418 })?;
419
420 let header_json =
421 serde_json::to_vec(&header).map_err(|e| CmfError::Parse(format!("header: {e}")))?;
422 let dir_bytes = Self::encode_directory(&entries);
423 let old_envelope = envelope_bytes(&model);
424 let trunk = header
425 .genome
426 .as_ref()
427 .and_then(|g| crate::knowledge::parse_hex64(&g.trunk_hash));
428 drop(model);
429
430 file.seek(SeekFrom::Start(file_len))?;
432 let (header_off, dir_off, end) = {
433 let mut w = BufWriter::new(&mut file);
434 let mut pos = file_len;
435 for (spec, entry) in tensors.iter().zip(&new_entries) {
436 let target = data_off + entry.off;
437 w.write_all(&zeros((target - pos) as usize))?;
438 w.write_all(&spec.data)?;
439 pos = target + spec.data.len() as u64;
440 }
441 let header_off = pos;
442 w.write_all(&header_json)?;
443 let dir_off = header_off + header_json.len() as u64;
444 w.write_all(&dir_bytes)?;
445 w.flush()?;
446 (header_off, dir_off, dir_off + dir_bytes.len() as u64)
447 };
448 file.sync_all()?;
449
450 let mut envelope = old_envelope;
451 let put = |e: &mut [u8; ENVELOPE_LEN], at: usize, v: u64| {
452 e[at..at + 8].copy_from_slice(&v.to_le_bytes())
453 };
454 envelope[12..16].copy_from_slice(&bits.to_le_bytes());
455 put(&mut envelope, 0x10, header_off);
456 put(&mut envelope, 0x18, header_json.len() as u64);
457 put(&mut envelope, 0x20, dir_off);
458 put(&mut envelope, 0x28, dir_bytes.len() as u64);
459 put(&mut envelope, 0x38, data_len_after);
460 let (header_hash, dir_hash) = (hash64(&header_json), hash64(&dir_bytes));
461 put(&mut envelope, 0x70, header_hash);
462 put(&mut envelope, 0x78, dir_hash);
463
464 Ok(PendingAppend {
465 file,
466 envelope,
467 old_envelope,
468 report: AppendReport {
469 path: path.to_path_buf(),
470 old_len: file_len,
471 new_len: end,
472 data_len_before,
473 data_len_after,
474 tensors_added: new_entries.len(),
475 segment,
476 required_features: bits,
477 header_hash,
478 dir_hash,
479 trunk_hash: trunk,
480 },
481 })
482 }
483
484 pub fn update_header_append(
492 path: impl AsRef<Path>,
493 f: impl FnOnce(&mut CmfHeader),
494 ) -> Result<AppendReport, CmfError> {
495 Self::prepare_update_header_append(path, f)?.commit()
496 }
497
498 pub fn prepare_update_header_append(
501 path: impl AsRef<Path>,
502 f: impl FnOnce(&mut CmfHeader),
503 ) -> Result<PendingAppend, CmfError> {
504 let path = path.as_ref();
505 let (mut file, model, file_len) = open_for_append(path)?;
506 let env = model.envelope;
507 let mut header = model.header.clone();
508 f(&mut header);
509 header.section_hashes = model.header.section_hashes.clone();
510 header.segments = model.header.segments.clone();
511 if header.shard.is_some() {
512 return Err(CmfError::Parse(
513 "update_header_append: cannot make a file sharded".into(),
514 ));
515 }
516 if let Some(old) = &model.header.genome {
519 let same = header.genome.as_ref().is_some_and(|g| {
520 g.id == old.id
521 && g.generation == old.generation
522 && g.trunk_hash == old.trunk_hash
523 && g.master_trunk_hash == old.master_trunk_hash
524 && g.encoding == old.encoding
525 && g.parent == old.parent
526 });
527 if !same {
528 return Err(CmfError::Parse(format!(
529 "update_header_append: genome '{}' identity (id, generation, hashes, \
530 encoding, parent) cannot change or be removed by a header update — a \
531 new genome is a new file",
532 old.id
533 )));
534 }
535 }
536 let vocab = model.vocab.as_deref();
537 let exec = model.exec_hashes();
543 seal_genome(&mut header, &model.tensors, vocab, exec)?;
544 let bits = appended_bits(
545 env.required_features,
546 &header,
547 &model.tensors,
548 env.masks.1 > 0,
549 );
550 header
551 .arch
552 .validate_operator_metadata()
553 .map_err(CmfError::Parse)?;
554 validate_knowledge(
555 &header,
556 &model.tensors,
557 vocab,
558 bits,
559 Some(env.data.1),
560 None,
561 exec,
562 )?;
563 crate::knowledge::check_lookup_policies(&header)?;
564 let header_json =
565 serde_json::to_vec(&header).map_err(|e| CmfError::Parse(format!("header: {e}")))?;
566 let old_envelope = envelope_bytes(&model);
567 let trunk = header
568 .genome
569 .as_ref()
570 .and_then(|g| crate::knowledge::parse_hex64(&g.trunk_hash));
571 drop(model);
572
573 file.seek(SeekFrom::Start(file_len))?;
574 file.write_all(&header_json)?;
575 file.sync_all()?;
576
577 let mut envelope = old_envelope;
578 envelope[12..16].copy_from_slice(&bits.to_le_bytes());
579 envelope[0x10..0x18].copy_from_slice(&file_len.to_le_bytes());
580 envelope[0x18..0x20].copy_from_slice(&(header_json.len() as u64).to_le_bytes());
581 let header_hash = hash64(&header_json);
582 envelope[0x70..0x78].copy_from_slice(&header_hash.to_le_bytes());
583
584 Ok(PendingAppend {
585 file,
586 envelope,
587 old_envelope,
588 report: AppendReport {
589 path: path.to_path_buf(),
590 old_len: file_len,
591 new_len: file_len + header_json.len() as u64,
592 data_len_before: env.data.1,
593 data_len_after: env.data.1,
594 tensors_added: 0,
595 segment: None,
596 required_features: bits,
597 header_hash,
598 dir_hash: env.dir_hash,
599 trunk_hash: trunk,
600 },
601 })
602 }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum LegacyBitsMigration {
609 NotCmf,
611 NothingToDo { required_features: u32 },
614 Migrated { from: u32, to: u32 },
617}
618
619impl CmfModel {
620 pub fn migrate_legacy_embryo_bits(
635 path: impl AsRef<Path>,
636 dry_run: bool,
637 ) -> Result<LegacyBitsMigration, CmfError> {
638 use super::legacy_embryo_bits as legacy;
639 use std::io::{Seek, SeekFrom, Write};
640 let path = path.as_ref();
641 let file = if dry_run {
642 File::open(path)?
643 } else {
644 let f = OpenOptions::new().read(true).write(true).open(path)?;
645 lock_exclusive(&f, path)?;
646 f
647 };
648 let Some((required, header)) = peek_header(path)? else {
649 return Ok(LegacyBitsMigration::NotCmf);
650 };
651 let set = |v: Option<&serde_json::Value>| v.is_some_and(|x| !x.is_null());
652 let arch = header.get("arch");
653 let has_prism = set(arch.and_then(|a| a.get("prism_hadamard")));
654 let has_anchor = set(arch.and_then(|a| a.get("anchor_core")));
655 let has_genome = set(header.get("genome"));
656 if required & legacy::BOTH == 0 || has_prism {
657 return Ok(LegacyBitsMigration::NothingToDo {
658 required_features: required,
659 });
660 }
661 if !has_anchor && !has_genome {
662 return Err(CmfError::Parse(format!(
663 "{}: bits {:#x} are set but the header has no prism_hadamard, anchor_core or \
664 genome — not a pre-0.8.1 Embryo file, refusing to guess",
665 path.display(),
666 required & legacy::BOTH
667 )));
668 }
669 let bit7 = required & legacy::BOUNDED_STATE != 0;
670 let bit8 = required & legacy::GENOME != 0;
671 if bit7 != has_anchor || bit8 != has_genome {
672 return Err(CmfError::Parse(format!(
673 "{}: legacy bits disagree with the header (bit 7={bit7} vs anchor_core={has_anchor}, \
674 bit 8={bit8} vs genome={has_genome}) — refusing to guess",
675 path.display()
676 )));
677 }
678 if required & (features::BOUNDED_STATE | features::GENOME) != 0 {
679 return Err(CmfError::Parse(format!(
680 "{}: carries both the old (7/8) and the new (12/13) bits ({required:#x}) — \
681 refusing to guess",
682 path.display()
683 )));
684 }
685 let mut to = required & !legacy::BOTH;
686 if has_anchor {
687 to |= features::BOUNDED_STATE;
688 }
689 if has_genome {
690 to |= features::GENOME;
691 }
692 if !dry_run {
693 let mut f = file;
694 f.seek(SeekFrom::Start(12))?;
695 f.write_all(&to.to_le_bytes())?;
696 f.sync_all()?;
697 }
698 Ok(LegacyBitsMigration::Migrated { from: required, to })
699 }
700}