1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ArtifactEntryKind {
23 Tool,
25 Skill,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub(crate) struct ArtifactHeader {
31 pub format_version: u32,
32 pub projection_version: u32,
34 pub model_fingerprint: String,
36 pub dim: usize,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub(crate) struct ArtifactEntry {
42 pub kind: ArtifactEntryKind,
43 pub id: String,
45 pub projection_hash: [u8; 32],
47 pub vector: Vec<f32>,
49}
50
51#[derive(Debug, Clone)]
53pub enum ArtifactError {
54 TooShort {
56 needed: usize,
58 got: usize,
60 },
61 InvalidMagic {
63 got: [u8; 4],
65 },
66 UnsupportedFormatVersion {
68 found: u32,
70 supported: u32,
72 },
73 ChecksumMismatch,
75 CorruptPayload {
77 at: usize,
79 detail: String,
81 },
82 InconsistentVectorWidth {
84 expected: usize,
86 got: usize,
88 },
89 VectorNotNormalized {
91 id: String,
93 },
94 NonEmptyZeroDim,
96 InvalidVector {
102 id: String,
104 detail: String,
106 },
107 IncompatibleMerge {
110 detail: String,
112 },
113 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
214pub(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
224pub(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
373pub 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
443const 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#[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 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 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 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 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 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 #[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 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}