Skip to main content

khive_pack_memory/
scoring.rs

1//! Composite memory scoring — ported from the archived internal service (v1 archive).
2//!
3//! Provides a fully-tunable scoring pipeline:
4//!   1. `ScoringConfig`  — all knobs, all `pub`, all serde-friendly for agent sweeps.
5//!   2. `calculate_score` — multiplicative formula: `w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`.
6//!   3. `ScoreAdjustment` — declarative conditional rules applied after the base formula.
7//!   4. `normalize_rrf_scores` / `normalize_rank_fusion_scores` — RRF and raw-cosine normalization.
8//!   5. `normalize_min_score` — dual-scale input (0.0–1.0 fraction or 0–100 integer).
9//!   6. `is_meaningful_query` — noise gate before embedding compute.
10//!   7. `contains_cjk` — CJK routing decision.
11//!   8. `needs_multilingual` — broad multilingual routing gate (non-ASCII alphabetic script).
12// FILE SIZE JUSTIFICATION: scoring.rs bundles ScoringConfig, all normalization helpers,
13// CJK routing, and the full test suite for the scoring pipeline. The tests require access
14// to module-private helpers; splitting would require pub(crate) promotion of private fns.
15
16use std::collections::{HashMap, HashSet};
17
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21// ── Adjustment conditions ─────────────────────────────────────────────────────
22
23/// A condition that determines whether a score adjustment applies to a candidate.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum AdjustmentCondition {
27    /// Match by memory type ("episodic" or "semantic").
28    MemoryType { kind: String },
29    /// Match by age in days. Both bounds are optional (omit = no bound).
30    AgeRange {
31        #[serde(default)]
32        min_days: Option<f32>,
33        #[serde(default)]
34        max_days: Option<f32>,
35    },
36    /// Match by salience score. Both bounds are optional.
37    SalienceRange {
38        #[serde(default)]
39        min: Option<f32>,
40        #[serde(default)]
41        max: Option<f32>,
42    },
43    /// Match when query entity names appear in memory content.
44    EntityMatch,
45    /// Match when query entity names do NOT appear in memory content.
46    EntityMiss,
47    /// All sub-conditions must be true (conjunction).
48    All {
49        conditions: Vec<AdjustmentCondition>,
50    },
51}
52
53/// The operation to apply when a condition matches.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum AdjustmentOp {
57    /// Add a fixed value to the score.
58    Add { value: f32 },
59    /// Subtract a fixed value from the score.
60    Subtract { value: f32 },
61    /// Multiply the score by a factor.
62    Multiply { factor: f32 },
63}
64
65/// A conditional score adjustment: if `condition` matches, apply `operation`.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ScoreAdjustment {
68    pub condition: AdjustmentCondition,
69    pub operation: AdjustmentOp,
70}
71
72/// Context passed to condition evaluation — properties of the candidate + query.
73pub struct CandidateContext<'a> {
74    pub memory_type: &'a str,
75    pub age_days: f32,
76    pub salience: f32,
77    pub content: &'a str,
78    pub entity_names: &'a [String],
79}
80
81/// Returns `true` when `needle` occurs in `haystack` at a **word (character)
82/// boundary** — the character immediately before the match (if any) and the
83/// character immediately after the match (if any) are both non-alphanumeric
84/// (or the match sits at the start/end of `haystack`).
85///
86/// Plain substring `contains` matches inside unrelated words: a candidate
87/// `"beta"` matches `"alphabet"` and `"betamax"`; `"car"` matches
88/// `"scarcity"`. Anchoring to boundaries closes that class of false positive
89/// while still matching multi-word phrases on their own boundaries — a
90/// caller-supplied explicit name like `"knowledge graph"` still matches
91/// `"...the knowledge graph shows..."` because the space characters on
92/// either side of the phrase are themselves non-alphanumeric, satisfying the
93/// boundary check without any special-casing for internal spaces.
94///
95/// Both `haystack` and `needle` are expected pre-lowercased by the caller
96/// (matching `EntityMatch`'s existing lowercase-both-sides contract).
97fn contains_at_word_boundary(haystack: &str, needle: &str) -> bool {
98    if needle.is_empty() {
99        return false;
100    }
101    let haystack_chars: Vec<char> = haystack.chars().collect();
102    let needle_chars: Vec<char> = needle.chars().collect();
103    let n = needle_chars.len();
104    if n == 0 || haystack_chars.len() < n {
105        return false;
106    }
107    for start in 0..=(haystack_chars.len() - n) {
108        if haystack_chars[start..start + n] != needle_chars[..] {
109            continue;
110        }
111        let before_ok = start == 0 || !haystack_chars[start - 1].is_alphanumeric();
112        let after_idx = start + n;
113        let after_ok =
114            after_idx >= haystack_chars.len() || !haystack_chars[after_idx].is_alphanumeric();
115        if before_ok && after_ok {
116            return true;
117        }
118    }
119    false
120}
121
122impl AdjustmentCondition {
123    /// Return `true` when this condition applies to the given candidate context.
124    pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
125        match self {
126            Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
127            Self::AgeRange { min_days, max_days } => {
128                if let Some(min) = min_days {
129                    if ctx.age_days < *min {
130                        return false;
131                    }
132                }
133                if let Some(max) = max_days {
134                    if ctx.age_days > *max {
135                        return false;
136                    }
137                }
138                true
139            }
140            Self::SalienceRange { min, max } => {
141                if let Some(lo) = min {
142                    if ctx.salience < *lo {
143                        return false;
144                    }
145                }
146                if let Some(hi) = max {
147                    if ctx.salience > *hi {
148                        return false;
149                    }
150                }
151                true
152            }
153            Self::EntityMatch => {
154                if ctx.entity_names.is_empty() {
155                    return false;
156                }
157                let lower = ctx.content.to_lowercase();
158                ctx.entity_names
159                    .iter()
160                    .any(|e| contains_at_word_boundary(&lower, e))
161            }
162            Self::EntityMiss => {
163                if ctx.entity_names.is_empty() {
164                    return false;
165                }
166                let lower = ctx.content.to_lowercase();
167                !ctx.entity_names
168                    .iter()
169                    .any(|e| contains_at_word_boundary(&lower, e))
170            }
171            Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
172        }
173    }
174}
175
176impl AdjustmentOp {
177    /// Apply this operation to `score` and return the adjusted value.
178    pub fn apply(&self, score: f32) -> f32 {
179        match self {
180            Self::Add { value } => score + value,
181            Self::Subtract { value } => score - value,
182            Self::Multiply { factor } => score * factor,
183        }
184    }
185}
186
187impl ScoreAdjustment {
188    /// Apply this adjustment to `score` if the condition matches, otherwise return `score` unchanged.
189    pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
190        if self.condition.matches(ctx) {
191            self.operation.apply(score)
192        } else {
193            score
194        }
195    }
196}
197
198/// Default score adjustments: episodic recency bonus, semantic age penalty, entity boost.
199pub fn default_adjustments() -> Vec<ScoreAdjustment> {
200    vec![
201        // Episodic recency bonus: recent episodic memories get an additive boost.
202        ScoreAdjustment {
203            condition: AdjustmentCondition::All {
204                conditions: vec![
205                    AdjustmentCondition::MemoryType {
206                        kind: "episodic".into(),
207                    },
208                    AdjustmentCondition::AgeRange {
209                        min_days: None,
210                        max_days: Some(7.0),
211                    },
212                ],
213            },
214            operation: AdjustmentOp::Add { value: 0.05 },
215        },
216        // Semantic age penalty: old high-salience semantic memories get penalized
217        // to prevent reference docs from crowding out episodic content.
218        ScoreAdjustment {
219            condition: AdjustmentCondition::All {
220                conditions: vec![
221                    AdjustmentCondition::MemoryType {
222                        kind: "semantic".into(),
223                    },
224                    AdjustmentCondition::AgeRange {
225                        min_days: Some(30.0),
226                        max_days: None,
227                    },
228                    AdjustmentCondition::SalienceRange {
229                        min: Some(0.85),
230                        max: None,
231                    },
232                ],
233            },
234            operation: AdjustmentOp::Subtract { value: 0.05 },
235        },
236        // Entity match boost: memories mentioning queried entities get boosted.
237        ScoreAdjustment {
238            condition: AdjustmentCondition::EntityMatch,
239            operation: AdjustmentOp::Multiply { factor: 1.3 },
240        },
241    ]
242}
243
244// ── Auto entity-name extraction ─────────────────────────────────────────────────
245
246/// A small closed set of common English function words, used to keep
247/// auto-extracted entity candidates low-noise. Deliberately conservative
248/// (articles, prepositions, pronouns, conjunctions, common auxiliary verbs
249/// only) — content words are intentionally left out of this list, since
250/// distinguishing a proper noun from a common noun without an NER model
251/// is exactly what the capitalization / length rules below are for.
252const ENTITY_STOPWORDS: &[&str] = &[
253    "a", "an", "the", "and", "or", "but", "of", "in", "on", "at", "to", "for", "with", "by",
254    "from", "is", "are", "was", "were", "be", "been", "being", "this", "that", "these", "those",
255    "it", "its", "he", "she", "his", "her", "him", "they", "them", "their", "we", "us", "our",
256    "you", "your", "i", "my", "me", "as", "if", "so", "not", "no", "yes", "do", "does", "did",
257    "has", "have", "had", "will", "would", "can", "could", "should", "may", "might", "about",
258    "into", "than", "then", "there", "here", "what", "which", "who", "whom", "when", "where",
259    "why", "how",
260];
261
262/// Maximum number of auto-extracted entity-name candidates per query.
263/// `EntityMatch` (see `default_adjustments` above) applies its multiplier to
264/// *any* candidate memory that contains *any* one of the extracted names —
265/// this constant does not limit how many memories can receive the boost
266/// (one generic name can still match many memories). It only bounds the
267/// extracted-name list length and, transitively, the number of
268/// `contains_at_word_boundary` scans `EntityMatch` performs per note.
269pub const MAX_AUTO_ENTITY_NAMES: usize = 8;
270
271/// Strip leading/trailing non-alphanumeric characters from a token (quotes,
272/// commas, trailing periods, etc.), keeping internal punctuation like
273/// apostrophes in a name intact.
274fn strip_token_punctuation(token: &str) -> &str {
275    token.trim_matches(|c: char| !c.is_alphanumeric())
276}
277
278/// Auto-extract entity-name candidates from a recall query when the caller
279/// does not supply `entity_names` explicitly.
280///
281/// Context (khive #dead-parameter defect): `entity_names` was a
282/// caller-supplied request field that fed `EntityMatch` — but no caller ever
283/// populated it, so the ×1.3 boost in `default_adjustments` never fired in
284/// practice. This function derives candidates server-side from the query
285/// text so the boost has something to match against.
286///
287/// **Capitalized tokens only.** A query token qualifies iff, after stripping
288/// surrounding punctuation, it is not a stopword and starts with an
289/// uppercase letter. Capitalization is the only signal used here because it
290/// is the sole low-noise proper-noun indicator available without an NER
291/// model or a lookup against known entity records — `EntityMatch::matches`
292/// (above) does a free-text boundary-anchored match against raw memory
293/// content, not something anchored to actual KG entity references, so
294/// admitting ordinary lowercase content words as candidates degenerates the
295/// boost into a second, redundant lexical-overlap signal on top of
296/// retrieval-stage relevance (confirmed by review: realistic score inputs
297/// clamp at the `[0, 1]` ceiling and flatten top-rank ordering when generic
298/// query words are treated as entity candidates).
299///
300/// **Queries with no capitalization extract nothing.** Many recall callers —
301/// agents in particular — pass fully lowercase queries, and this function
302/// deliberately returns an empty list for them rather than guessing at
303/// content words. Covering that case precisely requires anchoring candidates
304/// against known entity records (e.g. resolving query tokens against the KG)
305/// rather than lexical heuristics over the query string; that is out of
306/// scope here.
307///
308/// Returned names are lowercased (matching `EntityMatch`'s own lowercasing
309/// of both sides before the boundary-anchored match), deduplicated
310/// preserving first-seen order, and capped at `MAX_AUTO_ENTITY_NAMES`.
311pub fn extract_entity_candidates(query: &str) -> Vec<String> {
312    let mut seen: HashSet<String> = HashSet::new();
313    let mut out: Vec<String> = Vec::new();
314
315    for raw in query.split_whitespace() {
316        let stripped = strip_token_punctuation(raw);
317        if stripped.is_empty() {
318            continue;
319        }
320        if !stripped.chars().next().is_some_and(char::is_uppercase) {
321            continue;
322        }
323        let lower = stripped.to_lowercase();
324        if ENTITY_STOPWORDS.contains(&lower.as_str()) {
325            continue;
326        }
327        if seen.insert(lower.clone()) {
328            out.push(lower);
329            if out.len() >= MAX_AUTO_ENTITY_NAMES {
330                break;
331            }
332        }
333    }
334    out
335}
336
337// ── Scoring weights ───────────────────────────────────────────────────────────
338
339/// Weights for the combined memory score: `score = w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(default)]
342pub struct ScoringWeights {
343    /// Multiplicative boost from salience in `(1 + w_imp × salience)`. Default: 0.2.
344    pub salience: f32,
345    /// Multiplicative boost from recency in `(1 + w_temp × recency)`. Default: 0.1.
346    pub temporal: f32,
347    /// Base multiplier applied to relevance. Default: 0.7.
348    pub relevance: f32,
349}
350
351impl Default for ScoringWeights {
352    fn default() -> Self {
353        Self {
354            salience: 0.2,
355            temporal: 0.1,
356            relevance: 0.7,
357        }
358    }
359}
360
361// ── ScoringConfig ─────────────────────────────────────────────────────────────
362
363/// Complete, tunable scoring configuration for the memory recall pipeline.
364/// DoS caps: max_recall_candidates≤500, default_token_budget≤16000, default_recall_limit≤200.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(default)]
367pub struct ScoringConfig {
368    // ── Composite weights ──────────────────────────────────────────────────
369    pub weights: ScoringWeights,
370
371    // ── Relevance thresholds ───────────────────────────────────────────────
372    /// Minimum raw cosine similarity to include a vector hit. Hits below this
373    /// are excluded before RRF fusion (#2272). Default: 0.10.
374    pub min_raw_relevance: f32,
375    /// Minimum RRF score after fusion before normalization. Default: 0.0.
376    pub min_rrf_relevance: f32,
377    /// Relevance floor for the min-max normalization band. Default: 0.15.
378    pub baseline_relevance: f32,
379
380    // ── Temporal decay ─────────────────────────────────────────────────────
381    /// Upper cap on per-entry decay_factor before temporal recency calculation.
382    /// Default: 0.05.
383    pub decay_cap: f32,
384
385    // ── DoS caps (enforced server-side) ───────────────────────────────────
386    /// Maximum search candidates to retrieve. Server-side cap: 500. Default: 200.
387    pub max_recall_candidates: usize,
388    /// Default result limit when caller doesn't specify. Server-side cap: 200.
389    /// Default: 10.
390    pub default_recall_limit: usize,
391    /// Default token budget (tokens). Server-side cap: 16000. Default: 4000.
392    pub default_token_budget: usize,
393    /// Approximate characters per token (for token budget). Default: 4.
394    pub chars_per_token: usize,
395
396    // ── MMR diversity penalty ──────────────────────────────────────────────
397    /// Score penalty applied to results whose first `mmr_prefix_len` characters
398    /// match an earlier result. Default: 0.1.
399    pub mmr_penalty: f32,
400    /// Character prefix length used for MMR duplicate detection. Default: 100.
401    pub mmr_prefix_len: usize,
402
403    // ── Feature toggles ────────────────────────────────────────────────────
404    /// When true, suppress memories whose `properties.supersedes` value matches
405    /// the ID of another memory in the result set. Default: true.
406    pub enable_supersedes_suppression: bool,
407    /// When true and a multilingual embedding model is registered, route
408    /// non-ASCII-script queries (CJK, Cyrillic, Arabic, accented-Latin, …) to
409    /// it as the primary dense model. Default: true.
410    pub enable_multilingual_routing: bool,
411    /// Name of the multilingual embedding model to prefer for dense routing.
412    /// When None, the handler checks registered model names for substrings
413    /// "multilingual" or "paraphrase". Default: None.
414    pub multilingual_model: Option<String>,
415
416    // ── Conditional adjustments ────────────────────────────────────────────
417    /// Score adjustments applied after the base formula. Default: episodic bonus,
418    /// semantic age penalty, entity boost.
419    pub adjustments: Vec<ScoreAdjustment>,
420}
421
422impl Default for ScoringConfig {
423    fn default() -> Self {
424        Self {
425            weights: ScoringWeights::default(),
426
427            min_raw_relevance: 0.10,
428            min_rrf_relevance: 0.0,
429            baseline_relevance: 0.15,
430
431            decay_cap: 0.05,
432
433            max_recall_candidates: 200,
434            default_recall_limit: 10,
435            default_token_budget: 4000,
436            chars_per_token: 4,
437
438            mmr_penalty: 0.1,
439            mmr_prefix_len: 100,
440
441            enable_supersedes_suppression: true,
442            enable_multilingual_routing: true,
443            multilingual_model: None,
444
445            adjustments: default_adjustments(),
446        }
447    }
448}
449
450// ── Server-side DoS caps ──────────────────────────────────────────────────────
451
452/// Maximum candidates a caller may request (server-side cap).
453pub const MAX_RECALL_CANDIDATES: usize = 500;
454/// Maximum token budget a caller may request (server-side cap).
455pub const MAX_TOKEN_BUDGET: usize = 16_000;
456/// Maximum result limit a caller may request (server-side cap).
457pub const MAX_RECALL_LIMIT: usize = 200;
458
459impl ScoringConfig {
460    /// Clamp all DoS-cap fields to their server-side maximums.
461    ///
462    /// Called at the start of `handle_recall` so callers cannot trigger
463    /// unbounded candidate retrieval or token budget consumption.
464    pub fn apply_dos_caps(&mut self) {
465        self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
466        self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
467        self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
468    }
469}
470
471// ── Utility functions ─────────────────────────────────────────────────────────
472
473/// Returns `true` if `c` is a CJK character (Unified, Extension A/B, Hiragana,
474/// Katakana, Hangul).
475#[inline]
476pub fn is_cjk_char(c: char) -> bool {
477    matches!(c,
478        '\u{4E00}'..='\u{9FFF}'       // CJK Unified Ideographs
479        | '\u{3400}'..='\u{4DBF}'     // CJK Extension A
480        | '\u{F900}'..='\u{FAFF}'     // CJK Compatibility Ideographs
481        | '\u{3040}'..='\u{309F}'     // Hiragana
482        | '\u{30A0}'..='\u{30FF}'     // Katakana
483        | '\u{20000}'..='\u{2A6DF}'   // CJK Extension B
484        | '\u{AC00}'..='\u{D7AF}'     // Hangul Syllables
485    )
486}
487
488/// Returns `true` when >15% of the query's characters are CJK.
489pub fn contains_cjk(text: &str) -> bool {
490    let chars: Vec<char> = text.chars().collect();
491    if chars.is_empty() {
492        return false;
493    }
494    let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
495    (cjk as f32) / (chars.len() as f32) > 0.15
496}
497
498/// Returns `true` when `text` should be routed to the multilingual embedding model
499/// for dense retrieval.
500///
501/// Triggers when >15% of **alphabetic** characters are non-ASCII-alphabetic.
502/// Denominator is alphabetic-character count (not total characters), so punctuation,
503/// digits, and whitespace do not dilute the signal. This means `Müller` and
504/// `Müller?` and `Müller!!!` all route identically.
505///
506/// Covers: CJK, Cyrillic, Arabic, Devanagari, Hebrew, Thai, accented-Latin
507/// (é, ü, ñ, …) and any other non-ASCII script Unicode recognises as alphabetic,
508/// without introducing new crate dependencies.
509///
510/// **Known limitation**: ASCII-only non-English Latin (`bonjour le monde`,
511/// `como estas`, `ich suche einen buchhalter`) is NOT detected and routes to the
512/// primary model. Real language detection for that case requires a dedicated crate
513/// and is tracked as a follow-up to issue #101.
514pub fn needs_multilingual(text: &str) -> bool {
515    let alpha_chars: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
516    if alpha_chars.is_empty() {
517        return false;
518    }
519    let non_ascii_alpha = alpha_chars
520        .iter()
521        .filter(|&&c| !c.is_ascii_alphabetic())
522        .count();
523    (non_ascii_alpha as f32) / (alpha_chars.len() as f32) > 0.15
524}
525
526/// Normalize `min_score`: 0–1 passes through, 1–100 divides by 100, others return Err.
527pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
528    if !score.is_finite() {
529        return Err(crate::config::MinScoreError::NotFinite);
530    }
531    if (0.0..=1.0).contains(&score) {
532        return Ok(score as f32);
533    }
534    if (1.0..=100.0).contains(&score) {
535        return Ok((score / 100.0) as f32);
536    }
537    Err(crate::config::MinScoreError::OutOfRange(score))
538}
539
540/// Returns `true` if the query has enough semantic content for meaningful recall.
541pub fn is_meaningful_query(query: &str) -> bool {
542    let trimmed = query.trim();
543    if trimmed.is_empty() {
544        return false;
545    }
546
547    let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
548
549    let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
550    if meaningful_chars == 0 {
551        return false;
552    }
553
554    let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
555    if meaningful_chars < 2 && cjk_chars == 0 {
556        return false;
557    }
558
559    // Detect repeated-character patterns: "aaaa", "aaa bbb ccc" (gibberish).
560    let words: Vec<&str> = trimmed
561        .split_whitespace()
562        .filter(|w| w.chars().any(is_alpha_or_cjk))
563        .collect();
564    if !words.is_empty() {
565        let all_repeated = words.iter().all(|w| {
566            let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
567            if chars.len() <= 2 {
568                return false;
569            }
570            let unique: std::collections::HashSet<char> = chars
571                .iter()
572                .map(|c| c.to_lowercase().next().unwrap_or(*c))
573                .collect();
574            (unique.len() as f32) / (chars.len() as f32) < 0.4
575        });
576        if all_repeated {
577            return false;
578        }
579    }
580
581    true
582}
583
584// ── Score normalization ────────────────────────────────────────────────────────
585
586/// Calibrated relevance ceiling for normalized scores.
587///
588/// Prevents the best candidate from entering the 1.0 saturation zone before
589/// temporal, salience, and entity adjustments are applied.
590const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
591
592/// RRF score threshold above which the best result is considered a genuine
593/// relevance signal (~rank 20 in dual-source RRF(k=60)).
594const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
595
596/// Normalize raw-cosine or BM25 scores (single-source) into a calibrated relevance band.
597pub fn normalize_rank_fusion_scores(
598    scores: Vec<(Uuid, f32)>,
599    config: &ScoringConfig,
600) -> HashMap<Uuid, f32> {
601    if scores.is_empty() {
602        return HashMap::new();
603    }
604    let min_rrf = config.min_rrf_relevance;
605    let filtered: Vec<(Uuid, f32)> = scores
606        .into_iter()
607        .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
608        .collect();
609    if filtered.is_empty() {
610        return HashMap::new();
611    }
612    let max_score = filtered
613        .iter()
614        .map(|(_, s)| *s)
615        .fold(f32::NEG_INFINITY, f32::max);
616    if !max_score.is_finite() || max_score <= 0.0 {
617        return HashMap::new();
618    }
619    let min_score_seen = filtered
620        .iter()
621        .map(|(_, s)| *s)
622        .fold(f32::INFINITY, f32::min);
623    let span = max_score - min_score_seen;
624    let floor = config
625        .baseline_relevance
626        .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
627    let range = NORMALIZED_RELEVANCE_CEILING - floor;
628
629    let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
630
631    filtered
632        .into_iter()
633        .map(|(id, score)| {
634            let calibrated = if span <= f32::EPSILON {
635                max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
636            } else {
637                let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
638                floor + percentile * range
639            };
640            (id, calibrated * signal_strength)
641        })
642        .collect()
643}
644
645/// Normalize RRF-fused scores (dual-source) into a calibrated relevance band.
646pub fn normalize_rrf_scores(
647    scores: Vec<(Uuid, f32)>,
648    config: &ScoringConfig,
649) -> HashMap<Uuid, f32> {
650    if scores.is_empty() {
651        return HashMap::new();
652    }
653    let min_rrf = config.min_rrf_relevance;
654    let filtered: Vec<(Uuid, f32)> = scores
655        .into_iter()
656        .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
657        .collect();
658    if filtered.is_empty() {
659        return HashMap::new();
660    }
661    let max_score = filtered
662        .iter()
663        .map(|(_, s)| *s)
664        .fold(f32::NEG_INFINITY, f32::max);
665    if !max_score.is_finite() || max_score <= 0.0 {
666        return HashMap::new();
667    }
668    let min_score_seen = filtered
669        .iter()
670        .map(|(_, s)| *s)
671        .fold(f32::INFINITY, f32::min);
672    let span = max_score - min_score_seen;
673    let floor = config
674        .baseline_relevance
675        .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
676    let range = NORMALIZED_RELEVANCE_CEILING - floor;
677
678    filtered
679        .into_iter()
680        .map(|(id, score)| {
681            let calibrated = if span <= f32::EPSILON {
682                floor + range
683            } else {
684                let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
685                floor + percentile * range
686            };
687            (id, calibrated)
688        })
689        .collect()
690}
691
692// ── Composite scoring ─────────────────────────────────────────────────────────
693
694/// Input data for `calculate_score` — groups per-candidate fields to stay
695/// within clippy's 7-argument limit.
696pub struct ScoreInput<'a> {
697    pub salience: f32,
698    pub memory_type_str: &'a str,
699    pub content: &'a str,
700    pub created_at_millis: i64,
701    pub decay_factor: f32,
702    pub now_millis: i64,
703    pub relevance_score: f32,
704    pub entity_names: &'a [String],
705}
706
707/// Composite score for a single memory candidate.
708///
709/// Formula (semantic-gate model, multiplicative):
710///   `score = w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`
711///
712/// Then each `ScoreAdjustment` in `config.adjustments` is evaluated and applied in order.
713/// Result is clamped to `[0, 1]`.
714pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
715    let w = &config.weights;
716    let semantic_base = w.relevance * input.relevance_score;
717
718    let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
719        / (24.0 * 60.0 * 60.0 * 1000.0))
720        .max(0.0);
721
722    let capped_decay = input.decay_factor.min(config.decay_cap);
723    let temporal_recency = (-capped_decay * time_diff_days).exp();
724
725    let temporal_boost = 1.0 + w.temporal * temporal_recency;
726    let salience_boost = 1.0 + w.salience * input.salience;
727
728    let mut score = semantic_base * temporal_boost * salience_boost;
729
730    let ctx = CandidateContext {
731        memory_type: input.memory_type_str,
732        age_days: time_diff_days,
733        salience: input.salience,
734        content: input.content,
735        entity_names: input.entity_names,
736    };
737
738    for adj in &config.adjustments {
739        score = adj.apply(score, &ctx);
740    }
741
742    score.clamp(0.0, 1.0)
743}
744
745// ── ADR-104 §2 (Stage B): bounded per-entity posterior term ───────────────────
746
747/// Default weight for the per-entity posterior term. Chosen so the clamp
748/// bounds below are exactly reachable at posterior means of 0.0 and 1.0
749/// (`1 + 0.3 * (0.0 - 0.5) = 0.85`, `1 + 0.3 * (1.0 - 0.5) = 1.15`).
750pub const ENTITY_POSTERIOR_WEIGHT: f32 = 0.3;
751
752/// Lower bound of the per-entity posterior multiplier — the term can never
753/// move a score down by more than 15%.
754pub const ENTITY_POSTERIOR_CLAMP_MIN: f32 = 0.85;
755
756/// Upper bound of the per-entity posterior multiplier — the term can never
757/// move a score up by more than 15%.
758pub const ENTITY_POSTERIOR_CLAMP_MAX: f32 = 1.15;
759
760/// `clamp(1 + w_ent * (entity_posterior_mean - 0.5), 0.85, 1.15)`.
761///
762/// `None` (no posterior for this candidate beyond the uninformative prior)
763/// is neutral: exactly `1.0`, not the midpoint of the clamp band. Untouched
764/// memories must be unaffected — the neutral value has to be an identity
765/// multiplier, not a value that happens to fall inside the bounds.
766pub fn entity_posterior_term(entity_posterior_mean: Option<f64>, w_ent: f32) -> f32 {
767    match entity_posterior_mean {
768        Some(mean) => (1.0 + w_ent * (mean as f32 - 0.5))
769            .clamp(ENTITY_POSTERIOR_CLAMP_MIN, ENTITY_POSTERIOR_CLAMP_MAX),
770        None => 1.0,
771    }
772}
773
774// ── Tests ─────────────────────────────────────────────────────────────────────
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn is_meaningful_query_rejects_empty() {
782        assert!(!is_meaningful_query(""));
783        assert!(!is_meaningful_query("   "));
784    }
785
786    #[test]
787    fn is_meaningful_query_rejects_symbols_only() {
788        assert!(!is_meaningful_query("!@#$%"));
789        assert!(!is_meaningful_query("..."));
790    }
791
792    #[test]
793    fn is_meaningful_query_rejects_single_latin_char() {
794        assert!(!is_meaningful_query("a"));
795        assert!(!is_meaningful_query("Z"));
796    }
797
798    #[test]
799    fn is_meaningful_query_rejects_repeated_gibberish() {
800        assert!(!is_meaningful_query("aaaa bbbb cccc"));
801    }
802
803    #[test]
804    fn is_meaningful_query_accepts_normal_queries() {
805        assert!(is_meaningful_query("what is the capital of France"));
806        assert!(is_meaningful_query("rust async runtime"));
807        assert!(is_meaningful_query("hello"));
808    }
809
810    #[test]
811    fn contains_cjk_detects_chinese() {
812        // Purely CJK — well above 15% threshold.
813        assert!(contains_cjk("你好世界"));
814        // Mixed string: "世界" = 2 CJK out of 6 chars = 33%, above threshold.
815        assert!(contains_cjk("世界 hi"));
816        // Mostly-Latin with 2 CJK chars out of 15 total = 13% → below threshold.
817        assert!(!contains_cjk("hello 世界 world"));
818    }
819
820    #[test]
821    fn contains_cjk_ignores_latin() {
822        assert!(!contains_cjk("hello world"));
823        assert!(!contains_cjk(""));
824    }
825
826    #[test]
827    fn normalize_min_score_fraction_passthrough() {
828        let v = normalize_min_score(0.5).unwrap();
829        assert!((v - 0.5f32).abs() < 1e-6);
830    }
831
832    #[test]
833    fn normalize_min_score_percent_form() {
834        let v = normalize_min_score(50.0).unwrap();
835        assert!((v - 0.5f32).abs() < 1e-6);
836    }
837
838    #[test]
839    fn normalize_min_score_rejects_out_of_range() {
840        assert!(normalize_min_score(200.0).is_err());
841        assert!(normalize_min_score(-1.0).is_err());
842        assert!(normalize_min_score(f64::NAN).is_err());
843    }
844
845    #[test]
846    fn calculate_score_returns_unit_interval() {
847        let config = ScoringConfig::default();
848        let score = calculate_score(
849            &ScoreInput {
850                salience: 0.9,
851                memory_type_str: "episodic",
852                content: "test content",
853                created_at_millis: 0,
854                decay_factor: 0.01,
855                now_millis: 1000,
856                relevance_score: 0.8,
857                entity_names: &[],
858            },
859            &config,
860        );
861        assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
862    }
863
864    #[test]
865    fn calculate_score_high_salience_ranks_higher() {
866        let config = ScoringConfig {
867            adjustments: vec![],
868            ..ScoringConfig::default()
869        };
870        let now_ms = 1_000_000i64;
871        let score_high = calculate_score(
872            &ScoreInput {
873                salience: 0.9,
874                memory_type_str: "episodic",
875                content: "content",
876                created_at_millis: 0,
877                decay_factor: 0.01,
878                now_millis: now_ms,
879                relevance_score: 0.7,
880                entity_names: &[],
881            },
882            &config,
883        );
884        let score_low = calculate_score(
885            &ScoreInput {
886                salience: 0.1,
887                memory_type_str: "episodic",
888                content: "content",
889                created_at_millis: 0,
890                decay_factor: 0.01,
891                now_millis: now_ms,
892                relevance_score: 0.7,
893                entity_names: &[],
894            },
895            &config,
896        );
897        assert!(score_high > score_low, "high salience should rank higher");
898    }
899
900    #[test]
901    fn calculate_score_entity_match_from_auto_extraction_lifts_equal_relevance_candidate() {
902        // Wires `extract_entity_candidates` straight into `calculate_score` with
903        // the default adjustments (EntityMatch ×1.3 included) — everything else
904        // held identical between the two candidates — to isolate exactly what
905        // the entity boost contributes, decoupled from retrieval-stage relevance
906        // differences (which the handler-level tests in recall.rs cover instead).
907        let config = ScoringConfig::default();
908        let now_ms = 1_000_000i64;
909
910        // Capitalized-signal query so extraction yields a single isolated
911        // candidate ("gamma") — that keeps this test's two candidates
912        // differing by exactly one factor (does the content mention the
913        // named entity or not).
914        let query = "sibling transfer to Gamma university";
915        let entity_names = extract_entity_candidates(query);
916        assert_eq!(
917            entity_names,
918            vec!["gamma"],
919            "capitalized-signal query must extract exactly the one proper noun: {entity_names:?}"
920        );
921
922        let matching_score = calculate_score(
923            &ScoreInput {
924                salience: 0.5,
925                memory_type_str: "semantic",
926                content: "sibling transferred to gamma university this fall",
927                created_at_millis: 0,
928                decay_factor: 0.005,
929                now_millis: now_ms,
930                relevance_score: 0.5,
931                entity_names: &entity_names,
932            },
933            &config,
934        );
935        let non_matching_score = calculate_score(
936            &ScoreInput {
937                salience: 0.5,
938                memory_type_str: "semantic",
939                content: "sibling transferred to a different university this fall",
940                created_at_millis: 0,
941                decay_factor: 0.005,
942                now_millis: now_ms,
943                relevance_score: 0.5,
944                entity_names: &entity_names,
945            },
946            &config,
947        );
948
949        assert!(
950            matching_score > non_matching_score,
951            "memory matching an auto-extracted entity name must outrank an \
952             equal-relevance memory that doesn't: matching={matching_score} \
953             non_matching={non_matching_score}"
954        );
955        // The EntityMatch adjustment is a flat ×1.3 multiply applied after the
956        // base formula and the other (inactive, for this age/salience combo)
957        // adjustments — assert the lift is approximately that factor, not just
958        // "higher", so a future change to the adjustment order/weights is caught.
959        let ratio = matching_score / non_matching_score;
960        assert!(
961            (ratio - 1.3).abs() < 0.01,
962            "expected ~1.3x lift from the EntityMatch adjustment, got ratio {ratio}"
963        );
964    }
965
966    #[test]
967    fn dos_caps_enforce_limits() {
968        let mut config = ScoringConfig {
969            max_recall_candidates: 9999,
970            default_token_budget: 99999,
971            default_recall_limit: 9999,
972            ..ScoringConfig::default()
973        };
974        config.apply_dos_caps();
975        assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
976        assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
977        assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
978    }
979
980    #[test]
981    fn normalize_rrf_scores_preserves_ordering() {
982        let config = ScoringConfig::default();
983        let input = vec![
984            (Uuid::new_v4(), 0.030f32),
985            (Uuid::new_v4(), 0.020f32),
986            (Uuid::new_v4(), 0.015f32),
987        ];
988        let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
989        let output = normalize_rrf_scores(input, &config);
990        let s0 = output[&ids[0]];
991        let s1 = output[&ids[1]];
992        let s2 = output[&ids[2]];
993        assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
994        assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
995    }
996
997    // ── needs_multilingual ────────────────────────────────────────────────────
998
999    #[test]
1000    fn needs_multilingual_ascii_english_routes_primary() {
1001        assert!(!needs_multilingual("hello world"));
1002        assert!(!needs_multilingual("rust programming language"));
1003        assert!(!needs_multilingual(""));
1004        assert!(!needs_multilingual("42 items in the list"));
1005    }
1006
1007    #[test]
1008    fn needs_multilingual_cjk_routes_multilingual() {
1009        // Pure CJK: 4/4 = 100% non-ASCII alpha → well above 15%.
1010        assert!(needs_multilingual("你好世界"));
1011        // Mixed: 2 CJK out of 5 chars = 40%.
1012        assert!(needs_multilingual("abc你好"));
1013    }
1014
1015    #[test]
1016    fn needs_multilingual_cyrillic_routes_multilingual() {
1017        // "Привет мир" (Hello world in Russian): all Cyrillic alphabetic.
1018        assert!(needs_multilingual("Привет мир"));
1019        // Single Cyrillic word mixed with Latin — Г alone is 1/7 ≈ 14% (below threshold).
1020        // Verify with a word that has enough Cyrillic chars to cross 15%.
1021        assert!(needs_multilingual("Привет hello")); // Привет = 6 non-ASCII alpha, total chars including space = 12 → 6/12 = 50%
1022    }
1023
1024    #[test]
1025    fn needs_multilingual_arabic_routes_multilingual() {
1026        // "مرحبا" (Hello in Arabic): all Arabic alphabetic.
1027        assert!(needs_multilingual("مرحبا بالعالم"));
1028    }
1029
1030    #[test]
1031    fn needs_multilingual_devanagari_routes_multilingual() {
1032        // "नमस्ते" (Namaste in Hindi/Devanagari).
1033        assert!(needs_multilingual("नमस्ते दुनिया"));
1034    }
1035
1036    #[test]
1037    fn needs_multilingual_accented_latin_routes_multilingual() {
1038        // French: "café résumé" — é is alphabetic and non-ASCII.
1039        // Alphabetic chars: c,a,f,é,r,é,s,u,m,é = 10; non-ASCII alpha: 3; 3/10 = 30% > 15%.
1040        assert!(needs_multilingual("café résumé"));
1041        // German: "Müller" — ü is 1/6 alpha = 16.7% > 15%.
1042        assert!(needs_multilingual("Müller"));
1043        // Spanish: "niño" — ñ is 1/4 alpha = 25% > 15%.
1044        assert!(needs_multilingual("niño"));
1045    }
1046
1047    #[test]
1048    fn needs_multilingual_alphabetic_denominator_is_stable_under_punctuation() {
1049        // INVARIANT 2: punctuation/digits must not change routing decision.
1050        // "Müller" alpha: M,ü,l,l,e,r = 6; non-ASCII alpha: ü = 1; 1/6 = 16.7% > 15%.
1051        assert!(needs_multilingual("Müller"));
1052        // "Müller?" — same 6 alpha chars; ? is not alphabetic; 1/6 = 16.7% → still routes.
1053        assert!(needs_multilingual("Müller?"));
1054        // "Müller!!!" — same 6 alpha chars; 1/6 = 16.7% → still routes.
1055        assert!(needs_multilingual("Müller!!!"));
1056        // Digit-heavy short accented query: "42 Ü" — alpha: Ü = 1; non-ASCII: 1; 1/1 = 100%.
1057        assert!(needs_multilingual("42 Ü"));
1058        // Empty / no-alpha: must return false without divide-by-zero.
1059        assert!(!needs_multilingual(""));
1060        assert!(!needs_multilingual("42 100 ???"));
1061    }
1062
1063    #[test]
1064    fn needs_multilingual_pure_ascii_accented_below_threshold_routes_primary() {
1065        // "hello naïve" — ï is 1 non-ASCII alpha out of 10 alpha = 10% < 15% → primary.
1066        // (Deliberate design decision: a single diacritic in an otherwise English
1067        // sentence does not warrant multilingual routing.)
1068        assert!(!needs_multilingual("hello naive")); // no diacritics, definitely primary
1069                                                     // "über" — ü is 1/4 alpha = 25% → routes multilingual (German word).
1070        assert!(needs_multilingual("über"));
1071    }
1072
1073    #[test]
1074    fn needs_multilingual_ascii_only_non_english_latin_routes_primary_known_limitation() {
1075        // INVARIANT 4: ASCII-only non-English Latin is NOT detected here.
1076        // Real language detection is tracked as a follow-up to issue #101.
1077        assert!(!needs_multilingual("bonjour le monde"));
1078        assert!(!needs_multilingual("como estas"));
1079        assert!(!needs_multilingual("ich suche einen buchhalter"));
1080    }
1081
1082    // ── extract_entity_candidates ─────────────────────────────────────────────
1083
1084    #[test]
1085    fn extract_entity_candidates_capitalized_tokens_only() {
1086        // Only capitalized tokens qualify; lowercase content words
1087        // ("sibling", "college") are excluded even though they're not
1088        // stopwords.
1089        let out = extract_entity_candidates("Alex sibling college Acme Beta Gamma");
1090        assert_eq!(out, vec!["alex", "acme", "beta", "gamma"]);
1091    }
1092
1093    #[test]
1094    fn extract_entity_candidates_all_lowercase_query_extracts_nothing() {
1095        // DESIGN RULING: the lowercase fallback was removed after review —
1096        // it degenerated EntityMatch into a second lexical-overlap reward on
1097        // top of retrieval-stage relevance. An all-lowercase query (however
1098        // topical) must extract zero candidates.
1099        let out = extract_entity_candidates("alex sibling college acme beta gamma");
1100        assert!(out.is_empty());
1101    }
1102
1103    #[test]
1104    fn extract_entity_candidates_strips_stopwords() {
1105        let out = extract_entity_candidates("what is the capital of France");
1106        // "what", "is", "the", "of" are stopwords; "capital" is lowercase (excluded);
1107        // "France" is capitalized and not a stopword, so it remains.
1108        assert_eq!(out, vec!["france"]);
1109    }
1110
1111    #[test]
1112    fn extract_entity_candidates_strips_punctuation() {
1113        let out = extract_entity_candidates("Alex's sibling, Acme!");
1114        assert_eq!(out, vec!["alex's", "acme"]);
1115    }
1116
1117    #[test]
1118    fn extract_entity_candidates_caps_at_max_auto_entity_names() {
1119        let query = "Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliet";
1120        let out = extract_entity_candidates(query);
1121        assert_eq!(out.len(), MAX_AUTO_ENTITY_NAMES);
1122        assert_eq!(
1123            out,
1124            vec!["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]
1125        );
1126    }
1127
1128    #[test]
1129    fn extract_entity_candidates_dedupes_case_insensitively() {
1130        let out = extract_entity_candidates("Acme ACME college");
1131        // capitalization signal present (Acme, ACME) → only capitalized tokens
1132        // qualify; "college" is dropped; Acme and ACME collapse to one entry.
1133        assert_eq!(out, vec!["acme"]);
1134    }
1135
1136    #[test]
1137    fn extract_entity_candidates_empty_query_returns_empty() {
1138        assert!(extract_entity_candidates("").is_empty());
1139        assert!(extract_entity_candidates("   ").is_empty());
1140    }
1141
1142    #[test]
1143    fn extract_entity_candidates_all_stopwords_returns_empty() {
1144        assert!(extract_entity_candidates("is the a of").is_empty());
1145    }
1146
1147    // ── EntityMatch word-boundary matching ──────────────────────────────────────
1148
1149    fn entity_match_ctx<'a>(content: &'a str, entity_names: &'a [String]) -> CandidateContext<'a> {
1150        CandidateContext {
1151            memory_type: "episodic",
1152            age_days: 0.0,
1153            salience: 0.5,
1154            content,
1155            entity_names,
1156        }
1157    }
1158
1159    #[test]
1160    fn contains_at_word_boundary_rejects_substring_inside_another_word() {
1161        assert!(!contains_at_word_boundary("alphabet soup", "beta"));
1162        assert!(!contains_at_word_boundary("buy a betamax player", "beta"));
1163        assert!(!contains_at_word_boundary(
1164            "water scarcity is rising",
1165            "car"
1166        ));
1167    }
1168
1169    #[test]
1170    fn contains_at_word_boundary_accepts_the_word_itself() {
1171        assert!(contains_at_word_boundary("beta release notes", "beta"));
1172        assert!(contains_at_word_boundary("drove the car home", "car"));
1173        // start/end of string edges (no adjacent char to fail the boundary check).
1174        assert!(contains_at_word_boundary("beta", "beta"));
1175        assert!(contains_at_word_boundary("notes beta", "beta"));
1176    }
1177
1178    #[test]
1179    fn contains_at_word_boundary_accepts_multi_word_phrase() {
1180        // Internal spaces in a multi-word candidate are themselves
1181        // non-alphanumeric, so the phrase's own boundaries (not each word's)
1182        // are what the check anchors on.
1183        assert!(contains_at_word_boundary(
1184            "the knowledge graph shows this",
1185            "knowledge graph"
1186        ));
1187        assert!(!contains_at_word_boundary(
1188            "prior knowledge, graphical view",
1189            "knowledge graph"
1190        ));
1191    }
1192
1193    #[test]
1194    fn entity_match_condition_rejects_substring_false_positives() {
1195        let entity_names = vec!["beta".to_string()];
1196        let ctx = entity_match_ctx("alphabet soup for dinner", &entity_names);
1197        assert!(
1198            !AdjustmentCondition::EntityMatch.matches(&ctx),
1199            "\"beta\" must not match inside \"alphabet\""
1200        );
1201
1202        let entity_names = vec!["car".to_string()];
1203        let ctx = entity_match_ctx("water scarcity is rising", &entity_names);
1204        assert!(
1205            !AdjustmentCondition::EntityMatch.matches(&ctx),
1206            "\"car\" must not match inside \"scarcity\""
1207        );
1208    }
1209
1210    #[test]
1211    fn entity_match_condition_matches_multi_word_explicit_name() {
1212        let entity_names = vec!["knowledge graph".to_string()];
1213        let ctx = entity_match_ctx("notes on the knowledge graph design", &entity_names);
1214        assert!(
1215            AdjustmentCondition::EntityMatch.matches(&ctx),
1216            "explicit multi-word entity name must still match on its own boundaries"
1217        );
1218    }
1219
1220    #[test]
1221    fn entity_match_condition_still_matches_real_word_occurrence() {
1222        let entity_names = vec!["beta".to_string()];
1223        let ctx = entity_match_ctx("the beta release ships tomorrow", &entity_names);
1224        assert!(
1225            AdjustmentCondition::EntityMatch.matches(&ctx),
1226            "boundary anchoring must not break matching the actual word"
1227        );
1228    }
1229
1230    // ── ADR-104 §2 (Stage B): entity_posterior_term ────────────────────────────
1231
1232    #[test]
1233    fn entity_posterior_term_neutral_when_no_posterior() {
1234        assert_eq!(entity_posterior_term(None, ENTITY_POSTERIOR_WEIGHT), 1.0);
1235    }
1236
1237    #[test]
1238    fn entity_posterior_term_neutral_at_uninformative_prior_mean() {
1239        // Beta(1,1) mean = 0.5 — the uninformative prior itself, distinct
1240        // from `None`, must also be an identity multiplier.
1241        let term = entity_posterior_term(Some(0.5), ENTITY_POSTERIOR_WEIGHT);
1242        assert!((term - 1.0).abs() < 1e-6, "got {term}");
1243    }
1244
1245    #[test]
1246    fn entity_posterior_term_reaches_low_clamp_bound_at_mean_zero() {
1247        let term = entity_posterior_term(Some(0.0), ENTITY_POSTERIOR_WEIGHT);
1248        assert!(
1249            (term - ENTITY_POSTERIOR_CLAMP_MIN).abs() < 1e-6,
1250            "w_ent=0.3 at mean=0.0 must land exactly on the 0.85 clamp bound, got {term}"
1251        );
1252    }
1253
1254    #[test]
1255    fn entity_posterior_term_reaches_high_clamp_bound_at_mean_one() {
1256        let term = entity_posterior_term(Some(1.0), ENTITY_POSTERIOR_WEIGHT);
1257        assert!(
1258            (term - ENTITY_POSTERIOR_CLAMP_MAX).abs() < 1e-6,
1259            "w_ent=0.3 at mean=1.0 must land exactly on the 1.15 clamp bound, got {term}"
1260        );
1261    }
1262
1263    #[test]
1264    fn entity_posterior_term_never_exceeds_clamp_bounds_for_out_of_range_input() {
1265        // A Beta posterior mean is mathematically bounded to [0, 1], but the
1266        // clamp must hold defensively regardless — the term is never allowed
1267        // to move a score by more than +-15% no matter what value reaches it.
1268        let low = entity_posterior_term(Some(-5.0), ENTITY_POSTERIOR_WEIGHT);
1269        let high = entity_posterior_term(Some(5.0), ENTITY_POSTERIOR_WEIGHT);
1270        assert_eq!(low, ENTITY_POSTERIOR_CLAMP_MIN);
1271        assert_eq!(high, ENTITY_POSTERIOR_CLAMP_MAX);
1272    }
1273
1274    #[test]
1275    fn entity_posterior_term_moves_monotonically_with_mean() {
1276        let low = entity_posterior_term(Some(0.2), ENTITY_POSTERIOR_WEIGHT);
1277        let mid = entity_posterior_term(Some(0.5), ENTITY_POSTERIOR_WEIGHT);
1278        let high = entity_posterior_term(Some(0.8), ENTITY_POSTERIOR_WEIGHT);
1279        assert!(low < mid, "low={low} mid={mid}");
1280        assert!(mid < high, "mid={mid} high={high}");
1281    }
1282}