1use std::fmt;
51
52use serde::{Deserialize, Serialize};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum SemanticMode {
60 #[default]
62 HybridPreferred,
63 LexicalOnly,
65 StrictSemantic,
67}
68
69impl fmt::Display for SemanticMode {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str(self.as_str())
72 }
73}
74
75impl SemanticMode {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::HybridPreferred => "hybrid_preferred",
79 Self::LexicalOnly => "lexical_only",
80 Self::StrictSemantic => "strict_semantic",
81 }
82 }
83
84 pub fn parse(s: &str) -> Option<Self> {
86 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
87 "hybrid_preferred" | "hybrid" | "default" | "auto" => Some(Self::HybridPreferred),
88 "lexical_only" | "lexical" | "lex" | "off" => Some(Self::LexicalOnly),
89 "strict_semantic" | "strict" | "semantic" => Some(Self::StrictSemantic),
90 _ => None,
91 }
92 }
93
94 pub fn should_build_semantic(&self) -> bool {
96 !matches!(self, Self::LexicalOnly)
97 }
98
99 pub fn requires_semantic(&self) -> bool {
101 matches!(self, Self::StrictSemantic)
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
109#[serde(rename_all = "snake_case")]
110pub enum ModelDownloadPolicy {
111 #[default]
113 OptIn,
114 BudgetGated,
116 Automatic,
118}
119
120impl fmt::Display for ModelDownloadPolicy {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(self.as_str())
123 }
124}
125
126impl ModelDownloadPolicy {
127 pub fn as_str(self) -> &'static str {
128 match self {
129 Self::OptIn => "opt_in",
130 Self::BudgetGated => "budget_gated",
131 Self::Automatic => "automatic",
132 }
133 }
134
135 pub fn parse(s: &str) -> Option<Self> {
136 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
137 "opt_in" | "optin" | "manual" => Some(Self::OptIn),
138 "budget_gated" | "budget" | "gated" => Some(Self::BudgetGated),
139 "automatic" | "auto" => Some(Self::Automatic),
140 _ => None,
141 }
142 }
143}
144
145pub const DEFAULT_FAST_TIER_EMBEDDER: &str = "hash";
149
150pub const DEFAULT_QUALITY_TIER_EMBEDDER: &str = "minilm";
152
153pub const DEFAULT_RERANKER: &str = "ms-marco-minilm";
155
156pub const DEFAULT_FAST_DIMENSION: usize = 256;
160
161pub const DEFAULT_QUALITY_DIMENSION: usize = 384;
163
164pub const DEFAULT_QUALITY_WEIGHT: f32 = 0.7;
166
167pub const DEFAULT_MAX_REFINEMENT_DOCS: usize = 100;
169
170pub const DEFAULT_SEMANTIC_BUDGET_MB: u64 = 500;
179
180pub const MIN_FREE_DISK_MB: u64 = 200;
185
186pub const MAX_MODEL_SIZE_MB: u64 = 300;
188
189pub const DEFAULT_MAX_BACKFILL_THREADS: usize = 1;
194
195pub const DEFAULT_MAX_BACKFILL_RSS_MB: u64 = 256;
198
199pub const DEFAULT_IDLE_DELAY_SECONDS: u64 = 30;
203
204pub const DEFAULT_CHUNK_TIMEOUT_SECONDS: u64 = 120;
207
208pub const SEMANTIC_SCHEMA_VERSION: u32 = 1;
214
215pub const CHUNKING_STRATEGY_VERSION: u32 = 1;
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct SemanticPolicy {
227 pub mode: SemanticMode,
230
231 pub download_policy: ModelDownloadPolicy,
233
234 pub fast_tier_embedder: String,
237
238 pub quality_tier_embedder: String,
240
241 pub reranker: String,
243
244 pub fast_dimension: usize,
247
248 pub quality_dimension: usize,
250
251 pub quality_weight: f32,
253
254 pub max_refinement_docs: usize,
256
257 pub semantic_budget_mb: u64,
260
261 pub min_free_disk_mb: u64,
263
264 pub max_model_size_mb: u64,
266
267 pub max_backfill_threads: usize,
270
271 pub max_backfill_rss_mb: u64,
273
274 pub idle_delay_seconds: u64,
276
277 pub chunk_timeout_seconds: u64,
279
280 pub semantic_schema_version: u32,
283
284 pub chunking_strategy_version: u32,
286}
287
288impl Default for SemanticPolicy {
289 fn default() -> Self {
290 Self::compiled_defaults()
291 }
292}
293
294impl SemanticPolicy {
295 pub fn compiled_defaults() -> Self {
297 Self {
298 mode: SemanticMode::default(),
299 download_policy: ModelDownloadPolicy::default(),
300 fast_tier_embedder: DEFAULT_FAST_TIER_EMBEDDER.to_owned(),
301 quality_tier_embedder: DEFAULT_QUALITY_TIER_EMBEDDER.to_owned(),
302 reranker: DEFAULT_RERANKER.to_owned(),
303 fast_dimension: DEFAULT_FAST_DIMENSION,
304 quality_dimension: DEFAULT_QUALITY_DIMENSION,
305 quality_weight: DEFAULT_QUALITY_WEIGHT,
306 max_refinement_docs: DEFAULT_MAX_REFINEMENT_DOCS,
307 semantic_budget_mb: DEFAULT_SEMANTIC_BUDGET_MB,
308 min_free_disk_mb: MIN_FREE_DISK_MB,
309 max_model_size_mb: MAX_MODEL_SIZE_MB,
310 max_backfill_threads: DEFAULT_MAX_BACKFILL_THREADS,
311 max_backfill_rss_mb: DEFAULT_MAX_BACKFILL_RSS_MB,
312 idle_delay_seconds: DEFAULT_IDLE_DELAY_SECONDS,
313 chunk_timeout_seconds: DEFAULT_CHUNK_TIMEOUT_SECONDS,
314 semantic_schema_version: SEMANTIC_SCHEMA_VERSION,
315 chunking_strategy_version: CHUNKING_STRATEGY_VERSION,
316 }
317 }
318
319 fn with_env_lookup(mut self, mut lookup: impl FnMut(&str) -> Option<String>) -> Self {
320 if let Some(val) = lookup("CASS_SEMANTIC_MODE")
321 && let Some(mode) = SemanticMode::parse(&val)
322 {
323 self.mode = mode;
324 }
325
326 if let Some(val) = lookup("CASS_SEMANTIC_EMBEDDER") {
332 match val.trim().to_ascii_lowercase().as_str() {
333 "hash" => {
334 self.quality_tier_embedder = "hash".to_owned();
337 }
338 other => {
339 self.quality_tier_embedder = other.to_owned();
341 }
342 }
343 }
344
345 if let Some(val) = lookup("CASS_SEMANTIC_DOWNLOAD_POLICY")
346 && let Some(policy) = ModelDownloadPolicy::parse(&val)
347 {
348 self.download_policy = policy;
349 }
350
351 if let Some(val) = lookup("CASS_SEMANTIC_BUDGET_MB")
352 && let Ok(mb) = val.trim().parse::<u64>()
353 {
354 self.semantic_budget_mb = mb;
355 }
356
357 if let Some(val) = lookup("CASS_SEMANTIC_MIN_FREE_DISK_MB")
358 && let Ok(mb) = val.trim().parse::<u64>()
359 {
360 self.min_free_disk_mb = mb;
361 }
362
363 if let Some(val) = lookup("CASS_SEMANTIC_MAX_MODEL_SIZE_MB")
364 && let Ok(mb) = val.trim().parse::<u64>()
365 {
366 self.max_model_size_mb = mb;
367 }
368
369 if let Some(val) = lookup("CASS_TWO_TIER_FAST_DIM")
372 && let Ok(dim) = val.trim().parse()
373 {
374 self.fast_dimension = dim;
375 }
376
377 if let Some(val) = lookup("CASS_TWO_TIER_QUALITY_DIM")
378 && let Ok(dim) = val.trim().parse()
379 {
380 self.quality_dimension = dim;
381 }
382
383 if let Some(val) = lookup("CASS_TWO_TIER_QUALITY_WEIGHT")
384 && let Ok(w) = val.trim().parse::<f32>()
385 {
386 self.quality_weight = w.clamp(0.0, 1.0);
387 }
388
389 if let Some(val) = lookup("CASS_TWO_TIER_MAX_REFINEMENT")
390 && let Ok(max) = val.trim().parse()
391 {
392 self.max_refinement_docs = max;
393 }
394
395 if let Some(val) = lookup("CASS_SEMANTIC_MAX_BACKFILL_THREADS")
396 && let Ok(n) = val.trim().parse()
397 {
398 self.max_backfill_threads = n;
399 }
400
401 if let Some(val) = lookup("CASS_SEMANTIC_MAX_BACKFILL_RSS_MB")
402 && let Ok(mb) = val.trim().parse()
403 {
404 self.max_backfill_rss_mb = mb;
405 }
406
407 if let Some(val) = lookup("CASS_SEMANTIC_IDLE_DELAY_SECONDS")
408 && let Ok(s) = val.trim().parse()
409 {
410 self.idle_delay_seconds = s;
411 }
412
413 if let Some(val) = lookup("CASS_SEMANTIC_CHUNK_TIMEOUT_SECONDS")
414 && let Ok(s) = val.trim().parse()
415 {
416 self.chunk_timeout_seconds = s;
417 }
418
419 self
420 }
421
422 pub fn with_env_overrides(self) -> Self {
426 self.with_env_lookup(|key| dotenvy::var(key).ok())
427 }
428
429 pub fn with_cli_overrides(mut self, overrides: &CliSemanticOverrides) -> Self {
433 if let Some(mode) = overrides.mode {
434 self.mode = mode;
435 }
436 if let Some(budget) = overrides.semantic_budget_mb {
437 self.semantic_budget_mb = budget;
438 }
439 if let Some(ref embedder) = overrides.quality_tier_embedder {
440 self.quality_tier_embedder = embedder.clone();
441 }
442 if let Some(threads) = overrides.max_backfill_threads {
443 self.max_backfill_threads = threads;
444 }
445 self
446 }
447
448 pub fn resolve(cli: &CliSemanticOverrides) -> Self {
450 Self::compiled_defaults()
451 .with_env_overrides()
452 .with_cli_overrides(cli)
453 }
454}
455
456#[derive(Debug, Clone, Default)]
458pub struct CliSemanticOverrides {
459 pub mode: Option<SemanticMode>,
460 pub semantic_budget_mb: Option<u64>,
461 pub quality_tier_embedder: Option<String>,
462 pub max_backfill_threads: Option<usize>,
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum SettingSource {
471 CompiledDefault,
473 Config,
475 Environment,
477 Cli,
479}
480
481impl fmt::Display for SettingSource {
482 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
483 f.write_str(self.as_str())
484 }
485}
486
487impl SettingSource {
488 pub fn as_str(self) -> &'static str {
489 match self {
490 Self::CompiledDefault => "compiled_default",
491 Self::Config => "config",
492 Self::Environment => "environment",
493 Self::Cli => "cli",
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct EffectiveSetting {
501 pub name: String,
502 pub value: String,
503 pub source: SettingSource,
504 pub env_var: Option<String>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct EffectiveSettings {
516 pub settings: Vec<EffectiveSetting>,
517}
518
519fn compiled_default_setting(name: &str, value: impl Into<String>) -> EffectiveSetting {
520 EffectiveSetting {
521 name: name.to_owned(),
522 value: value.into(),
523 source: SettingSource::CompiledDefault,
524 env_var: None,
525 }
526}
527
528impl EffectiveSettings {
529 fn resolve_with_env_lookup(
530 cli: &CliSemanticOverrides,
531 lookup: impl FnMut(&str) -> Option<String>,
532 ) -> Self {
533 let defaults = SemanticPolicy::compiled_defaults();
534 let env_policy = defaults.clone().with_env_lookup(lookup);
535 let final_policy = env_policy.clone().with_cli_overrides(cli);
536
537 let mut settings = Vec::new();
538
539 macro_rules! track {
541 ($name:expr, $field:ident, $env_var:expr, $cli_field:ident) => {
542 let source = if cli.$cli_field.is_some() {
543 SettingSource::Cli
544 } else if env_policy.$field != defaults.$field {
545 SettingSource::Environment
546 } else {
547 SettingSource::CompiledDefault
548 };
549 settings.push(EffectiveSetting {
550 name: $name.to_owned(),
551 value: format!("{}", final_policy.$field),
552 source,
553 env_var: Some($env_var.to_owned()),
554 });
555 };
556 }
557
558 track!("mode", mode, "CASS_SEMANTIC_MODE", mode);
560
561 track!(
563 "semantic_budget_mb",
564 semantic_budget_mb,
565 "CASS_SEMANTIC_BUDGET_MB",
566 semantic_budget_mb
567 );
568
569 track!(
571 "quality_tier_embedder",
572 quality_tier_embedder,
573 "CASS_SEMANTIC_EMBEDDER",
574 quality_tier_embedder
575 );
576
577 track!(
579 "max_backfill_threads",
580 max_backfill_threads,
581 "CASS_SEMANTIC_MAX_BACKFILL_THREADS",
582 max_backfill_threads
583 );
584
585 settings.push(compiled_default_setting(
588 "fast_tier_embedder",
589 final_policy.fast_tier_embedder.clone(),
590 ));
591 settings.push(compiled_default_setting(
592 "reranker",
593 final_policy.reranker.clone(),
594 ));
595
596 type EnvOnlyFieldGetter = fn(&SemanticPolicy) -> String;
597 type EnvOnlyField<'a> = (&'a str, &'a str, EnvOnlyFieldGetter);
598
599 let env_only_fields: &[EnvOnlyField<'_>] = &[
600 ("fast_dimension", "CASS_TWO_TIER_FAST_DIM", |p| {
601 p.fast_dimension.to_string()
602 }),
603 ("quality_dimension", "CASS_TWO_TIER_QUALITY_DIM", |p| {
604 p.quality_dimension.to_string()
605 }),
606 ("quality_weight", "CASS_TWO_TIER_QUALITY_WEIGHT", |p| {
607 format!("{}", p.quality_weight)
608 }),
609 ("max_refinement_docs", "CASS_TWO_TIER_MAX_REFINEMENT", |p| {
610 p.max_refinement_docs.to_string()
611 }),
612 ("min_free_disk_mb", "CASS_SEMANTIC_MIN_FREE_DISK_MB", |p| {
613 p.min_free_disk_mb.to_string()
614 }),
615 (
616 "max_model_size_mb",
617 "CASS_SEMANTIC_MAX_MODEL_SIZE_MB",
618 |p| p.max_model_size_mb.to_string(),
619 ),
620 ("download_policy", "CASS_SEMANTIC_DOWNLOAD_POLICY", |p| {
621 p.download_policy.to_string()
622 }),
623 (
624 "idle_delay_seconds",
625 "CASS_SEMANTIC_IDLE_DELAY_SECONDS",
626 |p| p.idle_delay_seconds.to_string(),
627 ),
628 (
629 "chunk_timeout_seconds",
630 "CASS_SEMANTIC_CHUNK_TIMEOUT_SECONDS",
631 |p| p.chunk_timeout_seconds.to_string(),
632 ),
633 (
634 "max_backfill_rss_mb",
635 "CASS_SEMANTIC_MAX_BACKFILL_RSS_MB",
636 |p| p.max_backfill_rss_mb.to_string(),
637 ),
638 ];
639
640 for (name, env_var, getter) in env_only_fields {
641 let default_val = getter(&defaults);
642 let env_val = getter(&env_policy);
643 let source = if env_val != default_val {
644 SettingSource::Environment
645 } else {
646 SettingSource::CompiledDefault
647 };
648 settings.push(EffectiveSetting {
649 name: name.to_string(),
650 value: getter(&final_policy),
651 source,
652 env_var: Some(env_var.to_string()),
653 });
654 }
655
656 settings.push(compiled_default_setting(
658 "semantic_schema_version",
659 final_policy.semantic_schema_version.to_string(),
660 ));
661 settings.push(compiled_default_setting(
662 "chunking_strategy_version",
663 final_policy.chunking_strategy_version.to_string(),
664 ));
665
666 Self { settings }
667 }
668
669 pub fn resolve(cli: &CliSemanticOverrides) -> Self {
672 Self::resolve_with_env_lookup(cli, |key| dotenvy::var(key).ok())
673 }
674
675 pub fn get(&self, name: &str) -> Option<&EffectiveSetting> {
677 self.settings.iter().find(|s| s.name == name)
678 }
679
680 pub fn source_counts(&self) -> std::collections::HashMap<SettingSource, usize> {
682 let mut counts = std::collections::HashMap::new();
683 for s in &self.settings {
684 *counts.entry(s.source).or_insert(0) += 1;
685 }
686 counts
687 }
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "snake_case")]
695pub enum SemanticCapability {
696 FullQuality,
698 QualityNoHnsw,
700 FastTierOnly,
702 LexicalOnly,
704 Degraded { reason: String },
706}
707
708impl SemanticCapability {
709 pub fn can_search_semantic(&self) -> bool {
711 matches!(
712 self,
713 Self::FullQuality | Self::QualityNoHnsw | Self::FastTierOnly
714 )
715 }
716
717 pub fn has_quality_tier(&self) -> bool {
719 matches!(self, Self::FullQuality | Self::QualityNoHnsw)
720 }
721
722 pub fn status_label(&self) -> &'static str {
724 match self {
725 Self::FullQuality => "SEM+",
726 Self::QualityNoHnsw => "SEM",
727 Self::FastTierOnly => "SEM*",
728 Self::LexicalOnly => "LEX",
729 Self::Degraded { .. } => "ERR",
730 }
731 }
732
733 pub fn summary(&self) -> String {
735 match self {
736 Self::FullQuality => {
737 "Full semantic: ML embedder + vector index + HNSW accelerator".to_owned()
738 }
739 Self::QualityNoHnsw => {
740 "Quality semantic: ML embedder + vector index (brute-force)".to_owned()
741 }
742 Self::FastTierOnly => {
743 "Fast semantic: hash embedder only (install ML model for quality)".to_owned()
744 }
745 Self::LexicalOnly => "Lexical only: semantic search disabled by policy".to_owned(),
746 Self::Degraded { reason } => format!("Degraded: {reason}"),
747 }
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755#[serde(rename_all = "snake_case")]
756pub enum InvalidationAction {
757 UpToDate,
759 RebuildInBackground,
761 DiscardAndRebuild { reason: String },
763 Evict,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct SemanticAssetManifest {
770 pub embedder_id: String,
772 pub model_revision: String,
774 pub schema_version: u32,
776 pub chunking_version: u32,
778 pub doc_count: u64,
780 pub built_at_ms: i64,
782}
783
784impl SemanticAssetManifest {
785 pub fn invalidation_action(
792 &self,
793 policy: &SemanticPolicy,
794 current_model_revision: &str,
795 expected_embedder_id: &str,
796 ) -> InvalidationAction {
797 if !policy.mode.should_build_semantic() {
799 return InvalidationAction::Evict;
800 }
801
802 if self.schema_version != policy.semantic_schema_version {
804 return InvalidationAction::DiscardAndRebuild {
805 reason: format!(
806 "semantic schema version changed ({} → {})",
807 self.schema_version, policy.semantic_schema_version
808 ),
809 };
810 }
811
812 if self.chunking_version != policy.chunking_strategy_version {
814 return InvalidationAction::DiscardAndRebuild {
815 reason: format!(
816 "chunking strategy version changed ({} → {})",
817 self.chunking_version, policy.chunking_strategy_version
818 ),
819 };
820 }
821
822 if self.embedder_id != expected_embedder_id {
827 return InvalidationAction::DiscardAndRebuild {
828 reason: format!(
829 "embedder changed ({} → {})",
830 self.embedder_id, expected_embedder_id
831 ),
832 };
833 }
834
835 if self.model_revision != current_model_revision {
838 return InvalidationAction::RebuildInBackground;
839 }
840
841 InvalidationAction::UpToDate
842 }
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
849#[serde(rename_all = "snake_case")]
850pub enum BudgetDecision {
851 Allowed,
853 OverBudgetWarn { used_mb: u64, budget_mb: u64 },
855 DiskPressureDeny { free_mb: u64, min_required_mb: u64 },
857 ModelTooLarge { model_mb: u64, max_mb: u64 },
859}
860
861impl BudgetDecision {
862 pub fn is_allowed(&self) -> bool {
863 matches!(self, Self::Allowed | Self::OverBudgetWarn { .. })
864 }
865}
866
867impl SemanticPolicy {
868 pub fn check_budget(
878 &self,
879 write_size_mb: u64,
880 current_semantic_usage_mb: u64,
881 free_disk_mb: u64,
882 ) -> BudgetDecision {
883 if write_size_mb > self.max_model_size_mb {
885 return BudgetDecision::ModelTooLarge {
886 model_mb: write_size_mb,
887 max_mb: self.max_model_size_mb,
888 };
889 }
890
891 if free_disk_mb.saturating_sub(write_size_mb) < self.min_free_disk_mb {
893 return BudgetDecision::DiskPressureDeny {
894 free_mb: free_disk_mb,
895 min_required_mb: self.min_free_disk_mb,
896 };
897 }
898
899 let new_total = current_semantic_usage_mb.saturating_add(write_size_mb);
901 if new_total > self.semantic_budget_mb {
902 return BudgetDecision::OverBudgetWarn {
903 used_mb: new_total,
904 budget_mb: self.semantic_budget_mb,
905 };
906 }
907
908 BudgetDecision::Allowed
909 }
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize)]
916pub struct SemanticCapabilityReport {
917 pub mode: SemanticMode,
918 pub capability: SemanticCapability,
919 pub fast_tier_embedder: String,
920 pub quality_tier_embedder: String,
921 pub reranker: String,
922 pub fast_dimension: usize,
923 pub quality_dimension: usize,
924 pub quality_weight: f32,
925 pub semantic_budget_mb: u64,
926 pub current_usage_mb: u64,
927 pub download_policy: ModelDownloadPolicy,
928 pub semantic_schema_version: u32,
929 pub chunking_strategy_version: u32,
930 pub summary: String,
931}
932
933impl SemanticCapabilityReport {
934 pub fn from_policy(
936 policy: &SemanticPolicy,
937 capability: SemanticCapability,
938 current_usage_mb: u64,
939 ) -> Self {
940 let summary = capability.summary();
941 Self {
942 mode: policy.mode,
943 capability,
944 fast_tier_embedder: policy.fast_tier_embedder.clone(),
945 quality_tier_embedder: policy.quality_tier_embedder.clone(),
946 reranker: policy.reranker.clone(),
947 fast_dimension: policy.fast_dimension,
948 quality_dimension: policy.quality_dimension,
949 quality_weight: policy.quality_weight,
950 semantic_budget_mb: policy.semantic_budget_mb,
951 current_usage_mb,
952 download_policy: policy.download_policy,
953 semantic_schema_version: policy.semantic_schema_version,
954 chunking_strategy_version: policy.chunking_strategy_version,
955 summary,
956 }
957 }
958}
959
960pub const EVICTION_ORDER: &[SemanticArtifactKind] = &[
964 SemanticArtifactKind::HnswAccelerator,
965 SemanticArtifactKind::QualityVectorIndex,
966 SemanticArtifactKind::FastVectorIndex,
967 SemanticArtifactKind::ModelFiles,
968];
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
972#[serde(rename_all = "snake_case")]
973pub enum SemanticArtifactKind {
974 HnswAccelerator,
975 QualityVectorIndex,
976 FastVectorIndex,
977 ModelFiles,
978}
979
980impl SemanticArtifactKind {
981 pub fn required_for(&self, capability: &SemanticCapability) -> bool {
983 match (self, capability) {
984 (_, SemanticCapability::LexicalOnly) => false,
985 (Self::HnswAccelerator, _) => false, (Self::ModelFiles, SemanticCapability::FastTierOnly) => false,
987 (Self::QualityVectorIndex, SemanticCapability::FastTierOnly) => false,
988 _ => true,
989 }
990 }
991}
992
993#[cfg(test)]
996mod tests {
997 use super::*;
998
999 #[test]
1002 fn compiled_defaults_are_hybrid_preferred() {
1003 let p = SemanticPolicy::compiled_defaults();
1004 assert_eq!(p.mode, SemanticMode::HybridPreferred);
1005 assert_eq!(p.fast_tier_embedder, "hash");
1006 assert_eq!(p.quality_tier_embedder, "minilm");
1007 assert_eq!(p.download_policy, ModelDownloadPolicy::OptIn);
1008 assert_eq!(p.fast_dimension, 256);
1009 assert_eq!(p.quality_dimension, 384);
1010 assert!((p.quality_weight - 0.7).abs() < f32::EPSILON);
1011 assert_eq!(p.max_refinement_docs, 100);
1012 assert_eq!(p.semantic_budget_mb, 500);
1013 assert_eq!(p.min_free_disk_mb, 200);
1014 assert_eq!(p.max_backfill_threads, 1);
1015 assert_eq!(p.semantic_schema_version, SEMANTIC_SCHEMA_VERSION);
1016 assert_eq!(p.chunking_strategy_version, CHUNKING_STRATEGY_VERSION);
1017 }
1018
1019 #[test]
1020 fn cli_overrides_beat_defaults() {
1021 let cli = CliSemanticOverrides {
1022 mode: Some(SemanticMode::LexicalOnly),
1023 semantic_budget_mb: Some(100),
1024 quality_tier_embedder: Some("snowflake".to_owned()),
1025 max_backfill_threads: Some(4),
1026 };
1027 let p = SemanticPolicy::compiled_defaults().with_cli_overrides(&cli);
1028 assert_eq!(p.mode, SemanticMode::LexicalOnly);
1029 assert_eq!(p.semantic_budget_mb, 100);
1030 assert_eq!(p.quality_tier_embedder, "snowflake");
1031 assert_eq!(p.max_backfill_threads, 4);
1032 assert_eq!(p.fast_tier_embedder, "hash");
1034 assert_eq!(p.quality_dimension, 384);
1035 }
1036
1037 #[test]
1038 fn cli_overrides_beat_env_overrides() {
1039 let mut p = SemanticPolicy::compiled_defaults();
1041 p.mode = SemanticMode::LexicalOnly; let cli = CliSemanticOverrides {
1043 mode: Some(SemanticMode::StrictSemantic),
1044 ..Default::default()
1045 };
1046 let p = p.with_cli_overrides(&cli);
1047 assert_eq!(p.mode, SemanticMode::StrictSemantic);
1048 }
1049
1050 #[test]
1053 fn semantic_mode_parsing() {
1054 let cases: &[(&str, Option<SemanticMode>)] = &[
1055 ("hybrid_preferred", Some(SemanticMode::HybridPreferred)),
1056 ("hybrid", Some(SemanticMode::HybridPreferred)),
1057 ("default", Some(SemanticMode::HybridPreferred)),
1058 ("auto", Some(SemanticMode::HybridPreferred)),
1059 ("HYBRID", Some(SemanticMode::HybridPreferred)),
1060 ("lexical_only", Some(SemanticMode::LexicalOnly)),
1061 ("lexical", Some(SemanticMode::LexicalOnly)),
1062 ("lex", Some(SemanticMode::LexicalOnly)),
1063 ("off", Some(SemanticMode::LexicalOnly)),
1064 ("strict_semantic", Some(SemanticMode::StrictSemantic)),
1065 ("strict", Some(SemanticMode::StrictSemantic)),
1066 ("semantic", Some(SemanticMode::StrictSemantic)),
1067 (" Hybrid-Preferred ", Some(SemanticMode::HybridPreferred)),
1068 ("nonsense", None),
1069 ("", None),
1070 ];
1071 for (input, expected) in cases {
1072 assert_eq!(
1073 SemanticMode::parse(input),
1074 *expected,
1075 "failed for input: {input:?}"
1076 );
1077 }
1078 }
1079
1080 #[test]
1081 fn download_policy_parsing() {
1082 let cases: &[(&str, Option<ModelDownloadPolicy>)] = &[
1083 ("opt_in", Some(ModelDownloadPolicy::OptIn)),
1084 ("optin", Some(ModelDownloadPolicy::OptIn)),
1085 ("manual", Some(ModelDownloadPolicy::OptIn)),
1086 ("budget_gated", Some(ModelDownloadPolicy::BudgetGated)),
1087 ("budget", Some(ModelDownloadPolicy::BudgetGated)),
1088 ("gated", Some(ModelDownloadPolicy::BudgetGated)),
1089 ("automatic", Some(ModelDownloadPolicy::Automatic)),
1090 ("auto", Some(ModelDownloadPolicy::Automatic)),
1091 ("xyz", None),
1092 ];
1093 for (input, expected) in cases {
1094 assert_eq!(
1095 ModelDownloadPolicy::parse(input),
1096 *expected,
1097 "failed for input: {input:?}"
1098 );
1099 }
1100 }
1101
1102 #[test]
1103 fn display_spellings_delegate_to_as_str() {
1104 let semantic_modes = [
1105 (SemanticMode::HybridPreferred, "hybrid_preferred"),
1106 (SemanticMode::LexicalOnly, "lexical_only"),
1107 (SemanticMode::StrictSemantic, "strict_semantic"),
1108 ];
1109 for (mode, expected) in semantic_modes {
1110 assert_eq!(mode.as_str(), expected);
1111 assert_eq!(mode.to_string(), expected);
1112 }
1113
1114 let download_policies = [
1115 (ModelDownloadPolicy::OptIn, "opt_in"),
1116 (ModelDownloadPolicy::BudgetGated, "budget_gated"),
1117 (ModelDownloadPolicy::Automatic, "automatic"),
1118 ];
1119 for (policy, expected) in download_policies {
1120 assert_eq!(policy.as_str(), expected);
1121 assert_eq!(policy.to_string(), expected);
1122 }
1123
1124 let setting_sources = [
1125 (SettingSource::CompiledDefault, "compiled_default"),
1126 (SettingSource::Config, "config"),
1127 (SettingSource::Environment, "environment"),
1128 (SettingSource::Cli, "cli"),
1129 ];
1130 for (source, expected) in setting_sources {
1131 assert_eq!(source.as_str(), expected);
1132 assert_eq!(source.to_string(), expected);
1133 }
1134 }
1135
1136 #[test]
1139 fn mode_behaviour_flags() {
1140 let cases: &[(SemanticMode, bool, bool)] = &[
1141 (SemanticMode::HybridPreferred, true, false),
1143 (SemanticMode::LexicalOnly, false, false),
1144 (SemanticMode::StrictSemantic, true, true),
1145 ];
1146 for (mode, build, require) in cases {
1147 assert_eq!(
1148 mode.should_build_semantic(),
1149 *build,
1150 "should_build for {mode:?}"
1151 );
1152 assert_eq!(mode.requires_semantic(), *require, "requires for {mode:?}");
1153 }
1154 }
1155
1156 #[test]
1159 fn capability_classification() {
1160 let cases: &[(SemanticCapability, bool, bool, &str)] = &[
1161 (SemanticCapability::FullQuality, true, true, "SEM+"),
1163 (SemanticCapability::QualityNoHnsw, true, true, "SEM"),
1164 (SemanticCapability::FastTierOnly, true, false, "SEM*"),
1165 (SemanticCapability::LexicalOnly, false, false, "LEX"),
1166 (
1167 SemanticCapability::Degraded {
1168 reason: "test".to_owned(),
1169 },
1170 false,
1171 false,
1172 "ERR",
1173 ),
1174 ];
1175 for (cap, can_search, has_quality, label) in cases {
1176 assert_eq!(
1177 cap.can_search_semantic(),
1178 *can_search,
1179 "can_search for {cap:?}"
1180 );
1181 assert_eq!(
1182 cap.has_quality_tier(),
1183 *has_quality,
1184 "has_quality for {cap:?}"
1185 );
1186 assert_eq!(cap.status_label(), *label, "label for {cap:?}");
1187 }
1188 }
1189
1190 #[test]
1193 fn budget_decisions() {
1194 let p = SemanticPolicy::compiled_defaults();
1195 let cases: &[(u64, u64, u64, BudgetDecision)] = &[
1198 (90, 100, 1000, BudgetDecision::Allowed),
1202 (
1204 90,
1205 450,
1206 1000,
1207 BudgetDecision::OverBudgetWarn {
1208 used_mb: 540,
1209 budget_mb: 500,
1210 },
1211 ),
1212 (
1214 90,
1215 0,
1216 250,
1217 BudgetDecision::DiskPressureDeny {
1218 free_mb: 250,
1219 min_required_mb: 200,
1220 },
1221 ),
1222 (
1224 350,
1225 0,
1226 1000,
1227 BudgetDecision::ModelTooLarge {
1228 model_mb: 350,
1229 max_mb: 300,
1230 },
1231 ),
1232 (90, 410, 1000, BudgetDecision::Allowed),
1234 (
1236 91,
1237 410,
1238 1000,
1239 BudgetDecision::OverBudgetWarn {
1240 used_mb: 501,
1241 budget_mb: 500,
1242 },
1243 ),
1244 (90, 0, 290, BudgetDecision::Allowed),
1246 (
1248 90,
1249 0,
1250 289,
1251 BudgetDecision::DiskPressureDeny {
1252 free_mb: 289,
1253 min_required_mb: 200,
1254 },
1255 ),
1256 ];
1257
1258 for (write, usage, free, expected) in cases {
1259 let got = p.check_budget(*write, *usage, *free);
1260 assert_eq!(
1261 got, *expected,
1262 "budget check failed for write={write}, usage={usage}, free={free}"
1263 );
1264 }
1265 }
1266
1267 #[test]
1270 fn invalidation_decisions() {
1271 let policy = SemanticPolicy::compiled_defaults();
1272 let expected_id = format!(
1273 "{}-{}",
1274 policy.quality_tier_embedder, policy.quality_dimension
1275 );
1276
1277 let base_manifest = SemanticAssetManifest {
1278 embedder_id: expected_id.clone(),
1279 model_revision: "abc123".to_owned(),
1280 schema_version: SEMANTIC_SCHEMA_VERSION,
1281 chunking_version: CHUNKING_STRATEGY_VERSION,
1282 doc_count: 1000,
1283 built_at_ms: 1700000000000,
1284 };
1285
1286 assert_eq!(
1288 base_manifest.invalidation_action(&policy, "abc123", &expected_id),
1289 InvalidationAction::UpToDate,
1290 );
1291
1292 assert_eq!(
1294 base_manifest.invalidation_action(&policy, "def456", &expected_id),
1295 InvalidationAction::RebuildInBackground,
1296 );
1297
1298 {
1300 let mut m = base_manifest.clone();
1301 m.schema_version = 0;
1302 let action = m.invalidation_action(&policy, "abc123", &expected_id);
1303 assert!(matches!(
1304 action,
1305 InvalidationAction::DiscardAndRebuild { .. }
1306 ));
1307 }
1308
1309 {
1311 let mut m = base_manifest.clone();
1312 m.chunking_version = 0;
1313 let action = m.invalidation_action(&policy, "abc123", &expected_id);
1314 assert!(matches!(
1315 action,
1316 InvalidationAction::DiscardAndRebuild { .. }
1317 ));
1318 }
1319
1320 {
1322 let mut m = base_manifest.clone();
1323 m.embedder_id = "snowflake-768".to_owned();
1324 let action = m.invalidation_action(&policy, "abc123", &expected_id);
1325 assert!(matches!(
1326 action,
1327 InvalidationAction::DiscardAndRebuild { .. }
1328 ));
1329 }
1330
1331 {
1333 let mut lex_policy = policy.clone();
1334 lex_policy.mode = SemanticMode::LexicalOnly;
1335 assert_eq!(
1336 base_manifest.invalidation_action(&lex_policy, "abc123", &expected_id),
1337 InvalidationAction::Evict,
1338 );
1339 }
1340 }
1341
1342 #[test]
1345 fn eviction_order_hnsw_first_model_last() {
1346 assert_eq!(EVICTION_ORDER[0], SemanticArtifactKind::HnswAccelerator);
1347 assert_eq!(EVICTION_ORDER[1], SemanticArtifactKind::QualityVectorIndex);
1348 assert_eq!(EVICTION_ORDER[2], SemanticArtifactKind::FastVectorIndex);
1349 assert_eq!(EVICTION_ORDER[3], SemanticArtifactKind::ModelFiles);
1350 }
1351
1352 #[test]
1353 fn artifact_required_for_capability() {
1354 use SemanticArtifactKind::*;
1355 use SemanticCapability::*;
1356
1357 let cases: &[(SemanticArtifactKind, SemanticCapability, bool)] = &[
1358 (HnswAccelerator, FullQuality, false),
1360 (HnswAccelerator, FastTierOnly, false),
1361 (HnswAccelerator, LexicalOnly, false),
1362 (ModelFiles, LexicalOnly, false),
1364 (QualityVectorIndex, LexicalOnly, false),
1365 (FastVectorIndex, LexicalOnly, false),
1366 (FastVectorIndex, FastTierOnly, true),
1368 (QualityVectorIndex, FastTierOnly, false),
1369 (ModelFiles, FastTierOnly, false),
1370 (ModelFiles, FullQuality, true),
1372 (QualityVectorIndex, FullQuality, true),
1373 (FastVectorIndex, FullQuality, true),
1374 ];
1375
1376 for (artifact, cap, expected) in cases {
1377 assert_eq!(
1378 artifact.required_for(cap),
1379 *expected,
1380 "{artifact:?} required_for {cap:?}"
1381 );
1382 }
1383 }
1384
1385 #[test]
1388 fn fixture_no_model_state() {
1389 let policy = SemanticPolicy::compiled_defaults();
1390 let cap = SemanticCapability::FastTierOnly;
1391 let report = SemanticCapabilityReport::from_policy(&policy, cap, 0);
1392
1393 assert_eq!(report.mode, SemanticMode::HybridPreferred);
1394 assert!(report.summary.contains("hash embedder only"));
1395 assert_eq!(report.current_usage_mb, 0);
1396
1397 let json = serde_json::to_string_pretty(&report).unwrap();
1399 let deser: SemanticCapabilityReport = serde_json::from_str(&json).unwrap();
1400 assert_eq!(deser.mode, report.mode);
1401 assert_eq!(deser.fast_tier_embedder, "hash");
1402 }
1403
1404 #[test]
1405 fn fixture_fast_tier_only_state() {
1406 let policy = SemanticPolicy::compiled_defaults();
1407 let cap = SemanticCapability::FastTierOnly;
1408 let report = SemanticCapabilityReport::from_policy(&policy, cap, 0);
1409
1410 assert_eq!(report.capability, SemanticCapability::FastTierOnly);
1411 assert_eq!(report.quality_tier_embedder, "minilm");
1412 assert_eq!(report.download_policy, ModelDownloadPolicy::OptIn);
1413 }
1414
1415 #[test]
1416 fn fixture_full_quality_state() {
1417 let policy = SemanticPolicy::compiled_defaults();
1418 let cap = SemanticCapability::FullQuality;
1419 let report = SemanticCapabilityReport::from_policy(&policy, cap, 95);
1420
1421 assert_eq!(report.capability, SemanticCapability::FullQuality);
1422 assert_eq!(report.current_usage_mb, 95);
1423 assert!(report.summary.contains("Full semantic"));
1424
1425 let json = serde_json::to_string_pretty(&report).unwrap();
1426 let deser: SemanticCapabilityReport = serde_json::from_str(&json).unwrap();
1427 assert_eq!(deser.current_usage_mb, 95);
1428 }
1429
1430 #[test]
1433 fn policy_json_round_trip() {
1434 let policy = SemanticPolicy::compiled_defaults();
1435 let json = serde_json::to_string(&policy).unwrap();
1436 let deser: SemanticPolicy = serde_json::from_str(&json).unwrap();
1437 assert_eq!(deser, policy);
1438 }
1439
1440 #[test]
1441 fn asset_manifest_json_round_trip() {
1442 let manifest = SemanticAssetManifest {
1443 embedder_id: "minilm-384".to_owned(),
1444 model_revision: "abc123".to_owned(),
1445 schema_version: 1,
1446 chunking_version: 1,
1447 doc_count: 5000,
1448 built_at_ms: 1700000000000,
1449 };
1450 let json = serde_json::to_string(&manifest).unwrap();
1451 let deser: SemanticAssetManifest = serde_json::from_str(&json).unwrap();
1452 assert_eq!(deser, manifest);
1453 }
1454
1455 #[test]
1458 fn effective_settings_all_defaults() {
1459 let cli = CliSemanticOverrides::default();
1460 let settings = EffectiveSettings::resolve(&cli);
1461
1462 assert!(settings.settings.len() >= 15);
1464
1465 for s in &settings.settings {
1467 assert_eq!(
1468 s.source,
1469 SettingSource::CompiledDefault,
1470 "setting '{}' should be CompiledDefault, got {:?}",
1471 s.name,
1472 s.source
1473 );
1474 }
1475
1476 let mode = settings.get("mode").unwrap();
1478 assert_eq!(mode.value, "hybrid_preferred");
1479
1480 let budget = settings.get("semantic_budget_mb").unwrap();
1481 assert_eq!(budget.value, "500");
1482
1483 assert!(settings.get("fast_tier_embedder").is_some());
1486 assert!(settings.get("reranker").is_some());
1487 assert_eq!(settings.get("reranker").unwrap().value, "ms-marco-minilm");
1488 }
1489
1490 #[test]
1491 fn effective_settings_cli_overrides_show_cli_source() {
1492 let cli = CliSemanticOverrides {
1493 mode: Some(SemanticMode::LexicalOnly),
1494 semantic_budget_mb: Some(100),
1495 ..Default::default()
1496 };
1497 let settings = EffectiveSettings::resolve(&cli);
1498
1499 let mode = settings.get("mode").unwrap();
1500 assert_eq!(mode.value, "lexical_only");
1501 assert_eq!(mode.source, SettingSource::Cli);
1502
1503 let budget = settings.get("semantic_budget_mb").unwrap();
1504 assert_eq!(budget.value, "100");
1505 assert_eq!(budget.source, SettingSource::Cli);
1506
1507 let fast_dim = settings.get("fast_dimension").unwrap();
1509 assert_eq!(fast_dim.source, SettingSource::CompiledDefault);
1510 }
1511
1512 #[test]
1513 fn effective_settings_lookup_by_name() {
1514 let cli = CliSemanticOverrides::default();
1515 let settings = EffectiveSettings::resolve(&cli);
1516
1517 assert!(settings.get("mode").is_some());
1518 assert!(settings.get("semantic_schema_version").is_some());
1519 assert!(settings.get("nonexistent").is_none());
1520 }
1521
1522 #[test]
1523 fn effective_settings_environment_overrides_show_environment_source() {
1524 let settings =
1525 EffectiveSettings::resolve_with_env_lookup(&CliSemanticOverrides::default(), |key| {
1526 match key {
1527 "CASS_SEMANTIC_MODE" => Some("lexical_only".to_string()),
1528 "CASS_SEMANTIC_BUDGET_MB" => Some("321".to_string()),
1529 _ => None,
1530 }
1531 });
1532
1533 let mode = settings.get("mode").unwrap();
1534 assert_eq!(mode.value, "lexical_only");
1535 assert_eq!(mode.source, SettingSource::Environment);
1536
1537 let budget = settings.get("semantic_budget_mb").unwrap();
1538 assert_eq!(budget.value, "321");
1539 assert_eq!(budget.source, SettingSource::Environment);
1540 }
1541
1542 #[test]
1543 fn effective_settings_download_policy_uses_snake_case_value() {
1544 let settings =
1545 EffectiveSettings::resolve_with_env_lookup(&CliSemanticOverrides::default(), |key| {
1546 match key {
1547 "CASS_SEMANTIC_DOWNLOAD_POLICY" => Some("budget_gated".to_string()),
1548 _ => None,
1549 }
1550 });
1551
1552 let policy = settings.get("download_policy").unwrap();
1553 assert_eq!(policy.value, "budget_gated");
1554 assert_eq!(policy.source, SettingSource::Environment);
1555 }
1556
1557 #[test]
1558 fn effective_settings_json_round_trip() {
1559 let cli = CliSemanticOverrides {
1560 mode: Some(SemanticMode::StrictSemantic),
1561 ..Default::default()
1562 };
1563 let settings = EffectiveSettings::resolve(&cli);
1564 let json = serde_json::to_string_pretty(&settings).unwrap();
1565 let deser: EffectiveSettings = serde_json::from_str(&json).unwrap();
1566 assert_eq!(deser.settings.len(), settings.settings.len());
1567 assert_eq!(deser.get("mode").unwrap().value, "strict_semantic");
1568 }
1569
1570 #[test]
1571 fn effective_settings_source_counts() {
1572 let cli = CliSemanticOverrides {
1573 mode: Some(SemanticMode::LexicalOnly),
1574 semantic_budget_mb: Some(200),
1575 ..Default::default()
1576 };
1577 let settings = EffectiveSettings::resolve(&cli);
1578 let counts = settings.source_counts();
1579
1580 assert_eq!(*counts.get(&SettingSource::Cli).unwrap_or(&0), 2);
1581 assert!(*counts.get(&SettingSource::CompiledDefault).unwrap_or(&0) > 10);
1583 }
1584
1585 #[test]
1586 fn effective_settings_version_fields_always_compiled() {
1587 let cli = CliSemanticOverrides::default();
1588 let settings = EffectiveSettings::resolve(&cli);
1589
1590 let schema = settings.get("semantic_schema_version").unwrap();
1591 assert_eq!(schema.source, SettingSource::CompiledDefault);
1592 assert!(schema.env_var.is_none()); let chunking = settings.get("chunking_strategy_version").unwrap();
1595 assert_eq!(chunking.source, SettingSource::CompiledDefault);
1596 assert!(chunking.env_var.is_none());
1597 }
1598}