Skip to main content

apr_format/v2/
stamp.rs

1//! APR v2 provenance stamping — SHIP-009 full-discharge enabler.
2//!
3//! Read an APR v2 file, patch its provenance metadata (`license`,
4//! `data_source`, `data_license`), re-serialize. Tensor bytes are copied
5//! verbatim; header flags (`QUANTIZED`, `HAS_VOCAB`, …) are preserved so
6//! round-tripping a quantized model does not silently drop the flag that
7//! downstream consumers branch on.
8//!
9//! Motivates: the 7B Q4_K teacher shipped at commit `06a3eae38` predates
10//! `GATE-APR-PROV-001/002/003` (commit `8f0607d42`), so its `.apr` has
11//! `license: None / data_source: None / data_license: None`. `apr inspect`
12//! renders those as `(missing)`, and `GATE-APR-PROV-004` — the algorithm
13//! gate for `AC-SHIP1-009` — rejects the `(None, None, None)` triple at
14//! full-discharge time. This helper closes the tooling gap; the release
15//! cycle (re-stamp → re-upload → manifest-sha256 refresh) is a follow-up.
16//!
17//! Contract reference: `contracts/apr-provenance-v1.yaml` (v1.1.0,
18//! GATE-APR-PROV-001..004). Spec reference:
19//! `docs/specifications/aprender-train/ship-two-models-spec.md` §4.2
20//! AC-SHIP1-009 + v2.52.0 amendment (teacher provenance gap).
21
22use super::{AprV2Reader, AprV2Writer, V2FormatError};
23
24/// In-place field patches. `None` means "leave unchanged"; `Some("")` is a
25/// legitimate explicit clear (not currently contract-approved but kept
26/// distinct from `None` so callers can express intent).
27///
28/// PMAT-690 P0-K extension (2026-05-17): `hf_architecture` and
29/// `hf_model_type` were added so pre-P0-K APRs can be patched in place
30/// without re-import. The §86 SPEC amendment surfaced this: P2-E's
31/// epoch-49 checkpoint (val_loss=4.62, the best MODEL-2 result on
32/// record) has architecture="LlamaForCausalLM" (the P0-H fallback) and
33/// hf_architecture=null because its init APR pre-dates P0-K. Without
34/// in-place stamping, the 50 P2-E checkpoints (~125 GB) are unusable
35/// as `--init` for resume training because apr pretrain reads the
36/// (wrong) architecture stamp and rejects the load. Stamping the
37/// correct hf_architecture + a corrected `architecture` family slug
38/// salvages the entire run without a 53-min retrain.
39#[derive(Debug, Clone, Default)]
40pub struct ProvenancePatch {
41    /// SPDX license identifier to stamp into the metadata.
42    pub license: Option<String>,
43    /// Training-data source (dataset identifier or "teacher-only").
44    pub data_source: Option<String>,
45    /// SPDX license for `data_source`.
46    pub data_license: Option<String>,
47    /// HuggingFace class name from `config.json::architectures[0]`
48    /// (e.g., "Qwen2ForCausalLM"). PMAT-690 P0-K extension.
49    pub hf_architecture: Option<String>,
50    /// HuggingFace `config.json::model_type` (e.g., "qwen2").
51    /// PMAT-690 P0-K extension.
52    pub hf_model_type: Option<String>,
53    /// Lowercase architecture family slug (e.g., "qwen2", "llama").
54    /// PMAT-690 P0-K extension. Distinct from `hf_architecture` (which
55    /// is the HF class name like "Qwen2ForCausalLM"). This is the
56    /// field that `apr pretrain --init` reads for arch dispatch, so
57    /// patching this is what makes a pre-P0-K checkpoint resumable.
58    pub architecture: Option<String>,
59    /// Tokenizer vocabulary (token strings indexed by token-id). When
60    /// `Some`, the stamp embeds these strings into
61    /// `metadata.custom["tokenizer.vocabulary"]` (as a JSON array)
62    /// AND sets the HAS_VOCAB header flag — making the resulting APR
63    /// self-contained for `apr run` inference (which rejects APRs
64    /// without an embedded tokenizer per PMAT-172).
65    ///
66    /// PMAT-690 P3-C-prep follow-up (2026-05-17, defect 1 from
67    /// publish-readiness preflight on P2-E ep49): pre-P0-K APRs lack
68    /// embedded tokenizers because the training init didn't have
69    /// one. Without this stamp extension, the §86 salvage produces a
70    /// 6.0 GB HF-publish-ready directory that fails the headline
71    /// `apr run` smoke test.
72    pub tokenizer_vocab: Option<Vec<String>>,
73    /// BPE merge rules (e.g., `["Ä t", "i n", ...]`). When
74    /// `Some`, embedded into `metadata.custom["tokenizer.merges"]`.
75    pub tokenizer_merges: Option<Vec<String>>,
76    /// Tokenizer model type (e.g., "BPE", "Unigram"). Optional metadata
77    /// for `apr inspect` to surface.
78    pub tokenizer_model_type: Option<String>,
79}
80
81impl ProvenancePatch {
82    /// `true` iff at least one field would change. Guards against a
83    /// no-op rewrite producing a pointless new file.
84    #[must_use]
85    pub fn has_any(&self) -> bool {
86        self.license.is_some()
87            || self.data_source.is_some()
88            || self.data_license.is_some()
89            || self.hf_architecture.is_some()
90            || self.hf_model_type.is_some()
91            || self.architecture.is_some()
92            || self.tokenizer_vocab.is_some()
93            || self.tokenizer_merges.is_some()
94            || self.tokenizer_model_type.is_some()
95    }
96}
97
98/// Patch provenance metadata on an existing APR v2 buffer and return the
99/// re-serialized bytes.
100///
101/// # Errors
102/// Returns `V2FormatError::InvalidHeader` if:
103///   - `input` is not a valid APR v2 buffer (propagated from
104///     `AprV2Reader::from_bytes`)
105///   - `patch.has_any()` is `false` — a no-op stamp is rejected
106///     up-front so callers cannot accidentally rewrite without
107///     changing the artifact
108///
109/// # Guarantees
110///   - Header flags from `input` are preserved in the output (LAYOUT_ROW_MAJOR
111///     is always added regardless of input, per LAYOUT-002 jidoka)
112///   - Tensor bytes are copied verbatim — no quantize/dequantize round-trip
113///   - Sort-by-name ordering matches `AprV2Writer::write()` (tensor index
114///     is sorted, so the re-serialized index is canonical)
115///
116/// # Non-guarantees
117///   - Footer checksum WILL change (metadata bytes moved)
118///   - sha256 of the output file WILL differ from the input (by design —
119///     that is the whole point of a stamp operation)
120pub fn stamp_provenance_bytes(
121    input: &[u8],
122    patch: &ProvenancePatch,
123) -> Result<Vec<u8>, V2FormatError> {
124    if !patch.has_any() {
125        return Err(V2FormatError::InvalidHeader(
126            "stamp_provenance_bytes: patch has no fields set — \
127             refusing to rewrite without changes"
128                .to_string(),
129        ));
130    }
131
132    let reader = AprV2Reader::from_bytes(input)?;
133
134    let original_flags = reader.header().flags;
135    let mut new_metadata = reader.metadata().clone();
136
137    if let Some(ref lic) = patch.license {
138        new_metadata.license = Some(lic.clone());
139    }
140    if let Some(ref ds) = patch.data_source {
141        new_metadata.data_source = Some(ds.clone());
142    }
143    if let Some(ref dl) = patch.data_license {
144        new_metadata.data_license = Some(dl.clone());
145    }
146    // PMAT-690 P0-K extension: HF identity + architecture family
147    if let Some(ref ha) = patch.hf_architecture {
148        new_metadata.hf_architecture = Some(ha.clone());
149    }
150    if let Some(ref hmt) = patch.hf_model_type {
151        new_metadata.hf_model_type = Some(hmt.clone());
152    }
153    if let Some(ref arch) = patch.architecture {
154        new_metadata.architecture = Some(arch.clone());
155    }
156    // PMAT-690 P3-C-prep follow-up (defect 1): embed tokenizer into the
157    // custom JSON metadata. Mirrors the apr-import path's behaviour
158    // (`insert_f32_tokenizer_metadata` in converter::write); we duplicate
159    // the key names here so a stamped APR has the same shape as a
160    // freshly-imported one.
161    let mut set_has_vocab = false;
162    if let Some(ref vocab) = patch.tokenizer_vocab {
163        if !vocab.is_empty() {
164            let vocab_array: Vec<serde_json::Value> = vocab
165                .iter()
166                .map(|s| serde_json::Value::String(s.clone()))
167                .collect();
168            new_metadata.custom.insert(
169                "tokenizer.vocabulary".to_string(),
170                serde_json::Value::Array(vocab_array),
171            );
172            new_metadata.custom.insert(
173                "tokenizer.vocab_size".to_string(),
174                serde_json::Value::Number(serde_json::Number::from(vocab.len())),
175            );
176            set_has_vocab = true;
177        }
178    }
179    if let Some(ref merges) = patch.tokenizer_merges {
180        if !merges.is_empty() {
181            let merges_array: Vec<serde_json::Value> = merges
182                .iter()
183                .map(|s| serde_json::Value::String(s.clone()))
184                .collect();
185            new_metadata.custom.insert(
186                "tokenizer.merges".to_string(),
187                serde_json::Value::Array(merges_array),
188            );
189        }
190    }
191    if let Some(ref mt) = patch.tokenizer_model_type {
192        new_metadata.custom.insert(
193            "tokenizer.model_type".to_string(),
194            serde_json::Value::String(mt.clone()),
195        );
196    }
197
198    // PMAT-172 (defect 1 root cause): `apr run` checks the HAS_VOCAB
199    // flag before allowing inference. Setting tokenizer_vocab without
200    // setting the flag would still surface the "missing embedded tokenizer"
201    // error.
202    let effective_flags = if set_has_vocab {
203        original_flags.with(super::AprV2Flags::HAS_VOCAB)
204    } else {
205        original_flags
206    };
207
208    let mut writer = AprV2Writer::new(new_metadata);
209    writer.set_header_flags(effective_flags);
210
211    // Copy every tensor by name; AprV2Writer sorts by name internally on
212    // write(), so input ordering is irrelevant here.
213    for name in reader.tensor_names() {
214        let entry = reader
215            .get_tensor(name)
216            .ok_or_else(|| V2FormatError::InvalidHeader(format!("tensor {name} vanished")))?;
217        let data = reader
218            .get_tensor_data(name)
219            .ok_or_else(|| V2FormatError::InvalidHeader(format!("tensor {name} has no data")))?;
220        writer.add_tensor(
221            name.to_string(),
222            entry.dtype,
223            entry.shape.clone(),
224            data.to_vec(),
225        );
226    }
227
228    writer.write()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::super::{AprV2Flags, AprV2Metadata, TensorDType};
234    use super::*;
235
236    /// Build a minimal valid APR v2 buffer for round-trip tests.
237    fn minimal_apr_with_flags(flags: u16) -> Vec<u8> {
238        let metadata = AprV2Metadata::new("stamp-test");
239        let mut writer = AprV2Writer::new(metadata);
240        writer.set_header_flags(AprV2Flags::from_bits(flags));
241        writer.add_tensor(
242            "weight",
243            TensorDType::F32,
244            vec![2, 3],
245            vec![0u8; 24], // 2 * 3 * 4 bytes
246        );
247        writer.write().expect("write test apr")
248    }
249
250    #[test]
251    fn stamp_populates_all_three_fields_when_source_is_unpopulated() {
252        let input = minimal_apr_with_flags(0);
253        let patch = ProvenancePatch {
254            license: Some("Apache-2.0".into()),
255            data_source: Some("huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct".into()),
256            data_license: Some("Qwen-License-Agreement-v1".into()),
257            hf_architecture: None,
258            hf_model_type: None,
259            architecture: None,
260            tokenizer_vocab: None,
261            tokenizer_merges: None,
262            tokenizer_model_type: None,
263        };
264
265        let output = stamp_provenance_bytes(&input, &patch).expect("stamp must succeed");
266
267        let reader = AprV2Reader::from_bytes(&output).expect("stamped buffer must parse");
268        let md = reader.metadata();
269        assert_eq!(md.license.as_deref(), Some("Apache-2.0"));
270        assert_eq!(
271            md.data_source.as_deref(),
272            Some("huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct")
273        );
274        assert_eq!(
275            md.data_license.as_deref(),
276            Some("Qwen-License-Agreement-v1")
277        );
278    }
279
280    #[test]
281    fn stamp_preserves_tensor_data_byte_for_byte() {
282        let input = minimal_apr_with_flags(0);
283        let input_reader = AprV2Reader::from_bytes(&input).unwrap();
284        let original_bytes: Vec<u8> = input_reader
285            .get_tensor_data("weight")
286            .expect("input has weight")
287            .to_vec();
288
289        let patch = ProvenancePatch {
290            license: Some("MIT".into()),
291            ..Default::default()
292        };
293        let output = stamp_provenance_bytes(&input, &patch).unwrap();
294
295        let out_reader = AprV2Reader::from_bytes(&output).unwrap();
296        let round_tripped = out_reader
297            .get_tensor_data("weight")
298            .expect("output has weight");
299
300        assert_eq!(
301            original_bytes.as_slice(),
302            round_tripped,
303            "tensor bytes must survive stamp verbatim"
304        );
305    }
306
307    #[test]
308    fn stamp_preserves_header_flags() {
309        // Simulate a quantized source: set QUANTIZED | HAS_VOCAB on input.
310        let flags = AprV2Flags::QUANTIZED | AprV2Flags::HAS_VOCAB;
311        let input = minimal_apr_with_flags(flags);
312
313        let in_reader = AprV2Reader::from_bytes(&input).unwrap();
314        assert!(in_reader.header().flags.contains(AprV2Flags::QUANTIZED));
315        assert!(in_reader.header().flags.contains(AprV2Flags::HAS_VOCAB));
316
317        let patch = ProvenancePatch {
318            license: Some("Apache-2.0".into()),
319            ..Default::default()
320        };
321        let output = stamp_provenance_bytes(&input, &patch).unwrap();
322
323        let out_reader = AprV2Reader::from_bytes(&output).unwrap();
324        // Input flags preserved:
325        assert!(
326            out_reader.header().flags.contains(AprV2Flags::QUANTIZED),
327            "QUANTIZED flag must survive stamp"
328        );
329        assert!(
330            out_reader.header().flags.contains(AprV2Flags::HAS_VOCAB),
331            "HAS_VOCAB flag must survive stamp"
332        );
333        // LAYOUT-002 jidoka still engaged:
334        assert!(
335            out_reader
336                .header()
337                .flags
338                .contains(AprV2Flags::LAYOUT_ROW_MAJOR),
339            "LAYOUT_ROW_MAJOR must always be set"
340        );
341    }
342
343    #[test]
344    fn stamp_rejects_empty_patch() {
345        let input = minimal_apr_with_flags(0);
346        let empty = ProvenancePatch::default();
347        let err = stamp_provenance_bytes(&input, &empty).unwrap_err();
348        let msg = format!("{err:?}");
349        assert!(
350            msg.contains("patch has no fields"),
351            "empty-patch error must be explicit: {msg}"
352        );
353    }
354
355    #[test]
356    fn stamp_allows_partial_patch_leaving_other_fields_unchanged() {
357        // Input already has a license (pretend) but no data_* fields.
358        let mut md = AprV2Metadata::new("partial-test");
359        md.license = Some("Apache-2.0".into());
360        let mut writer = AprV2Writer::new(md);
361        writer.add_tensor("w", TensorDType::F32, vec![4], vec![0u8; 16]);
362        let input = writer.write().unwrap();
363
364        // Only patch data_source.
365        let patch = ProvenancePatch {
366            data_source: Some("teacher-only".into()),
367            ..Default::default()
368        };
369        let output = stamp_provenance_bytes(&input, &patch).unwrap();
370
371        let out_reader = AprV2Reader::from_bytes(&output).unwrap();
372        assert_eq!(
373            out_reader.metadata().license.as_deref(),
374            Some("Apache-2.0"),
375            "unchanged license must survive"
376        );
377        assert_eq!(
378            out_reader.metadata().data_source.as_deref(),
379            Some("teacher-only"),
380            "patched data_source must land"
381        );
382        assert!(
383            out_reader.metadata().data_license.is_none(),
384            "untouched data_license must remain None"
385        );
386    }
387
388    #[test]
389    fn stamp_is_idempotent_under_identical_patch() {
390        let input = minimal_apr_with_flags(0);
391        let patch = ProvenancePatch {
392            license: Some("Apache-2.0".into()),
393            data_source: Some("teacher-only".into()),
394            data_license: Some("Apache-2.0".into()),
395            hf_architecture: None,
396            hf_model_type: None,
397            architecture: None,
398            tokenizer_vocab: None,
399            tokenizer_merges: None,
400            tokenizer_model_type: None,
401        };
402
403        let first = stamp_provenance_bytes(&input, &patch).unwrap();
404        let second = stamp_provenance_bytes(&first, &patch).unwrap();
405        assert_eq!(
406            first, second,
407            "applying the same patch twice must be byte-identical (idempotent)"
408        );
409    }
410}