Skip to main content

ratel_ai_core/
embedding_artifact.rs

1//! Build-time embedding artifact: corpus vectors serialized for runtime load
2//! without re-inferring.
3//!
4//! Registries build single-kind bytes; [`merge_embedding_artifacts`] combines
5//! them into one mixed Tool+Skill RAT1. Warming ignores other known kinds.
6//! Model identity in the header is the artifact-scoped identity from
7//! [`Embedder::embed_batch_with_artifact_identity`] (Endpoint keeps batch-resolved
8//! response identity; Local uses a portable content digest).
9
10use sha2::{Digest, Sha256};
11
12use crate::dense_cache::Embeddable;
13use crate::embedding::{Embedded, Embedder, EmbedderError};
14
15const MAGIC: &[u8; 4] = b"RAT1";
16const SUPPORTED_FORMAT_VERSION: u32 = 1;
17const VERSION_PREFIX_LEN: usize = 8;
18const FILE_PREFIX_LEN: usize = 4 + 4 + 8 + 32;
19
20/// Catalog origin of an artifact entry (tool vs skill registry).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ArtifactEntryKind {
23    /// Entry built from a tool catalog item.
24    Tool,
25    /// Entry built from a skill catalog item.
26    Skill,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub(crate) struct ArtifactHeader {
31    pub format_version: u32,
32    /// Hash-derived version of searchable-text projection logic at build time
33    pub projection_version: u32,
34    /// Resolved embedder identity stamped on every vector in the artifact
35    pub model_fingerprint: String,
36    /// Common width of every vector in the artifact
37    pub dim: usize,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub(crate) struct ArtifactEntry {
42    pub kind: ArtifactEntryKind,
43    /// Stable catalog id ([`Embeddable::embed_id`])
44    pub id: String,
45    /// SHA-256 of the projection text at build time, the text itself is not stored
46    pub projection_hash: [u8; 32],
47    /// L2-normalized embedding of the item's projection text
48    pub vector: Vec<f32>,
49}
50
51/// Failure building or loading a binary embedding artifact.
52#[derive(Debug, Clone)]
53pub enum ArtifactError {
54    /// File shorter than the format prefix or declared payload length.
55    TooShort {
56        /// Minimum bytes required at this check.
57        needed: usize,
58        /// Bytes actually available.
59        got: usize,
60    },
61    /// Magic bytes were not `RAT1`.
62    InvalidMagic {
63        /// Four bytes read where the magic should be.
64        got: [u8; 4],
65    },
66    /// `format_version` is newer than this binary supports.
67    UnsupportedFormatVersion {
68        /// Version stamped in the file.
69        found: u32,
70        /// Highest version this binary can read.
71        supported: u32,
72    },
73    /// Payload SHA-256 did not match the checksum in the file prefix.
74    ChecksumMismatch,
75    /// Payload bytes failed structural decode after a valid checksum.
76    CorruptPayload {
77        /// Byte offset into the payload where decode failed.
78        at: usize,
79        /// Human-readable reason for the failure.
80        detail: String,
81    },
82    /// Embedder returned vectors of mixed widths in one build batch.
83    InconsistentVectorWidth {
84        /// Width of the first vector in the batch.
85        expected: usize,
86        /// Width of the diverging vector.
87        got: usize,
88    },
89    /// Embedder returned a vector that is not L2-normalized.
90    VectorNotNormalized {
91        /// Catalog id of the item whose vector failed the unit-norm check.
92        id: String,
93    },
94    /// Non-empty artifact declared `dim == 0` (invalid embedding space).
95    NonEmptyZeroDim,
96    /// A vector failed semantic checks (non-finite or not unit-normalized).
97    /// Raised for embedder output during [`build_artifact`] and for vectors
98    /// decoded from checksum-valid RAT1. Distinct from
99    /// [`Self::VectorNotNormalized`], which is the build-time failure for
100    /// finite non-unit embedder vectors.
101    InvalidVector {
102        /// Catalog id of the failing entry.
103        id: String,
104        /// Short deterministic reason (`non-finite component`, `not unit-normalized`).
105        detail: String,
106    },
107    /// Valid artifacts that cannot be combined (header mismatch or duplicate
108    /// `(kind, id)`). Not [`Self::CorruptPayload`]: each input may decode alone.
109    IncompatibleMerge {
110        /// Why the parts cannot be combined.
111        detail: String,
112    },
113    /// Underlying embedder failure during [`build_artifact`].
114    Embedder(EmbedderError),
115}
116
117impl ArtifactError {
118    fn hint(&self) -> &'static str {
119        match self {
120            ArtifactError::TooShort { .. } => {
121                "the artifact file is truncated or payload_len exceeds the file size"
122            }
123            ArtifactError::InvalidMagic { .. } => {
124                "verify the file is a Ratel embedding artifact (magic RAT1)"
125            }
126            ArtifactError::UnsupportedFormatVersion { .. } => {
127                "rebuild the artifact with a compatible Ratel version"
128            }
129            ArtifactError::ChecksumMismatch => {
130                "the artifact is corrupt or was modified; rebuild from source"
131            }
132            ArtifactError::CorruptPayload { .. } => {
133                "the artifact payload is malformed; rebuild from source"
134            }
135            ArtifactError::InconsistentVectorWidth { .. } => {
136                "the embedder returned vectors of mixed widths; fix the model or corpus"
137            }
138            ArtifactError::VectorNotNormalized { .. } => {
139                "the embedder must return L2-normalized vectors before building an artifact"
140            }
141            ArtifactError::NonEmptyZeroDim => {
142                "a non-empty embedding artifact must declare a positive vector dimension"
143            }
144            ArtifactError::InvalidVector { .. } => {
145                "vectors must be finite and unit-normalized; fix the embedder output or rebuild the artifact"
146            }
147            ArtifactError::IncompatibleMerge { .. } => {
148                "rebuild each part with the same model and projection, or drop the conflicting entry"
149            }
150            ArtifactError::Embedder(_) => {
151                "fix the embedding model or corpus before building the artifact"
152            }
153        }
154    }
155}
156
157impl std::fmt::Display for ArtifactError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        let hint = self.hint();
160        match self {
161            ArtifactError::TooShort { needed, got } => write!(
162                f,
163                "embedding artifact too short: need at least {needed} bytes, got {got} (hint: {hint})"
164            ),
165            ArtifactError::InvalidMagic { got } => write!(
166                f,
167                "embedding artifact invalid magic: expected RAT1, got {} (hint: {hint})",
168                std::str::from_utf8(got).unwrap_or("<non-utf8>")
169            ),
170            ArtifactError::UnsupportedFormatVersion { found, supported } => write!(
171                f,
172                "embedding artifact format version {found} is unsupported (supported: {supported}) (hint: {hint})"
173            ),
174            ArtifactError::ChecksumMismatch => {
175                write!(f, "embedding artifact checksum mismatch (hint: {hint})")
176            }
177            ArtifactError::CorruptPayload { at, detail } => write!(
178                f,
179                "embedding artifact payload corrupt at offset {at}: {detail} (hint: {hint})"
180            ),
181            ArtifactError::InconsistentVectorWidth { expected, got } => write!(
182                f,
183                "embedding artifact build: vector width {got} != expected {expected} (hint: {hint})"
184            ),
185            ArtifactError::VectorNotNormalized { id } => write!(
186                f,
187                "embedding artifact build: vector for id {id:?} is not L2-normalized (hint: {hint})"
188            ),
189            ArtifactError::NonEmptyZeroDim => write!(
190                f,
191                "embedding artifact has entries but dim is 0 (hint: {hint})"
192            ),
193            ArtifactError::InvalidVector { id, detail } => write!(
194                f,
195                "embedding artifact vector for id {id:?} is invalid: {detail} (hint: {hint})"
196            ),
197            ArtifactError::IncompatibleMerge { detail } => write!(
198                f,
199                "embedding artifact merge incompatible: {detail} (hint: {hint})"
200            ),
201            ArtifactError::Embedder(e) => write!(f, "embedding artifact build failed: {e}"),
202        }
203    }
204}
205
206impl std::error::Error for ArtifactError {}
207
208impl From<EmbedderError> for ArtifactError {
209    fn from(value: EmbedderError) -> Self {
210        Self::Embedder(value)
211    }
212}
213
214/// Hash of every catalog projection source — bumps when projection logic
215/// changes without a manual version constant.
216pub(crate) fn projection_version() -> u32 {
217    let mut h = Sha256::new();
218    h.update(include_str!("indexing.rs"));
219    h.update(include_str!("skill_indexing.rs"));
220    h.update(include_str!("fact_indexing.rs"));
221    u32::from_le_bytes(h.finalize()[..4].try_into().expect("4 bytes"))
222}
223
224/// A valid RAT1 artifact with zero entries (no embedder required)
225pub(crate) fn build_empty_artifact() -> Result<Vec<u8>, ArtifactError> {
226    let header = ArtifactHeader {
227        format_version: SUPPORTED_FORMAT_VERSION,
228        projection_version: projection_version(),
229        model_fingerprint: String::new(),
230        dim: 0,
231    };
232    let payload = encode_payload(&header, &[])?;
233    Ok(assemble_file(&payload))
234}
235
236pub(crate) fn build_artifact<'a, T: Embeddable + 'a>(
237    kind: ArtifactEntryKind,
238    items: impl IntoIterator<Item = &'a T>,
239    embedder: &dyn Embedder,
240) -> Result<Vec<u8>, ArtifactError> {
241    let rows: Vec<(String, String)> = items
242        .into_iter()
243        .map(|item| (item.embed_id().to_string(), item.embed_text()))
244        .collect();
245
246    if rows.is_empty() {
247        return build_empty_artifact();
248    }
249
250    let texts: Vec<String> = rows.iter().map(|(_, text)| text.clone()).collect();
251    let Embedded {
252        value: vectors,
253        fingerprint: model_fingerprint,
254    } = embedder.embed_batch_with_artifact_identity(&texts)?;
255
256    if vectors.len() != rows.len() {
257        return Err(ArtifactError::Embedder(EmbedderError::Inference {
258            source: format!(
259                "embedder returned {} embeddings for {} inputs",
260                vectors.len(),
261                rows.len()
262            ),
263        }));
264    }
265
266    let dim = vectors.first().map(Vec::len).unwrap_or(0);
267    require_positive_dim_when_nonempty(rows.len(), dim)?;
268    for ((id, _), vector) in rows.iter().zip(&vectors) {
269        if vector.len() != dim {
270            return Err(ArtifactError::InconsistentVectorWidth {
271                expected: dim,
272                got: vector.len(),
273            });
274        }
275        match classify_vector_semantics(vector) {
276            Ok(()) => {}
277            Err(VectorSemanticIssue::NonFinite) => {
278                return Err(ArtifactError::InvalidVector {
279                    id: id.clone(),
280                    detail: "non-finite component".into(),
281                });
282            }
283            Err(VectorSemanticIssue::NotUnitNormalized) => {
284                return Err(ArtifactError::VectorNotNormalized { id: id.clone() });
285            }
286        }
287    }
288
289    let entries: Vec<ArtifactEntry> = rows
290        .into_iter()
291        .zip(vectors)
292        .map(|((id, text), vector)| ArtifactEntry {
293            kind,
294            id,
295            projection_hash: hash_projection_text(&text),
296            vector,
297        })
298        .collect();
299
300    let header = ArtifactHeader {
301        format_version: SUPPORTED_FORMAT_VERSION,
302        projection_version: projection_version(),
303        model_fingerprint,
304        dim,
305    };
306
307    let payload = encode_payload(&header, &entries)?;
308    Ok(assemble_file(&payload))
309}
310
311pub(crate) fn load_and_validate(
312    bytes: &[u8],
313) -> Result<(ArtifactHeader, Vec<ArtifactEntry>), ArtifactError> {
314    if bytes.len() < VERSION_PREFIX_LEN {
315        return Err(ArtifactError::TooShort {
316            needed: VERSION_PREFIX_LEN,
317            got: bytes.len(),
318        });
319    }
320
321    let mut magic = [0u8; 4];
322    magic.copy_from_slice(&bytes[..4]);
323    if &magic != MAGIC {
324        return Err(ArtifactError::InvalidMagic { got: magic });
325    }
326
327    let format_version = u32::from_le_bytes(bytes[4..8].try_into().expect("4 bytes"));
328    if format_version != SUPPORTED_FORMAT_VERSION {
329        return Err(ArtifactError::UnsupportedFormatVersion {
330            found: format_version,
331            supported: SUPPORTED_FORMAT_VERSION,
332        });
333    }
334
335    if bytes.len() < FILE_PREFIX_LEN {
336        return Err(ArtifactError::TooShort {
337            needed: FILE_PREFIX_LEN,
338            got: bytes.len(),
339        });
340    }
341
342    let payload_len = u64::from_le_bytes(bytes[8..16].try_into().expect("8 bytes")) as usize;
343    let declared_checksum: [u8; 32] = bytes[16..48].try_into().expect("32 bytes");
344
345    let needed = FILE_PREFIX_LEN
346        .checked_add(payload_len)
347        .ok_or(ArtifactError::CorruptPayload {
348            at: 8,
349            detail: "payload_len overflow".into(),
350        })?;
351    if bytes.len() < needed {
352        return Err(ArtifactError::TooShort {
353            needed,
354            got: bytes.len(),
355        });
356    }
357    if bytes.len() > needed {
358        return Err(ArtifactError::CorruptPayload {
359            at: payload_len,
360            detail: format!("{} trailing bytes after artifact", bytes.len() - needed),
361        });
362    }
363
364    let payload = &bytes[FILE_PREFIX_LEN..needed];
365    let computed = Sha256::digest(payload);
366    if computed.as_slice() != declared_checksum {
367        return Err(ArtifactError::ChecksumMismatch);
368    }
369
370    decode_payload(format_version, payload)
371}
372
373/// Merge valid RAT1 parts into one artifact. Empty parts are skipped; nonempty
374/// parts must share format/projection version, fingerprint, and dim. Duplicate
375/// `(kind, id)` → [`ArtifactError::IncompatibleMerge`].
376pub fn merge_embedding_artifacts(parts: &[&[u8]]) -> Result<Vec<u8>, ArtifactError> {
377    let mut base_header: Option<ArtifactHeader> = None;
378    let mut merged: Vec<ArtifactEntry> = Vec::new();
379    let mut seen: std::collections::HashSet<(ArtifactEntryKind, String)> =
380        std::collections::HashSet::new();
381
382    for part in parts {
383        let (header, entries) = load_and_validate(part)?;
384        if entries.is_empty() {
385            continue;
386        }
387        match &base_header {
388            None => base_header = Some(header.clone()),
389            Some(base) => {
390                if header.format_version != base.format_version {
391                    return Err(ArtifactError::IncompatibleMerge {
392                        detail: format!(
393                            "format_version {} != {}",
394                            header.format_version, base.format_version
395                        ),
396                    });
397                }
398                if header.projection_version != base.projection_version {
399                    return Err(ArtifactError::IncompatibleMerge {
400                        detail: format!(
401                            "projection_version {} != {}",
402                            header.projection_version, base.projection_version
403                        ),
404                    });
405                }
406                if header.model_fingerprint != base.model_fingerprint {
407                    return Err(ArtifactError::IncompatibleMerge {
408                        detail: format!(
409                            "model_fingerprint {:?} != {:?}",
410                            header.model_fingerprint, base.model_fingerprint
411                        ),
412                    });
413                }
414                if header.dim != base.dim {
415                    return Err(ArtifactError::IncompatibleMerge {
416                        detail: format!("dim {} != {}", header.dim, base.dim),
417                    });
418                }
419            }
420        }
421        for entry in entries {
422            let key = (entry.kind, entry.id.clone());
423            if !seen.insert(key) {
424                return Err(ArtifactError::IncompatibleMerge {
425                    detail: format!("duplicate entry kind={:?} id={:?}", entry.kind, entry.id),
426                });
427            }
428            merged.push(entry);
429        }
430    }
431
432    let Some(header) = base_header else {
433        return build_empty_artifact();
434    };
435    let payload = encode_payload(&header, &merged)?;
436    Ok(assemble_file(&payload))
437}
438
439pub(crate) fn hash_projection_text(text: &str) -> [u8; 32] {
440    Sha256::digest(text.as_bytes()).into()
441}
442
443/// Shared squared-L2 unit tolerance used by build and decode.
444const UNIT_NORM_SQ_TOLERANCE: f32 = 1e-4;
445
446fn unit_norm_sq_ok(norm_sq: f32) -> bool {
447    (norm_sq - 1.0).abs() <= UNIT_NORM_SQ_TOLERANCE
448}
449
450fn is_unit_normalized(vector: &[f32]) -> bool {
451    let norm_sq: f32 = vector.iter().map(|x| x * x).sum();
452    unit_norm_sq_ok(norm_sq)
453}
454
455fn require_positive_dim_when_nonempty(entry_count: usize, dim: usize) -> Result<(), ArtifactError> {
456    if entry_count > 0 && dim == 0 {
457        return Err(ArtifactError::NonEmptyZeroDim);
458    }
459    Ok(())
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463enum VectorSemanticIssue {
464    NonFinite,
465    NotUnitNormalized,
466}
467
468fn classify_vector_semantics(vector: &[f32]) -> Result<(), VectorSemanticIssue> {
469    if vector.iter().any(|x| !x.is_finite()) {
470        return Err(VectorSemanticIssue::NonFinite);
471    }
472    if !is_unit_normalized(vector) {
473        return Err(VectorSemanticIssue::NotUnitNormalized);
474    }
475    Ok(())
476}
477
478fn assemble_file(payload: &[u8]) -> Vec<u8> {
479    let mut out = Vec::with_capacity(FILE_PREFIX_LEN + payload.len());
480    out.extend_from_slice(MAGIC);
481    out.extend_from_slice(&SUPPORTED_FORMAT_VERSION.to_le_bytes());
482    out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
483    out.extend_from_slice(&Sha256::digest(payload));
484    out.extend_from_slice(payload);
485    out
486}
487
488/// Test-only: assemble a checksum-valid RAT1 from arbitrary entries (may be
489/// semantically invalid). Bypasses build-time semantic checks so load/warm/merge
490/// regressions can target the decode boundary.
491#[cfg(test)]
492pub(crate) fn test_hand_artifact(
493    projection_version: u32,
494    dim: usize,
495    fingerprint: &str,
496    entries: &[ArtifactEntry],
497) -> Vec<u8> {
498    let header = ArtifactHeader {
499        format_version: SUPPORTED_FORMAT_VERSION,
500        projection_version,
501        model_fingerprint: fingerprint.into(),
502        dim,
503    };
504    let payload = encode_payload(&header, entries).expect("test hand artifact encode");
505    assemble_file(&payload)
506}
507
508fn encode_payload(
509    header: &ArtifactHeader,
510    entries: &[ArtifactEntry],
511) -> Result<Vec<u8>, ArtifactError> {
512    let mut out = Vec::new();
513    write_u32(&mut out, header.projection_version);
514    write_u32(
515        &mut out,
516        header
517            .dim
518            .try_into()
519            .map_err(|_| ArtifactError::CorruptPayload {
520                at: 0,
521                detail: "dim does not fit u32".into(),
522            })?,
523    );
524    let model_fp_at = out.len();
525    write_utf8(&mut out, &header.model_fingerprint, model_fp_at)?;
526    let entry_count_at = out.len();
527    write_u32(
528        &mut out,
529        entries
530            .len()
531            .try_into()
532            .map_err(|_| ArtifactError::CorruptPayload {
533                at: entry_count_at,
534                detail: "entry_count does not fit u32".into(),
535            })?,
536    );
537
538    for entry in entries {
539        write_kind(&mut out, entry.kind);
540        let id_at = out.len();
541        write_utf8(&mut out, &entry.id, id_at)?;
542        out.extend_from_slice(&entry.projection_hash);
543        if entry.vector.len() != header.dim {
544            return Err(ArtifactError::CorruptPayload {
545                at: out.len(),
546                detail: format!(
547                    "entry {} vector width {} != header dim {}",
548                    entry.id,
549                    entry.vector.len(),
550                    header.dim
551                ),
552            });
553        }
554        for &value in &entry.vector {
555            out.extend_from_slice(&value.to_le_bytes());
556        }
557    }
558    Ok(out)
559}
560
561fn decode_payload(
562    format_version: u32,
563    payload: &[u8],
564) -> Result<(ArtifactHeader, Vec<ArtifactEntry>), ArtifactError> {
565    let mut cursor = 0usize;
566    let projection_version = read_u32(payload, &mut cursor)?;
567    let dim = read_u32(payload, &mut cursor)? as usize;
568    let model_fingerprint = read_utf8(payload, &mut cursor)?;
569    let entry_count = read_u32(payload, &mut cursor)? as usize;
570
571    // Reject absurd dim/entry_count before Vec::with_capacity: each entry needs at
572    // least kind(1) + id_len(4) + hash(32) + dim*f32 (empty id). Checked math only —
573    // no arbitrary global size caps.
574    let remaining = payload.len().saturating_sub(cursor);
575    let vector_bytes = dim.checked_mul(4).ok_or(ArtifactError::CorruptPayload {
576        at: cursor,
577        detail: "dim*4 overflow".into(),
578    })?;
579    let min_entry =
580        (1usize + 4 + 32)
581            .checked_add(vector_bytes)
582            .ok_or(ArtifactError::CorruptPayload {
583                at: cursor,
584                detail: "min entry size overflow".into(),
585            })?;
586    let min_total = entry_count
587        .checked_mul(min_entry)
588        .ok_or(ArtifactError::CorruptPayload {
589            at: cursor,
590            detail: "entry_count*min_entry overflow".into(),
591        })?;
592    if min_total > remaining {
593        return Err(ArtifactError::CorruptPayload {
594            at: cursor,
595            detail: format!("entries need at least {min_total} bytes but only {remaining} remain"),
596        });
597    }
598
599    require_positive_dim_when_nonempty(entry_count, dim)?;
600
601    let mut entries = Vec::with_capacity(entry_count);
602    for _ in 0..entry_count {
603        let kind = read_kind(payload, &mut cursor)?;
604        let id = read_utf8(payload, &mut cursor)?;
605        let projection_hash = read_fixed::<32>(payload, &mut cursor)?;
606        let mut vector = Vec::with_capacity(dim);
607        let mut norm_sq = 0.0f32;
608        for _ in 0..dim {
609            let value = read_f32(payload, &mut cursor)?;
610            if !value.is_finite() {
611                return Err(ArtifactError::InvalidVector {
612                    id,
613                    detail: "non-finite component".into(),
614                });
615            }
616            norm_sq += value * value;
617            vector.push(value);
618        }
619        if !unit_norm_sq_ok(norm_sq) {
620            return Err(ArtifactError::InvalidVector {
621                id,
622                detail: "not unit-normalized".into(),
623            });
624        }
625        entries.push(ArtifactEntry {
626            kind,
627            id,
628            projection_hash,
629            vector,
630        });
631    }
632
633    if cursor != payload.len() {
634        return Err(ArtifactError::CorruptPayload {
635            at: cursor,
636            detail: format!("{} trailing bytes after entries", payload.len() - cursor),
637        });
638    }
639
640    Ok((
641        ArtifactHeader {
642            format_version,
643            projection_version,
644            model_fingerprint,
645            dim,
646        },
647        entries,
648    ))
649}
650
651fn write_kind(out: &mut Vec<u8>, kind: ArtifactEntryKind) {
652    out.push(match kind {
653        ArtifactEntryKind::Tool => 0,
654        ArtifactEntryKind::Skill => 1,
655    });
656}
657
658fn read_kind(payload: &[u8], cursor: &mut usize) -> Result<ArtifactEntryKind, ArtifactError> {
659    let byte = read_byte(payload, cursor)?;
660    match byte {
661        0 => Ok(ArtifactEntryKind::Tool),
662        1 => Ok(ArtifactEntryKind::Skill),
663        other => Err(ArtifactError::CorruptPayload {
664            at: cursor.saturating_sub(1),
665            detail: format!("unknown entry kind {other}"),
666        }),
667    }
668}
669
670fn write_u32(out: &mut Vec<u8>, value: u32) {
671    out.extend_from_slice(&value.to_le_bytes());
672}
673
674fn write_utf8(out: &mut Vec<u8>, s: &str, at: usize) -> Result<(), ArtifactError> {
675    let bytes = s.as_bytes();
676    let len = u32::try_from(bytes.len()).map_err(|_| ArtifactError::CorruptPayload {
677        at,
678        detail: format!("utf8 field length {} does not fit u32", bytes.len()),
679    })?;
680    write_u32(out, len);
681    out.extend_from_slice(bytes);
682    Ok(())
683}
684
685fn read_u32(payload: &[u8], cursor: &mut usize) -> Result<u32, ArtifactError> {
686    let at = *cursor;
687    let end = at.checked_add(4).ok_or(ArtifactError::CorruptPayload {
688        at,
689        detail: "u32 read overflow".into(),
690    })?;
691    if payload.len() < end {
692        return Err(ArtifactError::CorruptPayload {
693            at,
694            detail: "unexpected end of payload reading u32".into(),
695        });
696    }
697    let value = u32::from_le_bytes(payload[at..end].try_into().expect("4 bytes"));
698    *cursor = end;
699    Ok(value)
700}
701
702fn read_f32(payload: &[u8], cursor: &mut usize) -> Result<f32, ArtifactError> {
703    let at = *cursor;
704    let end = at.checked_add(4).ok_or(ArtifactError::CorruptPayload {
705        at,
706        detail: "f32 read overflow".into(),
707    })?;
708    if payload.len() < end {
709        return Err(ArtifactError::CorruptPayload {
710            at,
711            detail: "unexpected end of payload reading f32".into(),
712        });
713    }
714    let value = f32::from_le_bytes(payload[at..end].try_into().expect("4 bytes"));
715    *cursor = end;
716    Ok(value)
717}
718
719fn read_byte(payload: &[u8], cursor: &mut usize) -> Result<u8, ArtifactError> {
720    let at = *cursor;
721    if at >= payload.len() {
722        return Err(ArtifactError::CorruptPayload {
723            at,
724            detail: "unexpected end of payload reading byte".into(),
725        });
726    }
727    *cursor = at + 1;
728    Ok(payload[at])
729}
730
731fn read_fixed<const N: usize>(
732    payload: &[u8],
733    cursor: &mut usize,
734) -> Result<[u8; N], ArtifactError> {
735    let at = *cursor;
736    let end = at.checked_add(N).ok_or(ArtifactError::CorruptPayload {
737        at,
738        detail: format!("fixed-{N} read overflow"),
739    })?;
740    if payload.len() < end {
741        return Err(ArtifactError::CorruptPayload {
742            at,
743            detail: format!("unexpected end of payload reading fixed-{N}"),
744        });
745    }
746    let mut out = [0u8; N];
747    out.copy_from_slice(&payload[at..end]);
748    *cursor = end;
749    Ok(out)
750}
751
752fn read_utf8(payload: &[u8], cursor: &mut usize) -> Result<String, ArtifactError> {
753    let len = read_u32(payload, cursor)? as usize;
754    let start = *cursor;
755    let end = start
756        .checked_add(len)
757        .ok_or(ArtifactError::CorruptPayload {
758            at: start,
759            detail: "utf8 length overflow".into(),
760        })?;
761    if payload.len() < end {
762        return Err(ArtifactError::CorruptPayload {
763            at: start,
764            detail: format!("utf8 field claims {len} bytes but payload ends early"),
765        });
766    }
767    let s =
768        std::str::from_utf8(&payload[start..end]).map_err(|e| ArtifactError::CorruptPayload {
769            at: start,
770            detail: format!("invalid utf8: {e}"),
771        })?;
772    *cursor = end;
773    Ok(s.to_string())
774}
775
776#[cfg(test)]
777mod tests {
778    use std::sync::Arc;
779
780    use super::*;
781    use crate::embedding::Embedded;
782
783    struct StubItem {
784        id: String,
785        text: String,
786    }
787
788    impl Embeddable for StubItem {
789        fn embed_id(&self) -> &str {
790            &self.id
791        }
792        fn embed_text(&self) -> String {
793            self.text.clone()
794        }
795    }
796
797    struct StubEmbedder {
798        fingerprint: String,
799        vectors: Vec<Vec<f32>>,
800        /// When true, return `vectors` as-is even if len ≠ texts.len().
801        passthrough_batch: bool,
802    }
803
804    impl Embedder for StubEmbedder {
805        fn embed_doc(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
806            unreachable!("artifact tests use batch path")
807        }
808
809        fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
810            unreachable!("artifact tests use batch path")
811        }
812
813        fn embed_batch_with_identity(
814            &self,
815            texts: &[String],
816        ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
817            if texts.is_empty() {
818                return Ok(Embedded {
819                    value: Vec::new(),
820                    fingerprint: self.fingerprint.clone(),
821                });
822            }
823            let value = if self.passthrough_batch || self.vectors.len() == texts.len() {
824                self.vectors.clone()
825            } else {
826                let template = &self.vectors[0];
827                texts.iter().map(|_| template.clone()).collect()
828            };
829            Ok(Embedded {
830                value,
831                fingerprint: self.fingerprint.clone(),
832            })
833        }
834
835        fn fingerprint(&self) -> String {
836            self.fingerprint.clone()
837        }
838    }
839
840    /// Endpoint-like stub: static `fingerprint()` differs from the batch-resolved
841    /// identity returned by `embed_batch_with_identity`.
842    struct BatchResolvedStub {
843        static_fingerprint: String,
844        batch_fingerprint: String,
845        vectors: Vec<Vec<f32>>,
846    }
847
848    impl Embedder for BatchResolvedStub {
849        fn embed_doc(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
850            unreachable!("artifact tests use batch path")
851        }
852        fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
853            unreachable!("artifact tests use batch path")
854        }
855        fn embed_batch_with_identity(
856            &self,
857            texts: &[String],
858        ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
859            assert_eq!(texts.len(), self.vectors.len());
860            Ok(Embedded {
861                value: self.vectors.clone(),
862                fingerprint: self.batch_fingerprint.clone(),
863            })
864        }
865        fn fingerprint(&self) -> String {
866            self.static_fingerprint.clone()
867        }
868    }
869
870    /// Local-like stub: runtime / `embed_batch_with_identity` differ from
871    /// `embed_batch_with_artifact_identity` — proves build uses the artifact path.
872    struct ArtifactAwareBatchStub {
873        runtime: String,
874        artifact: String,
875        vectors: Vec<Vec<f32>>,
876    }
877
878    impl Embedder for ArtifactAwareBatchStub {
879        fn embed_doc(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
880            unreachable!("artifact tests use batch path")
881        }
882        fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
883            unreachable!("artifact tests use batch path")
884        }
885        fn embed_batch_with_identity(
886            &self,
887            texts: &[String],
888        ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
889            assert_eq!(texts.len(), self.vectors.len());
890            Ok(Embedded {
891                value: self.vectors.clone(),
892                fingerprint: self.runtime.clone(),
893            })
894        }
895        fn embed_batch_with_artifact_identity(
896            &self,
897            texts: &[String],
898        ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
899            assert_eq!(texts.len(), self.vectors.len());
900            Ok(Embedded {
901                value: self.vectors.clone(),
902                fingerprint: self.artifact.clone(),
903            })
904        }
905        fn fingerprint(&self) -> String {
906            self.runtime.clone()
907        }
908        fn artifact_identity(&self) -> Result<String, EmbedderError> {
909            Ok(self.artifact.clone())
910        }
911    }
912
913    fn stub_item(id: &str, text: &str) -> StubItem {
914        StubItem {
915            id: id.into(),
916            text: text.into(),
917        }
918    }
919
920    fn unit(v: [f32; 2]) -> Vec<f32> {
921        let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
922        v.iter().map(|x| x / norm).collect()
923    }
924
925    fn unit3(v: [f32; 3]) -> Vec<f32> {
926        let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
927        v.iter().map(|x| x / norm).collect()
928    }
929
930    fn sample_embedder_for(items: &[StubItem]) -> Arc<StubEmbedder> {
931        Arc::new(StubEmbedder {
932            fingerprint: "hf|repo=1:r|revision=4:main|pool=3:cls".into(),
933            vectors: items
934                .iter()
935                .enumerate()
936                .map(|(i, _)| {
937                    if i % 2 == 0 {
938                        unit([1.0, 0.0])
939                    } else {
940                        unit([0.0, 1.0])
941                    }
942                })
943                .collect(),
944            passthrough_batch: false,
945        })
946    }
947
948    fn hand_artifact(
949        projection_version: u32,
950        dim: usize,
951        fingerprint: &str,
952        entries: &[ArtifactEntry],
953    ) -> Vec<u8> {
954        test_hand_artifact(projection_version, dim, fingerprint, entries)
955    }
956
957    #[test]
958    fn build_followed_by_load_round_trips_header_and_entries() {
959        let items = [
960            stub_item("read_file", "read file from disk"),
961            stub_item("write_file", "write file to disk"),
962        ];
963        let bytes = build_artifact(
964            ArtifactEntryKind::Tool,
965            &items,
966            sample_embedder_for(&items).as_ref(),
967        )
968        .unwrap();
969        let (header, entries) = load_and_validate(&bytes).unwrap();
970
971        assert_eq!(header.format_version, SUPPORTED_FORMAT_VERSION);
972        assert_eq!(header.projection_version, projection_version());
973        assert_eq!(
974            header.model_fingerprint,
975            "hf|repo=1:r|revision=4:main|pool=3:cls"
976        );
977        assert_eq!(header.dim, 2);
978        assert_eq!(entries.len(), 2);
979        assert_eq!(entries[0].kind, ArtifactEntryKind::Tool);
980        assert_eq!(entries[0].id, "read_file");
981        assert_eq!(
982            entries[0].projection_hash,
983            hash_projection_text("read file from disk")
984        );
985        assert_eq!(entries[1].id, "write_file");
986    }
987
988    #[test]
989    fn build_uses_batch_identity_for_endpoint_semantics() {
990        let items = [stub_item("a", "alpha")];
991        let embedder = BatchResolvedStub {
992            static_fingerprint: "endpoint|url=1:u|model=9:configured".into(),
993            batch_fingerprint: "endpoint|url=1:u|model=8:resolved".into(),
994            vectors: vec![unit([1.0, 0.0])],
995        };
996        let bytes = build_artifact(ArtifactEntryKind::Tool, &items, &embedder).unwrap();
997        let (header, _) = load_and_validate(&bytes).unwrap();
998        assert_eq!(
999            header.model_fingerprint, "endpoint|url=1:u|model=8:resolved",
1000            "RAT1 header must use the batch-resolved identity, not static fingerprint()"
1001        );
1002    }
1003
1004    #[test]
1005    fn build_artifact_uses_artifact_aware_batch_identity() {
1006        let items = [stub_item("a", "alpha")];
1007        let embedder = ArtifactAwareBatchStub {
1008            runtime: "local|path=11:/models/foo|pool=4:mean".into(),
1009            artifact: "local|content=64:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb|pool=4:mean"
1010                .into(),
1011            vectors: vec![unit([1.0, 0.0])],
1012        };
1013        let bytes = build_artifact(ArtifactEntryKind::Tool, &items, &embedder).unwrap();
1014        let (header, _) = load_and_validate(&bytes).unwrap();
1015        assert_eq!(
1016            header.model_fingerprint, embedder.artifact,
1017            "build_artifact must call embed_batch_with_artifact_identity, not only embed_batch_with_identity"
1018        );
1019        assert_ne!(
1020            header.model_fingerprint, embedder.runtime,
1021            "header must not fall back to the runtime identity"
1022        );
1023    }
1024
1025    #[test]
1026    fn flipped_checksum_byte_fails_validation() {
1027        let items = [stub_item("a", "alpha")];
1028        let mut bytes = build_artifact(
1029            ArtifactEntryKind::Tool,
1030            &items,
1031            sample_embedder_for(&items).as_ref(),
1032        )
1033        .unwrap();
1034        let last = bytes.len() - 1;
1035        bytes[last] ^= 0x01;
1036        assert!(matches!(
1037            load_and_validate(&bytes),
1038            Err(ArtifactError::ChecksumMismatch)
1039        ));
1040    }
1041
1042    #[test]
1043    fn unknown_format_version_rejects_without_reading_payload() {
1044        let items = [stub_item("a", "alpha")];
1045        let mut bytes = build_artifact(
1046            ArtifactEntryKind::Tool,
1047            &items,
1048            sample_embedder_for(&items).as_ref(),
1049        )
1050        .unwrap();
1051        bytes[4..8].copy_from_slice(&999u32.to_le_bytes());
1052        if bytes.len() > FILE_PREFIX_LEN {
1053            bytes[FILE_PREFIX_LEN] ^= 0xff;
1054        }
1055        assert!(matches!(
1056            load_and_validate(&bytes),
1057            Err(ArtifactError::UnsupportedFormatVersion {
1058                found: 999,
1059                supported: SUPPORTED_FORMAT_VERSION
1060            })
1061        ));
1062    }
1063
1064    #[test]
1065    fn truncated_file_fails_with_too_short() {
1066        assert!(matches!(
1067            load_and_validate(b"RAT"),
1068            Err(ArtifactError::TooShort { needed: 8, got: 3 })
1069        ));
1070
1071        let items = [stub_item("a", "alpha")];
1072        let bytes = build_artifact(
1073            ArtifactEntryKind::Tool,
1074            &items,
1075            sample_embedder_for(&items).as_ref(),
1076        )
1077        .unwrap();
1078        let mut truncated = bytes.clone();
1079        truncated[8..16].copy_from_slice(&((bytes.len() + 100) as u64).to_le_bytes());
1080        assert!(matches!(
1081            load_and_validate(&truncated),
1082            Err(ArtifactError::TooShort { .. })
1083        ));
1084    }
1085
1086    #[test]
1087    fn structurally_corrupt_payload_with_valid_checksum_is_rejected() {
1088        let mut payload = Vec::new();
1089        write_u32(&mut payload, projection_version());
1090        write_u32(&mut payload, 2);
1091        write_utf8(&mut payload, "fp", 0).unwrap();
1092        write_u32(&mut payload, 1);
1093        payload.push(0);
1094        let id_at = payload.len();
1095        write_utf8(&mut payload, "x", id_at).unwrap();
1096        payload.extend_from_slice(&[0u8; 32]);
1097        payload.extend_from_slice(&1.0f32.to_le_bytes());
1098
1099        let file = assemble_file(&payload);
1100        assert!(matches!(
1101            load_and_validate(&file),
1102            Err(ArtifactError::CorruptPayload { .. })
1103        ));
1104    }
1105
1106    #[test]
1107    fn absurd_entry_count_with_valid_checksum_is_rejected_before_allocation() {
1108        let mut payload = Vec::new();
1109        write_u32(&mut payload, projection_version());
1110        write_u32(&mut payload, 2);
1111        write_utf8(&mut payload, "fp", 0).unwrap();
1112        write_u32(&mut payload, u32::MAX);
1113
1114        let file = assemble_file(&payload);
1115        assert!(matches!(
1116            load_and_validate(&file),
1117            Err(ArtifactError::CorruptPayload { .. })
1118        ));
1119    }
1120
1121    #[test]
1122    fn absurd_dim_with_valid_checksum_is_rejected_before_allocation() {
1123        let mut payload = Vec::new();
1124        write_u32(&mut payload, projection_version());
1125        write_u32(&mut payload, u32::MAX);
1126        write_utf8(&mut payload, "fp", 0).unwrap();
1127        write_u32(&mut payload, 1);
1128        // Minimal entry prefix so decode reaches the dim capacity path conceptually;
1129        // the pre-check must reject before allocating dim floats.
1130        payload.push(0);
1131        let id_at = payload.len();
1132        write_utf8(&mut payload, "x", id_at).unwrap();
1133        payload.extend_from_slice(&[0u8; 32]);
1134
1135        let file = assemble_file(&payload);
1136        assert!(matches!(
1137            load_and_validate(&file),
1138            Err(ArtifactError::CorruptPayload { .. })
1139        ));
1140    }
1141
1142    #[test]
1143    fn trailing_bytes_after_artifact_are_rejected() {
1144        let items = [stub_item("a", "alpha")];
1145        let bytes = build_artifact(
1146            ArtifactEntryKind::Tool,
1147            &items,
1148            sample_embedder_for(&items).as_ref(),
1149        )
1150        .unwrap();
1151        let mut with_garbage = bytes.clone();
1152        with_garbage.push(0xFF);
1153        assert!(matches!(
1154            load_and_validate(&with_garbage),
1155            Err(ArtifactError::CorruptPayload { at, .. })
1156                if at == bytes.len() - FILE_PREFIX_LEN
1157        ));
1158    }
1159
1160    #[test]
1161    fn serialized_bytes_contain_no_sensitive_plaintext() {
1162        let secret_description = "SECRET_DESCRIPTION_DO_NOT_SERIALIZE";
1163        let secret_body = "SECRET_BODY_WITH_EXECUTOR_AND_CREDENTIALS";
1164        let secret_api_key = "sk-live-abc123supersecret";
1165        let projection = "minimal public projection";
1166
1167        let sensitive = SensitiveItem {
1168            id: "tool_id_only".into(),
1169            description: secret_description.into(),
1170            body: secret_body.into(),
1171            api_key: secret_api_key.into(),
1172            projection: projection.into(),
1173        };
1174
1175        let embedder = Arc::new(StubEmbedder {
1176            fingerprint: "test|model=1:m".into(),
1177            vectors: vec![unit([1.0, 0.0])],
1178            passthrough_batch: false,
1179        });
1180
1181        let bytes =
1182            build_artifact(ArtifactEntryKind::Tool, [&sensitive], embedder.as_ref()).unwrap();
1183
1184        let blob = String::from_utf8_lossy(&bytes);
1185        for forbidden in [
1186            secret_description,
1187            secret_body,
1188            secret_api_key,
1189            "executor",
1190            projection,
1191        ] {
1192            assert!(
1193                !blob.contains(forbidden),
1194                "forbidden plaintext {forbidden:?} found in artifact bytes"
1195            );
1196        }
1197        assert!(
1198            blob.contains("tool_id_only"),
1199            "id is part of the wire format"
1200        );
1201    }
1202
1203    // description, body and api_key are intentionally never read
1204    // the test proves they never reach the artifact bytes
1205    #[allow(dead_code)]
1206    struct SensitiveItem {
1207        id: String,
1208        description: String,
1209        body: String,
1210        api_key: String,
1211        projection: String,
1212    }
1213
1214    impl Embeddable for SensitiveItem {
1215        fn embed_id(&self) -> &str {
1216            &self.id
1217        }
1218        fn embed_text(&self) -> String {
1219            self.projection.clone()
1220        }
1221    }
1222
1223    #[test]
1224    fn mixed_width_vectors_reject_with_inconsistent_width() {
1225        let items = [stub_item("first", "alpha"), stub_item("second", "beta")];
1226        let embedder = Arc::new(StubEmbedder {
1227            fingerprint: "test|model=1:m".into(),
1228            vectors: vec![unit([1.0, 0.0]), vec![0.0, 1.0, 0.0]],
1229            passthrough_batch: false,
1230        });
1231        assert!(matches!(
1232            build_artifact(ArtifactEntryKind::Tool, &items, embedder.as_ref()),
1233            Err(ArtifactError::InconsistentVectorWidth {
1234                expected: 2,
1235                got: 3,
1236            })
1237        ));
1238    }
1239
1240    #[test]
1241    fn non_normalized_vector_rejects_with_vector_not_normalized() {
1242        let items = [stub_item("bad_item", "alpha")];
1243        let embedder = Arc::new(StubEmbedder {
1244            fingerprint: "test|model=1:m".into(),
1245            vectors: vec![vec![2.0, 0.0]],
1246            passthrough_batch: false,
1247        });
1248        assert!(matches!(
1249            build_artifact(ArtifactEntryKind::Tool, &items, embedder.as_ref()),
1250            Err(ArtifactError::VectorNotNormalized { id }) if id == "bad_item"
1251        ));
1252    }
1253
1254    #[test]
1255    fn build_rejects_non_finite_vector_with_invalid_vector() {
1256        let items = [stub_item("nan_item", "alpha")];
1257        let embedder = Arc::new(StubEmbedder {
1258            fingerprint: "test|model=1:m".into(),
1259            vectors: vec![vec![f32::NAN, 0.0]],
1260            passthrough_batch: false,
1261        });
1262        assert!(matches!(
1263            build_artifact(ArtifactEntryKind::Tool, &items, embedder.as_ref()),
1264            Err(ArtifactError::InvalidVector { id, detail })
1265                if id == "nan_item" && detail == "non-finite component"
1266        ));
1267    }
1268
1269    #[test]
1270    fn built_vectors_are_l2_normalized() {
1271        let items = [stub_item("a", "alpha"), stub_item("b", "beta")];
1272        let bytes = build_artifact(
1273            ArtifactEntryKind::Skill,
1274            &items,
1275            sample_embedder_for(&items).as_ref(),
1276        )
1277        .unwrap();
1278        let (_, entries) = load_and_validate(&bytes).unwrap();
1279        for entry in entries {
1280            assert!(
1281                is_unit_normalized(&entry.vector),
1282                "vector for {} must be unit-normalized",
1283                entry.id
1284            );
1285        }
1286    }
1287
1288    fn hand_entry(id: &str, vector: Vec<f32>) -> ArtifactEntry {
1289        ArtifactEntry {
1290            kind: ArtifactEntryKind::Tool,
1291            id: id.into(),
1292            projection_hash: [0u8; 32],
1293            vector,
1294        }
1295    }
1296
1297    #[test]
1298    fn load_rejects_nonempty_zero_dim_with_valid_checksum() {
1299        let bytes = hand_artifact(
1300            projection_version(),
1301            0,
1302            "fp-zero-dim",
1303            &[hand_entry("a", vec![])],
1304        );
1305        assert!(matches!(
1306            load_and_validate(&bytes),
1307            Err(ArtifactError::NonEmptyZeroDim)
1308        ));
1309    }
1310
1311    #[test]
1312    fn build_rejects_nonempty_zero_dim_vectors() {
1313        let items = [stub_item("empty_vec", "alpha")];
1314        let embedder = Arc::new(StubEmbedder {
1315            fingerprint: "test|model=1:m".into(),
1316            vectors: vec![vec![]],
1317            passthrough_batch: false,
1318        });
1319        assert!(matches!(
1320            build_artifact(ArtifactEntryKind::Tool, &items, embedder.as_ref()),
1321            Err(ArtifactError::NonEmptyZeroDim)
1322        ));
1323    }
1324
1325    #[test]
1326    fn canonical_empty_artifact_still_loads() {
1327        let bytes = build_empty_artifact().unwrap();
1328        let (header, entries) = load_and_validate(&bytes).unwrap();
1329        assert!(entries.is_empty());
1330        assert_eq!(header.dim, 0);
1331    }
1332
1333    #[test]
1334    fn empty_artifact_with_nonzero_dim_still_loads() {
1335        let bytes = hand_artifact(projection_version(), 8, "fp-empty-nonzero-dim", &[]);
1336        let (header, entries) = load_and_validate(&bytes).unwrap();
1337        assert!(entries.is_empty());
1338        assert_eq!(header.dim, 8);
1339    }
1340
1341    #[test]
1342    fn load_rejects_nan_vector_with_valid_checksum() {
1343        let bytes = hand_artifact(
1344            projection_version(),
1345            2,
1346            "fp-nan",
1347            &[hand_entry("bad", vec![f32::NAN, 0.0])],
1348        );
1349        assert!(matches!(
1350            load_and_validate(&bytes),
1351            Err(ArtifactError::InvalidVector { id, detail })
1352                if id == "bad" && detail == "non-finite component"
1353        ));
1354    }
1355
1356    #[test]
1357    fn load_rejects_pos_infinity_vector_with_valid_checksum() {
1358        let bytes = hand_artifact(
1359            projection_version(),
1360            2,
1361            "fp-pinf",
1362            &[hand_entry("bad", vec![f32::INFINITY, 0.0])],
1363        );
1364        assert!(matches!(
1365            load_and_validate(&bytes),
1366            Err(ArtifactError::InvalidVector { id, detail })
1367                if id == "bad" && detail == "non-finite component"
1368        ));
1369    }
1370
1371    #[test]
1372    fn load_rejects_neg_infinity_vector_with_valid_checksum() {
1373        let bytes = hand_artifact(
1374            projection_version(),
1375            2,
1376            "fp-ninf",
1377            &[hand_entry("bad", vec![f32::NEG_INFINITY, 0.0])],
1378        );
1379        assert!(matches!(
1380            load_and_validate(&bytes),
1381            Err(ArtifactError::InvalidVector { id, detail })
1382                if id == "bad" && detail == "non-finite component"
1383        ));
1384    }
1385
1386    #[test]
1387    fn load_rejects_non_unit_vector_with_valid_checksum() {
1388        let bytes = hand_artifact(
1389            projection_version(),
1390            2,
1391            "fp-nonunit",
1392            &[hand_entry("bad", vec![3.0, 0.0])],
1393        );
1394        assert!(matches!(
1395            load_and_validate(&bytes),
1396            Err(ArtifactError::InvalidVector { id, detail })
1397                if id == "bad" && detail == "not unit-normalized"
1398        ));
1399    }
1400
1401    #[test]
1402    fn load_accepts_unit_normalized_vector() {
1403        let bytes = hand_artifact(
1404            projection_version(),
1405            2,
1406            "fp-unit",
1407            &[hand_entry("ok", unit([1.0, 0.0]))],
1408        );
1409        let (header, entries) = load_and_validate(&bytes).unwrap();
1410        assert_eq!(header.dim, 2);
1411        assert_eq!(entries.len(), 1);
1412        assert!(is_unit_normalized(&entries[0].vector));
1413    }
1414
1415    #[test]
1416    fn unit_norm_tolerance_matches_is_unit_normalized() {
1417        let tol = super::UNIT_NORM_SQ_TOLERANCE;
1418        // Shared squared-norm predicate with values comfortably inside / outside
1419        // the band (avoid 1.0 ± tol, which f32 addition may push past the bound).
1420        assert!(super::unit_norm_sq_ok(1.0));
1421        assert!(super::unit_norm_sq_ok(1.0 + tol * 0.5));
1422        assert!(!super::unit_norm_sq_ok(1.0 + tol * 2.0));
1423
1424        let inside = vec![(1.0 + tol * 0.5).sqrt(), 0.0];
1425        assert!(is_unit_normalized(&inside));
1426        let bytes_ok = hand_artifact(
1427            projection_version(),
1428            2,
1429            "fp-tol-in",
1430            &[hand_entry("ok", inside)],
1431        );
1432        assert!(load_and_validate(&bytes_ok).is_ok());
1433
1434        let outside = vec![(1.0 + tol * 2.0).sqrt(), 0.0];
1435        assert!(!is_unit_normalized(&outside));
1436        let bytes_bad = hand_artifact(
1437            projection_version(),
1438            2,
1439            "fp-tol-out",
1440            &[hand_entry("bad", outside)],
1441        );
1442        assert!(matches!(
1443            load_and_validate(&bytes_bad),
1444            Err(ArtifactError::InvalidVector { detail, .. }) if detail == "not unit-normalized"
1445        ));
1446    }
1447
1448    #[test]
1449    fn merge_rejects_checksum_valid_invalid_vector() {
1450        let bad = hand_artifact(
1451            projection_version(),
1452            2,
1453            "fp-merge-nan",
1454            &[hand_entry("bad", vec![f32::NAN, 0.0])],
1455        );
1456        assert!(matches!(
1457            merge_embedding_artifacts(&[&bad]),
1458            Err(ArtifactError::InvalidVector { id, detail })
1459                if id == "bad" && detail == "non-finite component"
1460        ));
1461    }
1462
1463    #[test]
1464    fn merge_tool_and_skill_artifacts_round_trips() {
1465        let tools = [stub_item("t", "tool text")];
1466        let skills = [stub_item("s", "skill text")];
1467        let embedder =
1468            sample_embedder_for(&[stub_item("t", "tool text"), stub_item("s", "skill text")]);
1469        let tool_bytes =
1470            build_artifact(ArtifactEntryKind::Tool, &tools, embedder.as_ref()).unwrap();
1471        let skill_bytes =
1472            build_artifact(ArtifactEntryKind::Skill, &skills, embedder.as_ref()).unwrap();
1473        let merged = merge_embedding_artifacts(&[&tool_bytes, &skill_bytes]).unwrap();
1474        let (header, entries) = load_and_validate(&merged).unwrap();
1475        assert_eq!(header.format_version, SUPPORTED_FORMAT_VERSION);
1476        assert_eq!(entries.len(), 2);
1477        assert_eq!(entries[0].kind, ArtifactEntryKind::Tool);
1478        assert_eq!(entries[0].id, "t");
1479        assert_eq!(entries[1].kind, ArtifactEntryKind::Skill);
1480        assert_eq!(entries[1].id, "s");
1481    }
1482
1483    #[test]
1484    fn merge_empty_parts_yields_empty_artifact() {
1485        let empty = build_empty_artifact().unwrap();
1486        let merged = merge_embedding_artifacts(&[&empty, &empty]).unwrap();
1487        let (header, entries) = load_and_validate(&merged).unwrap();
1488        assert!(entries.is_empty());
1489        assert_eq!(header.dim, 0);
1490        assert!(header.model_fingerprint.is_empty());
1491    }
1492
1493    #[test]
1494    fn merge_empty_with_nonempty_is_identity() {
1495        let items = [stub_item("a", "alpha")];
1496        let nonempty = build_artifact(
1497            ArtifactEntryKind::Tool,
1498            &items,
1499            sample_embedder_for(&items).as_ref(),
1500        )
1501        .unwrap();
1502        let empty = build_empty_artifact().unwrap();
1503        let merged = merge_embedding_artifacts(&[&empty, &nonempty]).unwrap();
1504        let (_, entries) = load_and_validate(&merged).unwrap();
1505        assert_eq!(entries.len(), 1);
1506        assert_eq!(entries[0].id, "a");
1507    }
1508
1509    #[test]
1510    fn merge_rejects_model_fingerprint_mismatch() {
1511        let items = [stub_item("a", "alpha")];
1512        let a = build_artifact(
1513            ArtifactEntryKind::Tool,
1514            &items,
1515            Arc::new(StubEmbedder {
1516                fingerprint: "fp-a".into(),
1517                vectors: vec![unit([1.0, 0.0])],
1518                passthrough_batch: false,
1519            })
1520            .as_ref(),
1521        )
1522        .unwrap();
1523        let b = build_artifact(
1524            ArtifactEntryKind::Skill,
1525            &items,
1526            Arc::new(StubEmbedder {
1527                fingerprint: "fp-b".into(),
1528                vectors: vec![unit([0.0, 1.0])],
1529                passthrough_batch: false,
1530            })
1531            .as_ref(),
1532        )
1533        .unwrap();
1534        assert!(matches!(
1535            merge_embedding_artifacts(&[&a, &b]),
1536            Err(ArtifactError::IncompatibleMerge { .. })
1537        ));
1538    }
1539
1540    #[test]
1541    fn merge_rejects_duplicate_kind_id() {
1542        let items = [stub_item("dup", "alpha")];
1543        let a = build_artifact(
1544            ArtifactEntryKind::Tool,
1545            &items,
1546            sample_embedder_for(&items).as_ref(),
1547        )
1548        .unwrap();
1549        let b = build_artifact(
1550            ArtifactEntryKind::Tool,
1551            &items,
1552            sample_embedder_for(&items).as_ref(),
1553        )
1554        .unwrap();
1555        assert!(matches!(
1556            merge_embedding_artifacts(&[&a, &b]),
1557            Err(ArtifactError::IncompatibleMerge { detail }) if detail.contains("duplicate")
1558        ));
1559    }
1560
1561    #[test]
1562    fn merge_allows_same_id_across_kinds() {
1563        let items = [stub_item("search", "text")];
1564        let tool = build_artifact(
1565            ArtifactEntryKind::Tool,
1566            &items,
1567            sample_embedder_for(&items).as_ref(),
1568        )
1569        .unwrap();
1570        let skill = build_artifact(
1571            ArtifactEntryKind::Skill,
1572            &items,
1573            sample_embedder_for(&items).as_ref(),
1574        )
1575        .unwrap();
1576        let merged = merge_embedding_artifacts(&[&tool, &skill]).unwrap();
1577        let (_, entries) = load_and_validate(&merged).unwrap();
1578        assert_eq!(entries.len(), 2);
1579        assert_eq!(entries[0].kind, ArtifactEntryKind::Tool);
1580        assert_eq!(entries[1].kind, ArtifactEntryKind::Skill);
1581        assert_eq!(entries[0].id, "search");
1582        assert_eq!(entries[1].id, "search");
1583    }
1584
1585    #[test]
1586    fn merge_malformed_input_is_corrupt_not_incompatible() {
1587        assert!(matches!(
1588            merge_embedding_artifacts(&[b"not-a-rat1-file"]),
1589            Err(ArtifactError::InvalidMagic { .. } | ArtifactError::TooShort { .. })
1590        ));
1591    }
1592
1593    #[test]
1594    fn build_rejects_fewer_vectors_than_inputs() {
1595        let items = [stub_item("a", "alpha"), stub_item("b", "beta")];
1596        let err = build_artifact(
1597            ArtifactEntryKind::Tool,
1598            &items,
1599            Arc::new(StubEmbedder {
1600                fingerprint: "fp".into(),
1601                vectors: vec![unit([1.0, 0.0])],
1602                passthrough_batch: true,
1603            })
1604            .as_ref(),
1605        )
1606        .unwrap_err();
1607        assert!(matches!(
1608            err,
1609            ArtifactError::Embedder(EmbedderError::Inference { source })
1610                if source.contains("1 embeddings for 2 inputs")
1611        ));
1612    }
1613
1614    #[test]
1615    fn build_rejects_more_vectors_than_inputs() {
1616        let items = [stub_item("a", "alpha")];
1617        let err = build_artifact(
1618            ArtifactEntryKind::Tool,
1619            &items,
1620            Arc::new(StubEmbedder {
1621                fingerprint: "fp".into(),
1622                vectors: vec![unit([1.0, 0.0]), unit([0.0, 1.0])],
1623                passthrough_batch: true,
1624            })
1625            .as_ref(),
1626        )
1627        .unwrap_err();
1628        assert!(matches!(
1629            err,
1630            ArtifactError::Embedder(EmbedderError::Inference { source })
1631                if source.contains("2 embeddings for 1 inputs")
1632        ));
1633    }
1634
1635    #[test]
1636    fn merge_rejects_dim_mismatch_as_incompatible() {
1637        let a = hand_artifact(
1638            1,
1639            2,
1640            "fp",
1641            &[ArtifactEntry {
1642                kind: ArtifactEntryKind::Tool,
1643                id: "a".into(),
1644                projection_hash: [0; 32],
1645                vector: unit([1.0, 0.0]),
1646            }],
1647        );
1648        let b = hand_artifact(
1649            1,
1650            3,
1651            "fp",
1652            &[ArtifactEntry {
1653                kind: ArtifactEntryKind::Skill,
1654                id: "b".into(),
1655                projection_hash: [1; 32],
1656                vector: unit3([1.0, 0.0, 0.0]),
1657            }],
1658        );
1659        assert!(matches!(
1660            merge_embedding_artifacts(&[&a, &b]),
1661            Err(ArtifactError::IncompatibleMerge { detail }) if detail.contains("dim")
1662        ));
1663    }
1664
1665    #[test]
1666    fn merge_rejects_projection_version_mismatch_as_incompatible() {
1667        let a = hand_artifact(
1668            1,
1669            2,
1670            "fp",
1671            &[ArtifactEntry {
1672                kind: ArtifactEntryKind::Tool,
1673                id: "a".into(),
1674                projection_hash: [0; 32],
1675                vector: unit([1.0, 0.0]),
1676            }],
1677        );
1678        let b = hand_artifact(
1679            2,
1680            2,
1681            "fp",
1682            &[ArtifactEntry {
1683                kind: ArtifactEntryKind::Skill,
1684                id: "b".into(),
1685                projection_hash: [1; 32],
1686                vector: unit([0.0, 1.0]),
1687            }],
1688        );
1689        assert!(matches!(
1690            merge_embedding_artifacts(&[&a, &b]),
1691            Err(ArtifactError::IncompatibleMerge { detail })
1692                if detail.contains("projection_version")
1693        ));
1694    }
1695}