lattice-embed 0.9.0

SIMD-accelerated vector operations and embedding generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
//! Tests for embedding services.

use super::*;
use crate::model::EmbeddingModel;

#[test]
fn test_max_batch_size_constant() {
    assert_eq!(DEFAULT_MAX_BATCH_SIZE, 1000);
}

#[test]
fn test_max_text_bytes_constant() {
    assert_eq!(MAX_TEXT_BYTES, 32768);
}

#[test]
#[allow(deprecated)]
fn test_max_text_chars_deprecated_alias_matches() {
    assert_eq!(MAX_TEXT_CHARS, MAX_TEXT_BYTES);
}

#[cfg(feature = "native")]
mod native_tests {
    use super::*;
    use crate::{EmbeddingModel, ModelConfig};

    /// Default service loads BgeSmallEnV15 — only that model is supported.
    #[test]
    fn test_native_service_supports_only_loaded_model() {
        let service = NativeEmbeddingService::default();
        assert!(service.supports_model(EmbeddingModel::BgeSmallEnV15));
        // All other models must be rejected even if they are local.
        assert!(!service.supports_model(EmbeddingModel::BgeBaseEnV15));
        assert!(!service.supports_model(EmbeddingModel::BgeLargeEnV15));
        assert!(!service.supports_model(EmbeddingModel::MultilingualE5Small));
        assert!(!service.supports_model(EmbeddingModel::MultilingualE5Base));
        assert!(!service.supports_model(EmbeddingModel::Qwen3Embedding0_6B));
        assert!(!service.supports_model(EmbeddingModel::Qwen3Embedding4B));
        assert!(!service.supports_model(EmbeddingModel::TextEmbedding3Small));
    }

    /// with_model() constructor — only the selected model is supported.
    #[test]
    fn test_native_service_with_model_supports_only_that_model() {
        let service = NativeEmbeddingService::with_model(EmbeddingModel::MultilingualE5Small);
        assert!(service.supports_model(EmbeddingModel::MultilingualE5Small));
        assert!(!service.supports_model(EmbeddingModel::BgeSmallEnV15));
    }

    #[test]
    fn test_native_service_name() {
        let service = NativeEmbeddingService::default();
        assert_eq!(service.name(), "native-bert");
    }

    #[test]
    fn test_native_service_with_model_config_qwen_default() {
        use crate::model::ModelConfig;
        let cfg = ModelConfig::new(EmbeddingModel::Qwen3Embedding4B);
        let service = NativeEmbeddingService::with_model_config(cfg).unwrap();
        assert!(service.supports_model(EmbeddingModel::Qwen3Embedding4B));
        assert!(!service.supports_model(EmbeddingModel::Qwen3Embedding0_6B));
    }

    #[test]
    fn test_native_service_with_model_config_invalid_dim_rejected() {
        use crate::model::ModelConfig;
        // BgeSmallEnV15 does not support MRL — output_dim must be rejected at construction.
        let cfg = ModelConfig {
            model: EmbeddingModel::BgeSmallEnV15,
            output_dim: Some(128),
        };
        assert!(NativeEmbeddingService::with_model_config(cfg).is_err());
    }

    #[test]
    fn test_native_service_model_config_returns_configured_dim() {
        let cfg = ModelConfig::try_new(EmbeddingModel::Qwen3Embedding4B, Some(1024)).unwrap();
        let service = NativeEmbeddingService::with_model_config(cfg).unwrap();
        let returned = service.model_config(EmbeddingModel::Qwen3Embedding4B);
        assert_eq!(returned.output_dim, Some(1024));
        assert_eq!(returned.dimensions(), 1024);
    }

    #[test]
    fn test_native_service_model_config_unknown_model_returns_native() {
        let service = NativeEmbeddingService::default(); // BgeSmallEnV15
        let returned = service.model_config(EmbeddingModel::BgeBaseEnV15);
        assert_eq!(returned.output_dim, None);
        assert_eq!(returned.dimensions(), 768);
    }

    // Mutex to serialize all tests that read or write LATTICE_EMBED_DIM.
    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// LATTICE_EMBED_DIM absent → with_model_from_env returns native dim (2560 for 4B).
    #[test]
    fn test_with_model_from_env_absent_returns_native_dim() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: ENV_MUTEX serialises all env-var mutations in this test binary.
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        let svc =
            NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B).unwrap();
        assert_eq!(
            svc.model_config(EmbeddingModel::Qwen3Embedding4B)
                .output_dim,
            None
        );
        assert_eq!(
            svc.model_config(EmbeddingModel::Qwen3Embedding4B)
                .dimensions(),
            2560
        );
    }

    /// LATTICE_EMBED_DIM=1024 → with_model_from_env stores output_dim=Some(1024).
    #[test]
    fn test_with_model_from_env_dim_1024() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "1024") };
        let result = NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        let svc = result.unwrap();
        let cfg = svc.model_config(EmbeddingModel::Qwen3Embedding4B);
        assert_eq!(cfg.output_dim, Some(1024));
        assert_eq!(cfg.dimensions(), 1024);
    }

    /// LATTICE_EMBED_DIM=512 → Qwen3Embedding0_6B works (512 < 1024 native).
    #[test]
    fn test_with_model_from_env_qwen_06b_dim_512() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "512") };
        let result =
            NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding0_6B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        let svc = result.unwrap();
        let cfg = svc.model_config(EmbeddingModel::Qwen3Embedding0_6B);
        assert_eq!(cfg.output_dim, Some(512));
        assert_eq!(cfg.dimensions(), 512);
    }

    /// LATTICE_EMBED_DIM=not_a_number → error.
    #[test]
    fn test_with_model_from_env_invalid_value_returns_error() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "not_a_number") };
        let result = NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        assert!(result.is_err(), "non-numeric LATTICE_EMBED_DIM must fail");
    }

    /// LATTICE_EMBED_DIM=16 → error because 16 < MIN_MRL_OUTPUT_DIM (32).
    #[test]
    fn test_with_model_from_env_dim_below_minimum_returns_error() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "16") };
        let result = NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        assert!(result.is_err(), "dim < 32 must be rejected");
    }

    /// LATTICE_EMBED_DIM=9999 → error because 9999 > 2560 (native dim for 4B).
    #[test]
    fn test_with_model_from_env_dim_above_native_returns_error() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "9999") };
        let result = NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        assert!(result.is_err(), "dim > native must be rejected");
    }

    /// LATTICE_EMBED_DIM="" (empty string) → treated as absent → native dim.
    #[test]
    fn test_with_model_from_env_empty_string_treated_as_absent() {
        let _g = ENV_MUTEX.lock().unwrap();
        // SAFETY: serialised by ENV_MUTEX.
        unsafe { std::env::set_var("LATTICE_EMBED_DIM", "") };
        let result = NativeEmbeddingService::with_model_from_env(EmbeddingModel::Qwen3Embedding4B);
        unsafe { std::env::remove_var("LATTICE_EMBED_DIM") };
        let svc = result.unwrap();
        assert_eq!(
            svc.model_config(EmbeddingModel::Qwen3Embedding4B)
                .output_dim,
            None
        );
    }

    /// embed() must return an error when the requested model differs from the loaded one.
    #[tokio::test]
    async fn test_native_service_embed_wrong_model_returns_error() {
        let service = NativeEmbeddingService::default(); // loads BgeSmallEnV15
        let texts = vec!["hello".to_string()];
        // Requesting a different local model should fail before any IO.
        let result = service.embed(&texts, EmbeddingModel::BgeBaseEnV15).await;
        assert!(result.is_err(), "expected error for wrong model");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("BgeBaseEnV15") || err.contains("requested model"),
            "error should mention the model mismatch, got: {err}"
        );
    }

    /// #1010 repro shape 1: an all-ASCII text where char count == byte count.
    /// Rejected under either a byte or a char guard, so this alone would not
    /// have caught the bug — kept alongside the multibyte case for coverage.
    #[tokio::test]
    async fn test_native_service_ascii_text_too_long_reports_bytes() {
        let text = "a".repeat(36_556);
        assert_eq!(text.len(), 36_556);
        assert_eq!(text.chars().count(), 36_556);

        let service = NativeEmbeddingService::default();
        let texts = vec![text];
        let err = service
            .embed(&texts, EmbeddingModel::BgeSmallEnV15)
            .await
            .expect_err("text over MAX_TEXT_BYTES must be rejected");
        let msg = err.to_string();
        assert!(msg.contains("36556"), "got: {msg}");
        assert!(msg.contains("bytes"), "got: {msg}");
    }

    /// #1010 repro shape 2 (discriminating case): 32,768 chars / 32,846 bytes —
    /// under char count, this is exactly at the limit and would be ADMITTED;
    /// under byte count (the guard's actual behavior), it exceeds the limit
    /// and must be REJECTED. If the guard is ever switched to `chars().count()`,
    /// this test fails.
    #[tokio::test]
    async fn test_native_service_multibyte_text_too_long_uses_byte_count() {
        // 78 two-byte chars + 32,690 one-byte chars = 32,768 chars / 32,846 bytes.
        let text = format!("{}{}", "é".repeat(78), "a".repeat(32_690));
        assert_eq!(text.chars().count(), 32_768);
        assert_eq!(text.len(), 32_846);

        let service = NativeEmbeddingService::default();
        let texts = vec![text];
        let err = service
            .embed(&texts, EmbeddingModel::BgeSmallEnV15)
            .await
            .expect_err("32846-byte text must be rejected under byte semantics");
        let msg = err.to_string();
        assert!(msg.contains("32846"), "got: {msg}");
        assert!(
            !msg.contains("32768 bytes exceeds") && !msg.contains("32768 chars exceeds"),
            "reported length must be the byte count (32846), not the char count: {msg}"
        );
    }

    /// Same discriminating shape, through the `CachedEmbeddingService` wrapper —
    /// a sibling invocation path with its own copy of the length guard.
    #[tokio::test]
    async fn test_cached_service_multibyte_text_too_long_uses_byte_count() {
        use crate::service::CachedEmbeddingService;
        use std::sync::Arc;

        let text = format!("{}{}", "é".repeat(78), "a".repeat(32_690));
        assert_eq!(text.chars().count(), 32_768);
        assert_eq!(text.len(), 32_846);

        let service =
            CachedEmbeddingService::with_default_cache(Arc::new(NativeEmbeddingService::default()));
        let texts = vec![text];
        let err = service
            .embed(&texts, EmbeddingModel::BgeSmallEnV15)
            .await
            .expect_err("32846-byte text must be rejected under byte semantics");
        let msg = err.to_string();
        assert!(msg.contains("32846"), "got: {msg}");
    }

    /// #1104: the published cap covers caller text, so text sitting exactly at
    /// the cap must clear validation on a role path even though the model's
    /// query instruction makes the prepared string longer than the cap.
    ///
    /// Model weights are not present in every environment, so this asserts the
    /// request got PAST length validation rather than asserting success. That is
    /// the precise thing that regressed: validating prepared text returned
    /// `TextTooLong` here and never reached model loading.
    #[tokio::test]
    async fn test_role_path_validates_caller_text_not_prepared_text() {
        let model = EmbeddingModel::BgeSmallEnV15;
        assert!(
            model.max_instruction_bytes() > 0,
            "test needs a model that prepends a query instruction"
        );

        let text = "a".repeat(MAX_TEXT_BYTES);
        assert_eq!(text.len(), MAX_TEXT_BYTES);

        let service = NativeEmbeddingService::default();
        if let Err(e) = service.embed_query(&[text], model).await {
            assert!(
                !matches!(e, crate::error::EmbedError::TextTooLong { .. }),
                "caller text at exactly the cap must not be rejected for length: {e}"
            );
        }
    }

    /// #1104 companion: the cap is still enforced, and the error still reports
    /// the published cap rather than the wider backstop used on prepared text.
    #[tokio::test]
    async fn test_role_path_still_rejects_caller_text_over_the_cap() {
        let text = "a".repeat(MAX_TEXT_BYTES + 1);
        let service = NativeEmbeddingService::default();
        let err = service
            .embed_query(&[text], EmbeddingModel::BgeSmallEnV15)
            .await
            .expect_err("caller text over the cap must be rejected");
        assert!(
            matches!(err, crate::error::EmbedError::TextTooLong { max, .. } if max == MAX_TEXT_BYTES),
            "must report the published cap, got: {err}"
        );
    }

    /// Sibling invocation path: the cached wrapper has its own entry point and
    /// must apply the same caller-text semantics.
    #[tokio::test]
    async fn test_cached_role_path_validates_caller_text() {
        use crate::service::CachedEmbeddingService;
        use std::sync::Arc;

        let text = "a".repeat(MAX_TEXT_BYTES);
        let service =
            CachedEmbeddingService::with_default_cache(Arc::new(NativeEmbeddingService::default()));

        if let Err(e) = service
            .embed_query(&[text], EmbeddingModel::BgeSmallEnV15)
            .await
        {
            assert!(
                !matches!(e, crate::error::EmbedError::TextTooLong { .. }),
                "caller text at exactly the cap must not be rejected for length: {e}"
            );
        }
    }
}

#[test]
fn test_max_instruction_bytes_matches_the_longest_instruction() {
    for model in [
        EmbeddingModel::BgeSmallEnV15,
        EmbeddingModel::MultilingualE5Small,
        EmbeddingModel::Qwen3Embedding0_6B,
        EmbeddingModel::AllMiniLmL6V2,
    ] {
        let q = model.query_instruction().map_or(0, str::len);
        let d = model.document_instruction().map_or(0, str::len);
        assert_eq!(
            model.max_instruction_bytes(),
            q.max(d),
            "{model:?} reports the wrong instruction bound"
        );
    }

    // Symmetric models take no instruction, so prepared text is caller text and
    // the backstop collapses onto the published cap exactly.
    assert_eq!(EmbeddingModel::AllMiniLmL6V2.max_instruction_bytes(), 0);
}

// ---------------------------------------------------------------------------
// External-implementor contract for the embed_with_role default (PR #1110)
// ---------------------------------------------------------------------------
//
// The default `embed_with_role` validates caller text, then prepends the role
// instruction and delegates to `embed`. For an out-of-tree service that enforces
// the exact `MAX_TEXT_BYTES` cap inside `embed` and does not override the default,
// that means cap-sized caller text for an instruction-bearing model reaches the
// backend already lengthened and is rejected. This is a deliberate, documented
// limitation of keeping `embed` the sole abstract method (backward compatible on
// a published crate); the escape hatch is overriding `embed_with_role`. Both in-
// crate implementors take the escape hatch. These tests pin both sides of that
// contract executably so the choice cannot regress into prose only. They use no
// model weights, so they run in every feature configuration.
mod external_impl_contract {
    use crate::error::{EmbedError, Result};
    use crate::model::EmbeddingModel;
    use crate::service::{
        EmbeddingRole, EmbeddingService, MAX_TEXT_BYTES, apply_prefix, validate_texts,
    };
    use async_trait::async_trait;

    /// Shared backend: exact published cap, no weights, one-dim stub vectors.
    fn exact_cap_embed(texts: &[String]) -> Result<Vec<Vec<f32>>> {
        for t in texts {
            if t.len() > MAX_TEXT_BYTES {
                return Err(EmbedError::TextTooLong {
                    length: t.len(),
                    max: MAX_TEXT_BYTES,
                });
            }
        }
        Ok(texts.iter().map(|_| vec![0.0]).collect())
    }

    /// External service that inherits the `embed_with_role` default unchanged.
    struct ExactCapNoOverride;

    #[async_trait]
    impl EmbeddingService for ExactCapNoOverride {
        async fn embed(&self, texts: &[String], _model: EmbeddingModel) -> Result<Vec<Vec<f32>>> {
            exact_cap_embed(texts)
        }
        fn supports_model(&self, _model: EmbeddingModel) -> bool {
            true
        }
        fn name(&self) -> &'static str {
            "exact-cap-no-override"
        }
    }

    /// External service that overrides the role method: validate caller text,
    /// then reach its own backend with a bound sized for the prepared string.
    struct ExactCapWithOverride;

    #[async_trait]
    impl EmbeddingService for ExactCapWithOverride {
        async fn embed(&self, texts: &[String], _model: EmbeddingModel) -> Result<Vec<Vec<f32>>> {
            exact_cap_embed(texts)
        }
        async fn embed_with_role(
            &self,
            texts: &[String],
            model: EmbeddingModel,
            role: EmbeddingRole,
        ) -> Result<Vec<Vec<f32>>> {
            validate_texts(texts)?;
            let prepared = apply_prefix(texts, role.instruction(model));
            let backstop = MAX_TEXT_BYTES + model.max_instruction_bytes();
            for t in &prepared {
                if t.len() > backstop {
                    return Err(EmbedError::TextTooLong {
                        length: t.len(),
                        max: MAX_TEXT_BYTES,
                    });
                }
            }
            Ok(prepared.iter().map(|_| vec![0.0]).collect())
        }
        fn supports_model(&self, _model: EmbeddingModel) -> bool {
            true
        }
        fn name(&self) -> &'static str {
            "exact-cap-with-override"
        }
    }

    /// The documented limitation, made executable: the default forwards prepared
    /// text through the exact-cap `embed`, so cap-sized caller text is rejected.
    /// Reverting the default to skip preparation would flip this, so it guards the
    /// dispatch shape rather than restating it.
    #[tokio::test]
    async fn default_role_forwards_prepared_text_to_embed() {
        let text = "a".repeat(MAX_TEXT_BYTES);
        assert_eq!(text.len(), MAX_TEXT_BYTES);
        let err = ExactCapNoOverride
            .embed_query(&[text], EmbeddingModel::BgeSmallEnV15)
            .await
            .expect_err("default forwards the lengthened string through exact-cap embed");
        assert!(
            matches!(err, EmbedError::TextTooLong { .. }),
            "expected the inherited exact-cap embed to reject prepared text, got: {err}"
        );
    }

    /// The escape hatch, made executable: overriding `embed_with_role` lets an
    /// external service enforce the cap on caller text and admit cap-sized input.
    #[tokio::test]
    async fn overriding_the_role_method_preserves_the_caller_cap() {
        let text = "a".repeat(MAX_TEXT_BYTES);
        let out = ExactCapWithOverride
            .embed_query(&[text], EmbeddingModel::BgeSmallEnV15)
            .await
            .expect("overriding embed_with_role admits cap-sized caller text");
        assert_eq!(out.len(), 1, "one embedding per input");
    }

    #[cfg(feature = "native")]
    #[tokio::test]
    async fn cached_wrapper_preserves_external_role_override() {
        use crate::service::CachedEmbeddingService;
        use std::sync::Arc;

        let text = "a".repeat(MAX_TEXT_BYTES);
        let service = CachedEmbeddingService::new(Arc::new(ExactCapWithOverride), 128);
        let out = service
            .embed_query(&[text], EmbeddingModel::BgeSmallEnV15)
            .await
            .expect("cached miss must preserve the inner role override");
        assert_eq!(out.len(), 1, "one embedding per input");
    }

    /// Over-cap caller text is rejected on both external shapes, and the error
    /// reports the published cap rather than any wider internal bound.
    #[tokio::test]
    async fn over_cap_caller_text_reports_published_cap_on_both_shapes() {
        let text = "a".repeat(MAX_TEXT_BYTES + 1);
        let one = std::slice::from_ref(&text);
        for res in [
            ExactCapNoOverride
                .embed_query(one, EmbeddingModel::BgeSmallEnV15)
                .await,
            ExactCapWithOverride
                .embed_query(one, EmbeddingModel::BgeSmallEnV15)
                .await,
        ] {
            let err = res.expect_err("over-cap caller text must be rejected");
            assert!(
                matches!(err, EmbedError::TextTooLong { max, .. } if max == MAX_TEXT_BYTES),
                "must report the published cap, got: {err}"
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Role-aware prompt tests (P0-E2)
// ---------------------------------------------------------------------------

/// E5 query_instruction returns "query: ", document_instruction returns "passage: ".
#[test]
fn test_e5_query_instruction() {
    assert_eq!(
        EmbeddingModel::MultilingualE5Small.query_instruction(),
        Some("query: "),
        "E5 small must return 'query: ' prefix"
    );
    assert_eq!(
        EmbeddingModel::MultilingualE5Base.query_instruction(),
        Some("query: "),
        "E5 base must return 'query: ' prefix"
    );
}

/// E5 document_instruction returns "passage: " (P0-E2 fix — was None before).
#[test]
fn test_e5_document_instruction() {
    assert_eq!(
        EmbeddingModel::MultilingualE5Small.document_instruction(),
        Some("passage: "),
        "E5 small must return 'passage: ' document prefix"
    );
    assert_eq!(
        EmbeddingModel::MultilingualE5Base.document_instruction(),
        Some("passage: "),
        "E5 base must return 'passage: ' document prefix"
    );
}

/// BGE-v1.5 query_instruction returns the documented retrieval instruction
/// (asymmetric retrieval — queries get the prefix, passages do not). Guards
/// against a future over-correction that adds the prefix to the passage side.
#[test]
fn test_bge_query_instruction() {
    assert_eq!(
        EmbeddingModel::BgeSmallEnV15.query_instruction(),
        Some("Represent this sentence for searching relevant passages: "),
        "BGE small must return the retrieval query instruction"
    );
    assert_eq!(
        EmbeddingModel::BgeBaseEnV15.query_instruction(),
        Some("Represent this sentence for searching relevant passages: "),
        "BGE base must return the retrieval query instruction"
    );
    assert_eq!(
        EmbeddingModel::BgeLargeEnV15.query_instruction(),
        Some("Represent this sentence for searching relevant passages: "),
        "BGE large must return the retrieval query instruction"
    );
    assert_eq!(
        EmbeddingModel::BgeSmallEnV15.document_instruction(),
        None,
        "BGE passages must stay unprefixed"
    );
}

/// BGE and MiniLM models must NOT have document_instruction (they use raw text).
#[test]
fn test_bge_minilm_no_document_instruction() {
    assert_eq!(
        EmbeddingModel::BgeSmallEnV15.document_instruction(),
        None,
        "BGE small must not have document prefix"
    );
    assert_eq!(EmbeddingModel::BgeBaseEnV15.document_instruction(), None);
    assert_eq!(EmbeddingModel::BgeLargeEnV15.document_instruction(), None);
    assert_eq!(EmbeddingModel::AllMiniLmL6V2.document_instruction(), None);
    assert_eq!(
        EmbeddingModel::ParaphraseMultilingualMiniLmL12V2.document_instruction(),
        None
    );
}

/// Qwen document_instruction returns None (raw passage, instruction only for queries).
#[test]
fn test_qwen_no_document_instruction() {
    assert_eq!(
        EmbeddingModel::Qwen3Embedding0_6B.document_instruction(),
        None,
        "Qwen document side uses raw text"
    );
}

/// apply_prefix prepends the prefix when Some, returns clone when None.
#[test]
fn test_apply_prefix_some() {
    let texts = vec!["hello".to_string(), "world".to_string()];
    let result = apply_prefix(&texts, Some("query: "));
    assert_eq!(result, vec!["query: hello", "query: world"]);
}

#[test]
fn test_apply_prefix_none() {
    let texts = vec!["hello".to_string()];
    let result = apply_prefix(&texts, None);
    assert_eq!(result, texts);
}

/// EmbeddingRole cache tags are distinct strings.
#[test]
fn test_embedding_role_cache_tags_distinct() {
    let q = EmbeddingRole::Query.cache_tag();
    let p = EmbeddingRole::Passage.cache_tag();
    let g = EmbeddingRole::Generic.cache_tag();
    assert_ne!(q, p);
    assert_ne!(q, g);
    assert_ne!(p, g);
}