Skip to main content

semantic_memory/
config.rs

1use crate::error::MemoryError;
2use crate::tokenizer::TokenCounter;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8/// Configuration for the memory system.
9#[derive(Clone, Serialize, Deserialize)]
10pub struct MemoryConfig {
11    /// Base directory for all storage files (SQLite + HNSW sidecar files).
12    /// Replaces the v0.1.0 `database_path` field.
13    pub base_dir: PathBuf,
14
15    /// Embedding provider configuration.
16    pub embedding: EmbeddingConfig,
17
18    /// Search tuning parameters.
19    pub search: SearchConfig,
20
21    /// Chunking parameters.
22    pub chunking: ChunkingConfig,
23
24    /// Connection pool configuration.
25    pub pool: PoolConfig,
26
27    /// Resource limits.
28    pub limits: MemoryLimits,
29
30    /// Custom token counter. None = use EstimateTokenCounter (chars / 4).
31    #[serde(skip)]
32    pub token_counter: Option<Arc<dyn TokenCounter>>,
33
34    /// HNSW index configuration.
35    #[cfg(feature = "hnsw")]
36    #[serde(skip)]
37    pub hnsw: crate::hnsw::HnswConfig,
38}
39
40impl std::fmt::Debug for MemoryConfig {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        let mut s = f.debug_struct("MemoryConfig");
43        s.field("base_dir", &self.base_dir)
44            .field("embedding", &self.embedding)
45            .field("search", &self.search)
46            .field("chunking", &self.chunking)
47            .field("pool", &self.pool)
48            .field("limits", &self.limits)
49            .field(
50                "token_counter",
51                &self.token_counter.as_ref().map(|_| "custom"),
52            );
53        #[cfg(feature = "hnsw")]
54        s.field("hnsw", &self.hnsw);
55        s.finish()
56    }
57}
58
59impl Default for MemoryConfig {
60    fn default() -> Self {
61        Self {
62            base_dir: PathBuf::from("memory"),
63            embedding: EmbeddingConfig::default(),
64            search: SearchConfig::default(),
65            chunking: ChunkingConfig::default(),
66            pool: PoolConfig::default(),
67            limits: MemoryLimits::default(),
68            token_counter: None,
69            #[cfg(feature = "hnsw")]
70            hnsw: crate::hnsw::HnswConfig::default(),
71        }
72    }
73}
74
75impl MemoryConfig {
76    /// Normalize and validate configuration into a concrete runtime shape.
77    ///
78    /// This is the single canonical config entry point used by store creation.
79    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
80        self.embedding.normalize_and_validate()?;
81        self.limits = self.limits.normalize_and_validate()?;
82        let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
83        self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
84        self.search
85            .normalize_and_validate(self.embedding.dimensions)?;
86        self.chunking.normalize_and_validate()?;
87        self.pool.normalize_and_validate()?;
88        #[cfg(feature = "hnsw")]
89        {
90            self.hnsw.dimensions = self.embedding.dimensions;
91        }
92        Ok(self)
93    }
94}
95
96/// Embedding provider configuration.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct EmbeddingConfig {
99    /// Ollama base URL. Only required when using OllamaEmbedder.
100    /// When using CandleEmbedder (default with `candle-embedder` feature),
101    /// this field is ignored. Defaults to `http://localhost:11434`.
102    pub ollama_url: String,
103
104    /// Embedding model name.
105    pub model: String,
106
107    /// Expected embedding dimensions.
108    pub dimensions: usize,
109
110    /// Maximum texts to embed in a single API call.
111    pub batch_size: usize,
112
113    /// Timeout for embedding requests in seconds.
114    pub timeout_secs: u64,
115}
116
117impl Default for EmbeddingConfig {
118    fn default() -> Self {
119        Self {
120            ollama_url: "http://localhost:11434".to_string(),
121            model: "nomic-embed-text".to_string(),
122            dimensions: 768,
123            batch_size: 32,
124            timeout_secs: 30,
125        }
126    }
127}
128
129impl EmbeddingConfig {
130    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
131        if self.dimensions == 0 {
132            return Err(MemoryError::InvalidConfig {
133                field: "embedding.dimensions",
134                reason: "dimensions must be at least 1".to_string(),
135            });
136        }
137        if self.batch_size == 0 {
138            self.batch_size = 1;
139        }
140        if self.timeout_secs == 0 {
141            self.timeout_secs = 1;
142        }
143        // Validate ollama_url only when it will be used. With the
144        // candle-embedder feature, the default embedder is CandleEmbedder
145        // which does not use Ollama, so a placeholder URL is fine.
146        #[cfg(not(feature = "candle-embedder"))]
147        {
148            let parsed =
149                reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
150                    field: "embedding.ollama_url",
151                    reason: "must be an absolute http:// or https:// URL".to_string(),
152                })?;
153            match parsed.scheme() {
154                "http" | "https" if parsed.host_str().is_some() => {}
155                _ => {
156                    return Err(MemoryError::InvalidConfig {
157                        field: "embedding.ollama_url",
158                        reason: "must be an absolute http:// or https:// URL".to_string(),
159                    })
160                }
161            }
162        }
163        // With candle-embedder, skip URL validation — the field is ignored
164        // by CandleEmbedder. If OllamaEmbedder is used explicitly via
165        // open_with_embedder, it does its own URL handling.
166        #[cfg(feature = "candle-embedder")]
167        {
168            let _ = &self.ollama_url; // suppress unused field warning
169        }
170        Ok(())
171    }
172}
173
174/// Search tuning parameters.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SearchConfig {
177    /// Weight for BM25 score in RRF fusion.
178    pub bm25_weight: f64,
179
180    /// Weight for vector similarity in RRF fusion.
181    pub vector_weight: f64,
182
183    /// Weight for late interaction (ColBERT MaxSim) in RRF fusion.
184    /// Defaults to 0.0 (disabled). Set to 1.0 to enable as 3rd RRF signal.
185    #[serde(default = "default_zero")]
186    pub late_interaction_weight: f64,
187
188    /// BM25 k1 parameter. Controls term frequency saturation.
189    /// Default: 1.2 (FTS5 standard). Lower (0.8-1.0) helps with technical content.
190    pub bm25_k1: f64,
191
192    /// BM25 b parameter. Controls document length normalization.
193    /// Default: 0.75 (FTS5 standard).
194    pub bm25_b: f64,
195
196    /// Optional per-namespace weight multipliers.
197    /// Empty = no weighting (all namespaces scored equally).
198    pub namespace_weights: std::collections::HashMap<String, f64>,
199
200    /// RRF constant (k). Controls rank importance decay.
201    pub rrf_k: f64,
202
203    /// Number of candidates from each search method before fusion.
204    pub candidate_pool_size: usize,
205
206    /// Default number of results to return.
207    pub default_top_k: usize,
208
209    /// Minimum cosine similarity threshold for vector candidates.
210    pub min_similarity: f64,
211
212    /// Optional recency boost. If enabled, results are boosted based on how
213    /// recently they were created/updated. The value is the half-life in days —
214    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
215    /// None = no recency weighting (current behavior, default).
216    pub recency_half_life_days: Option<f64>,
217
218    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
219    /// Only used when recency_half_life_days is Some.
220    /// Default: 0.5
221    pub recency_weight: f64,
222
223    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
224    /// from SQLite. Improves recall at the cost of one batched SQL query.
225    /// Only applies when HNSW feature is enabled.
226    /// Default: true
227    pub rerank_from_f32: bool,
228
229    /// Optional derived-vector candidate backend. Disabled by default because
230    /// raw f32 embeddings remain authoritative.
231    #[serde(default)]
232    pub derived_vector_backend: DerivedVectorBackendPolicy,
233
234    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
235    #[serde(default = "default_turbo_quant_bits")]
236    pub turbo_quant_bits: u8,
237
238    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
239    #[serde(default = "default_turbo_quant_projections")]
240    pub turbo_quant_projections: usize,
241
242    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
243    #[serde(default)]
244    pub turbo_quant_seed: u64,
245
246    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
247    #[serde(default = "default_true")]
248    pub turbo_quant_require_exact_rerank: bool,
249
250    /// Matryoshka candidate-stage embedding dimensions for 2-stage search.
251    /// When set to Some(dim) and the `matryoshka` feature is enabled, the query
252    /// embedding is truncated to `dim` dimensions for candidate retrieval, then
253    /// reranked with the full embedding. Defaults to Some(64).
254    /// Set to None to disable 2-stage matryoshka search.
255    #[serde(default = "default_candidate_dims")]
256    pub candidate_dims: Option<usize>,
257
258    /// When true, compress search result content using SimpleMem-style semantic
259    /// compression (first sentence + key terms, capped at 150 chars).
260    /// Defaults to false.
261    #[serde(default)]
262    pub compress_results: bool,
263}
264
265/// Candidate backend policy for rebuildable derived vector artifacts.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum DerivedVectorBackendPolicy {
269    /// Use authoritative raw f32 embeddings for vector candidate generation.
270    #[default]
271    Disabled,
272    /// Use TurboQuant only to generate candidates, then exact rerank by default.
273    TurboQuantCandidateOnly,
274    /// Use a generation-level proveKV/poly-kv shared pool only to generate candidates,
275    /// then exact-rerank against authoritative f32 embeddings.
276    ///
277    /// This is deliberately not a replacement for SQLite f32 storage or for prompt/KV
278    /// prefix reuse. It is a rebuildable derived artifact over an embedding snapshot.
279    ProveKvPoolCandidateOnly,
280}
281
282const fn default_turbo_quant_bits() -> u8 {
283    8
284}
285
286const fn default_turbo_quant_projections() -> usize {
287    64
288}
289
290const fn default_true() -> bool {
291    true
292}
293
294const fn default_zero() -> f64 {
295    0.0
296}
297
298const fn default_candidate_dims() -> Option<usize> {
299    Some(64)
300}
301
302impl Default for SearchConfig {
303    fn default() -> Self {
304        Self {
305            bm25_weight: 1.0,
306            vector_weight: 1.0,
307            late_interaction_weight: 0.15,
308            bm25_k1: 1.2,
309            bm25_b: 0.75,
310            namespace_weights: std::collections::HashMap::new(),
311            rrf_k: 60.0,
312            candidate_pool_size: 50,
313            default_top_k: 5,
314            min_similarity: 0.3,
315            recency_half_life_days: None,
316            recency_weight: 0.5,
317            rerank_from_f32: true,
318            derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
319            turbo_quant_bits: default_turbo_quant_bits(),
320            turbo_quant_projections: default_turbo_quant_projections(),
321            turbo_quant_seed: 0,
322            turbo_quant_require_exact_rerank: true,
323            candidate_dims: default_candidate_dims(),
324            compress_results: false,
325        }
326    }
327}
328
329impl SearchConfig {
330    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
331        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
332    }
333
334    pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
335        self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
336    }
337
338    pub(crate) fn uses_derived_vector_backend(&self) -> bool {
339        self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
340    }
341
342    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
343        #[cfg(not(feature = "turbo-quant-codec"))]
344        let _ = embedding_dimensions;
345        if self.candidate_pool_size == 0 {
346            self.candidate_pool_size = 1;
347        }
348        if self.default_top_k == 0 {
349            self.default_top_k = 1;
350        }
351        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
352        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
353            return Err(MemoryError::InvalidConfig {
354                field: "search.rrf_k",
355                reason: "rrf_k must be finite and > 0".to_string(),
356            });
357        }
358        if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
359            return Err(MemoryError::InvalidConfig {
360                field: "search.bm25_weight",
361                reason: "bm25_weight must be finite and >= 0".to_string(),
362            });
363        }
364        if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
365            return Err(MemoryError::InvalidConfig {
366                field: "search.vector_weight",
367                reason: "vector_weight must be finite and >= 0".to_string(),
368            });
369        }
370        if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
371            return Err(MemoryError::InvalidConfig {
372                field: "search.recency_weight",
373                reason: "recency_weight must be finite and >= 0".to_string(),
374            });
375        }
376        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
377            return Err(MemoryError::InvalidConfig {
378                field: "search.min_similarity",
379                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
380            });
381        }
382        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
383            return Err(MemoryError::InvalidConfig {
384                field: "search.recency_half_life_days",
385                reason: "recency_half_life_days must be finite".to_string(),
386            });
387        }
388        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
389            return Err(MemoryError::InvalidConfig {
390                field: "search.recency_half_life_days",
391                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
392            });
393        }
394        if self.uses_turbo_quant_backend() {
395            #[cfg(not(feature = "turbo-quant-codec"))]
396            {
397                return Err(MemoryError::InvalidConfig {
398                    field: "search.derived_vector_backend",
399                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
400                        .to_string(),
401                });
402            }
403            #[cfg(feature = "turbo-quant-codec")]
404            {
405                if embedding_dimensions % 2 != 0 {
406                    return Err(MemoryError::InvalidConfig {
407                        field: "embedding.dimensions",
408                        reason: "TurboQuant requires even embedding dimensions".to_string(),
409                    });
410                }
411                if self.turbo_quant_projections == 0 {
412                    return Err(MemoryError::InvalidConfig {
413                        field: "search.turbo_quant_projections",
414                        reason: "TurboQuant projections must be at least 1".to_string(),
415                    });
416                }
417                if !(2..=16).contains(&self.turbo_quant_bits) {
418                    return Err(MemoryError::InvalidConfig {
419                        field: "search.turbo_quant_bits",
420                        reason: "TurboQuant bits must be within 2..=16".to_string(),
421                    });
422                }
423            }
424        }
425        if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
426            return Err(MemoryError::InvalidConfig {
427                field: "search.turbo_quant_require_exact_rerank",
428                reason: "derived vector candidate backends require exact f32 rerank".to_string(),
429            });
430        }
431        Ok(())
432    }
433}
434
435/// Chunking strategy to use when splitting text.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
437#[serde(rename_all = "snake_case")]
438pub enum ChunkingStrategy {
439    /// Plain recursive splitting (current/default behavior).
440    #[default]
441    Plain,
442    /// Sentence-boundary-aware chunking with configurable overlap.
443    Sentence,
444    /// Code-aware chunking that avoids splitting inside function bodies.
445    /// Detects Rust, Python, and TypeScript blocks.
446    Code,
447    /// Markdown-header-based chunking that splits on header boundaries.
448    Markdown,
449}
450
451/// Text chunking parameters.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct ChunkingConfig {
454    /// Target chunk size in characters.
455    pub target_size: usize,
456
457    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
458    pub min_size: usize,
459
460    /// Maximum chunk size. Chunks larger than this are force-split.
461    pub max_size: usize,
462
463    /// Overlap between adjacent chunks in characters.
464    pub overlap: usize,
465
466    /// Chunking strategy to use. Defaults to [`ChunkingStrategy::Plain`]
467    /// for backward compatibility.
468    #[serde(default)]
469    pub strategy: ChunkingStrategy,
470}
471
472impl Default for ChunkingConfig {
473    fn default() -> Self {
474        Self {
475            target_size: 1000,
476            min_size: 100,
477            max_size: 2000,
478            overlap: 200,
479            strategy: ChunkingStrategy::default(),
480        }
481    }
482}
483
484impl ChunkingConfig {
485    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
486        if self.min_size == 0 {
487            self.min_size = 1;
488        }
489        if self.max_size == 0 {
490            return Err(MemoryError::InvalidConfig {
491                field: "chunking.max_size",
492                reason: "max_size must be at least 1".to_string(),
493            });
494        }
495        if self.max_size < self.min_size {
496            return Err(MemoryError::InvalidConfig {
497                field: "chunking.max_size",
498                reason: "max_size must be >= min_size".to_string(),
499            });
500        }
501        if self.target_size < self.min_size {
502            self.target_size = self.min_size;
503        }
504        if self.target_size > self.max_size {
505            self.target_size = self.max_size;
506        }
507        if self.overlap >= self.min_size {
508            self.overlap = self.min_size.saturating_sub(1);
509        }
510        Ok(())
511    }
512}
513
514/// Connection pool configuration for SQLite.
515///
516/// Controls busy timeout and WAL checkpoint behavior. These defaults
517/// are tuned for a single-process server on local SSD storage.
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct PoolConfig {
520    /// SQLite busy timeout in milliseconds.
521    /// Default: 5000 (5 seconds).
522    pub busy_timeout_ms: u32,
523
524    /// WAL auto-checkpoint threshold in pages.
525    /// Default: 1000 (~4 MB with 4KB pages).
526    pub wal_autocheckpoint: u32,
527
528    /// Enable WAL mode. Should almost always be true.
529    /// Default: true.
530    pub enable_wal: bool,
531
532    /// Number of reader connections kept in the pool.
533    /// Writes still flow through a single writer connection because SQLite
534    /// allows only one concurrent writer, but readers can proceed concurrently
535    /// under WAL semantics.
536    pub max_read_connections: usize,
537
538    /// Timeout in seconds for acquiring a reader connection from the pool.
539    /// Default: 30 seconds.
540    pub reader_timeout_secs: u64,
541}
542
543impl Default for PoolConfig {
544    fn default() -> Self {
545        Self {
546            busy_timeout_ms: 5000,
547            wal_autocheckpoint: 1000,
548            enable_wal: true,
549            max_read_connections: 4,
550            reader_timeout_secs: 30,
551        }
552    }
553}
554
555impl PoolConfig {
556    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
557        if self.busy_timeout_ms == 0 {
558            self.busy_timeout_ms = 1;
559        }
560        if self.wal_autocheckpoint == 0 {
561            self.wal_autocheckpoint = 1;
562        }
563        if self.max_read_connections == 0 {
564            return Err(MemoryError::InvalidConfig {
565                field: "pool.max_read_connections",
566                reason: "set pool.max_read_connections to at least 1".to_string(),
567            });
568        }
569        if self.reader_timeout_secs == 0 {
570            self.reader_timeout_secs = 1;
571        }
572        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
573        Ok(())
574    }
575}
576
577/// Resource limits for the memory system.
578///
579/// Prevents runaway resource usage. All limits have defaults tuned for
580/// a laptop-class server (8GB RAM, SSD storage).
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct MemoryLimits {
583    /// Maximum number of facts per namespace.
584    /// Default: 100_000.
585    pub max_facts_per_namespace: usize,
586
587    /// Maximum number of chunks per document.
588    /// Default: 1_000.
589    pub max_chunks_per_document: usize,
590
591    /// Maximum content size in bytes for a single fact or message.
592    /// Default: 1 MB (1_048_576 bytes).
593    pub max_content_bytes: usize,
594
595    /// Maximum number of concurrent embedding requests.
596    /// Hard-capped at 32 regardless of config.
597    /// Default: 8.
598    pub max_embedding_concurrency: usize,
599
600    /// Maximum total database size in bytes. 0 = unlimited.
601    /// Default: 0 (unlimited).
602    pub max_db_size_bytes: u64,
603
604    /// Embedding request timeout.
605    /// Default: 30 seconds.
606    #[serde(with = "duration_secs")]
607    pub embedding_timeout: Duration,
608}
609
610impl Default for MemoryLimits {
611    fn default() -> Self {
612        Self {
613            max_facts_per_namespace: 100_000,
614            max_chunks_per_document: 1_000,
615            max_content_bytes: 1_048_576,
616            max_embedding_concurrency: 8,
617            max_db_size_bytes: 0,
618            embedding_timeout: Duration::from_secs(30),
619        }
620    }
621}
622
623impl MemoryLimits {
624    /// Normalize and validate limits to hard caps.
625    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
626        if self.max_facts_per_namespace == 0 {
627            return Err(MemoryError::InvalidConfig {
628                field: "limits.max_facts_per_namespace",
629                reason: "must be at least 1".to_string(),
630            });
631        }
632        if self.max_chunks_per_document == 0 {
633            return Err(MemoryError::InvalidConfig {
634                field: "limits.max_chunks_per_document",
635                reason: "must be at least 1".to_string(),
636            });
637        }
638        if self.max_content_bytes == 0 {
639            return Err(MemoryError::InvalidConfig {
640                field: "limits.max_content_bytes",
641                reason: "must be at least 1".to_string(),
642            });
643        }
644        // Hard cap: concurrency at 32
645        if self.max_embedding_concurrency > 32 {
646            self.max_embedding_concurrency = 32;
647        }
648        if self.max_embedding_concurrency == 0 {
649            self.max_embedding_concurrency = 1;
650        }
651        if self.embedding_timeout.is_zero() {
652            self.embedding_timeout = Duration::from_secs(1);
653        }
654        Ok(self)
655    }
656
657    /// Backward-compatible alias for callers that only need clamped limits.
658    ///
659    /// Falls back to defaults if the caller-provided limits are invalid.
660    /// Default limits are infallible so the fallback path cannot fail.
661    pub fn validated(self) -> Self {
662        self.normalize_and_validate().unwrap_or_else(|err| {
663            tracing::warn!(
664                error = %err,
665                "invalid MemoryLimits supplied to validated(); using defaults"
666            );
667            // Default limits are always valid — this path is infallible.
668            let defaults = Self::default();
669            Self {
670                max_facts_per_namespace: defaults.max_facts_per_namespace,
671                max_chunks_per_document: defaults.max_chunks_per_document,
672                max_content_bytes: defaults.max_content_bytes,
673                max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
674                max_db_size_bytes: defaults.max_db_size_bytes,
675                embedding_timeout: if defaults.embedding_timeout.is_zero() {
676                    std::time::Duration::from_secs(1)
677                } else {
678                    defaults.embedding_timeout
679                },
680            }
681        })
682    }
683}
684
685mod duration_secs {
686    use serde::{Deserialize, Deserializer, Serializer};
687    use std::time::Duration;
688
689    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
690        s.serialize_u64(d.as_secs())
691    }
692
693    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
694        let secs = u64::deserialize(d)?;
695        Ok(Duration::from_secs(secs))
696    }
697}