Skip to main content

cortiq_core/format/
append.rs

1//! True tail append (spec §9.3; Patent 15 cl.3): a skill enters an existing
2//! file WITHOUT rewriting a byte of it.
3//!
4//! Layout after an append:
5//!
6//! ```text
7//! [0, 128)            envelope — rewritten IN PLACE, last
8//! [128, old_len)      untouched: old header, old directory, trunk blob,
9//!                     masks, vocab, index (all still addressed by offset)
10//! [old_len, …)        new tensor payloads (64-aligned relative to data_off,
11//!                     large ones page-aligned), then the new header JSON and
12//!                     the new directory (old entries byte-identical, new
13//!                     entries appended)
14//! ```
15//!
16//! `data_len` grows to cover the new payloads, so the old masks/vocab/index
17//! sections now lie INSIDE `[data_off, data_off + data_len)`. That is legal:
18//! every reader addresses sections only through the envelope, bounds are
19//! checked against the file and the data length only, and no reader checks
20//! that sections are disjoint (`parse_envelope`; `python/cmf_reader.py` does
21//! the same).
22//!
23//! Crash ordering: payloads → header → directory → fsync → envelope →
24//! fsync. Until the 128-byte envelope is written the file IS the old file
25//! (the tail is unreferenced bytes), so a crash at any point leaves either
26//! the old or the new consistent file. [`PendingAppend`] exposes the two
27//! phases so the "killed before the envelope" case is testable.
28
29use super::*;
30use crate::knowledge::{
31    KNOWLEDGE_BITS, seal_genome, skill_kind, validate_expert_append_values, validate_knowledge,
32};
33use std::fs::OpenOptions;
34
35/// What a tail append did.
36#[derive(Debug, Clone)]
37pub struct AppendReport {
38    pub path: PathBuf,
39    /// File length before the append: bytes `[128, old_len)` are unchanged.
40    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    /// The data segment the append added (None for header-only updates).
46    pub segment: Option<Segment>,
47    pub required_features: u32,
48    pub header_hash: u64,
49    pub dir_hash: u64,
50    /// `genome.trunk_hash` of the result (unchanged by construction).
51    pub trunk_hash: Option<u64>,
52}
53
54/// An append whose tail is written and fsynced but whose envelope is not:
55/// the file on disk is still the OLD file. [`PendingAppend::commit`]
56/// publishes it; dropping it abandons the tail (unreferenced bytes).
57pub 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    /// Rewrite the envelope in place and fsync, then re-open the result
70    /// with full validation. Should the re-open fail (a bug — the content
71    /// was validated before the tail was written), the old envelope is
72    /// restored and the error returned: the file is the old file again.
73    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
94/// One writer at a time (advisory `flock`, released when the handle drops).
95///
96/// A held lock is retried for up to 2 s before it counts as another writer:
97/// a flock lives on the open file description, so a child a sibling thread
98/// forks between our previous append's close and this open keeps a copy of
99/// the description (and the lock) until its exec closes it. A parallel test
100/// harness spawning the CLI hit exactly that on macOS ("another writer holds
101/// the append lock" right after our own append). A real concurrent writer
102/// still gets the error.
103fn 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            // SAFETY: plain fd + flags; advisory lock on an open handle.
110            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
135/// Open for append: take the lock, then validate the current file fully.
136fn 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
162/// Recompute the bits for new content: every bit the old file carried
163/// stays (a conservative writer may have set more than content implies),
164/// the three knowledge bits follow content exactly.
165fn 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    /// Append one skill record and its tensors to the END of `path`
197    /// (spec §9.3): the prefix `[128, old_len)` stays byte-identical, old
198    /// directory entries keep their offsets and hashes, the new header and
199    /// directory are published by rewriting the envelope last.
200    ///
201    /// Refuses a skill id that already exists (re-bake = new id or explicit
202    /// retire), tensors outside `skill.{id}.`, records bound to a base
203    /// directory (`base_dir_hash` — those are standalone skill files), and
204    /// anything `open()` would refuse afterwards (validated before a byte is
205    /// written). `router` / `routing`: `Some` replaces the header's value,
206    /// `None` keeps it (a stale router falls back to the backbone by its
207    /// skills hash). A `skill_committed` lineage event is pushed (the
208    /// caller's event if given; its `seq` is assigned here). For an
209    /// `ffn_replace` record without `state_effect`, the writer computes it
210    /// ([`ffn_replace_state_effect`]).
211    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    /// Phase 1 of [`Self::append_skill`]: payloads, header and directory
223    /// written at the tail and fsynced; the envelope is NOT touched, so the
224    /// file on disk still opens as the old one until
225    /// [`PendingAppend::commit`].
226    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        // Layout of the new payloads, relative to data_off.
267        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        // New header.
328        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        // The mask / sparse-index sections stay where they are: same bytes.
379        // `exec_hashes()`, not the `exec` field: `open()` fills the field
380        // only for GENOME files, and a header update may BIRTH the genome
381        // on a file that had none — sealed without its mask section, the
382        // committed file would re-open with "trunk_hash mismatch" (NF-1).
383        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        // Descriptor values of the new record from the payloads given;
401        // earlier records passed this when they were written.
402        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        // Lookup tables of the new record from the payloads given, of the
411        // earlier records from the file.
412        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        // Tail: payloads, header, directory — then fsync.
431        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    /// Header-only update through the same tail mechanism (router
485    /// recalibration, status/gate updates, lineage events): the new header
486    /// JSON goes to the end of the file, the directory and every payload
487    /// stay where they are, the envelope is rewritten last. `section_hashes`
488    /// and `segments` are layout facts and are kept from the current file
489    /// whatever `f` does; everything else must still pass `open()`'s rules
490    /// (a trunk change is refused by the genome hash).
491    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    /// Phase 1 of [`Self::update_header_append`] (see
499    /// [`Self::prepare_append_skill`]).
500    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        // The genome's identity is frozen: a header update may move its
517        // status or reference battery, never drop it or re-point it.
518        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        // The mask / sparse-index sections stay where they are: same bytes.
538        // `exec_hashes()`, not the `exec` field: `open()` fills the field
539        // only for GENOME files, and a header update may BIRTH the genome
540        // on a file that had none — sealed without its mask section, the
541        // committed file would re-open with "trunk_hash mismatch" (NF-1).
542        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/// What [`CmfModel::migrate_legacy_embryo_bits`] found (and, unless it was
606/// a dry run, did).
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum LegacyBitsMigration {
609    /// Not a CMF file.
610    NotCmf,
611    /// No pre-0.8.1 Embryo bits: nothing to move (already migrated, a real
612    /// Prism file, or a file that never carried them).
613    NothingToDo { required_features: u32 },
614    /// Bits 7/8 moved to 12/13 (`from` → `to`). A dry run reports the move
615    /// without writing it.
616    Migrated { from: u32, to: u32 },
617}
618
619impl CmfModel {
620    /// Move the `BOUNDED_STATE`/`GENOME` bits that a pre-0.8.1 embryo-o1
621    /// build wrote on 7/8 (today's `PRISM_HADAMARD`/`PRISM_AFFINE`) to
622    /// 12/13, IN PLACE: only envelope bytes 12..16 change. Neither the
623    /// header/directory hashes nor `trunk_hash` cover them, so every
624    /// appended record, segment and lineage event survives — which a
625    /// re-export would drop. A detached `.sig` signs the whole file and
626    /// must be renewed afterwards.
627    ///
628    /// Decided on content: bit 7 must match `arch.anchor_core`, bit 8 must
629    /// match `genome`, and a header with `arch.prism_hadamard` is a real
630    /// Prism file that is left alone. Takes the append lock. The result is
631    /// NOT re-validated here: the caller opens the file with
632    /// [`CmfModel::open`], which may still refuse a file written before
633    /// later format rules.
634    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}