aft/commands/semantic_search/
scoring.rs1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use super::plan_table::{PlanTable, SearchLaneKind, SearchShape};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct LaneContribution {
11 pub lane: SearchLaneKind,
12 pub position: usize,
13 pub raw_score: f32,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct AdmittedContribution {
19 pub lane: SearchLaneKind,
20 pub position: usize,
21 pub raw_score: f32,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub struct LaneScoringRule {
27 pub weight: f32,
28 pub rrf_constant: f32,
29 pub plan_order_index: usize,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct ScoringPolicy {
35 rules: BTreeMap<SearchLaneKind, LaneScoringRule>,
36}
37
38impl ScoringPolicy {
39 pub fn new(lexical: LaneScoringRule, semantic: LaneScoringRule) -> Result<Self, ScoringError> {
40 let mut rules = BTreeMap::new();
41 rules.insert(SearchLaneKind::Lexical, lexical);
42 rules.insert(SearchLaneKind::Semantic, semantic);
43 let policy = Self { rules };
44 policy.validate()?;
45 Ok(policy)
46 }
47
48 pub fn empty() -> Self {
51 Self {
52 rules: BTreeMap::new(),
53 }
54 }
55
56 pub fn from_plan_table(table: &PlanTable, shape: SearchShape) -> Result<Self, ScoringError> {
57 let shape_name = shape.as_str();
58 let lane_entries = table
59 .entries
60 .get(shape_name)
61 .ok_or_else(|| ScoringError::MissingPlanShape(shape_name.to_string()))?;
62
63 let rule = |lane: SearchLaneKind| -> Result<LaneScoringRule, ScoringError> {
64 let entry = lane_entries
65 .get(lane.as_str())
66 .ok_or(ScoringError::MissingLaneRule(lane))?;
67 let weight = entry.weight.ok_or(ScoringError::MissingWeight(lane))?;
68 let rrf_constant = entry
69 .rrf_constant
70 .ok_or(ScoringError::MissingRrfConstant(lane))?;
71 Ok(LaneScoringRule {
72 weight,
73 rrf_constant,
74 plan_order_index: entry.plan_order_index,
75 })
76 };
77
78 Self::new(
79 rule(SearchLaneKind::Lexical)?,
80 rule(SearchLaneKind::Semantic)?,
81 )
82 }
83
84 pub fn rule(&self, lane: SearchLaneKind) -> Option<LaneScoringRule> {
85 self.rules.get(&lane).copied()
86 }
87
88 fn validate(&self) -> Result<(), ScoringError> {
89 for (lane, rule) in &self.rules {
90 if *lane == SearchLaneKind::Exact {
91 return Err(ScoringError::ExactLaneHasScoringRule);
92 }
93 if !rule.weight.is_finite() || rule.weight < 0.0 {
94 return Err(ScoringError::InvalidWeight(*lane));
95 }
96 if !rule.rrf_constant.is_finite() || rule.rrf_constant < 0.0 {
97 return Err(ScoringError::InvalidRrfConstant(*lane));
98 }
99 }
100 Ok(())
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct FrozenScores {
107 pub fusion_score: f32,
108 pub lane_score: f32,
109 pub best_lane: SearchLaneKind,
110 pub admitted: Vec<AdmittedContribution>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum ScoringError {
115 EmptyAdmittedSet,
116 ExactContribution,
117 DuplicateLane(SearchLaneKind),
118 NonFiniteRawScore(SearchLaneKind),
119 MissingLaneRule(SearchLaneKind),
120 MissingPlanShape(String),
121 MissingWeight(SearchLaneKind),
122 MissingRrfConstant(SearchLaneKind),
123 InvalidWeight(SearchLaneKind),
124 InvalidRrfConstant(SearchLaneKind),
125 ExactLaneHasScoringRule,
126}
127
128impl fmt::Display for ScoringError {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 match self {
131 Self::EmptyAdmittedSet => {
132 formatter.write_str("non-exact candidate has an empty admitted contribution set")
133 }
134 Self::ExactContribution => {
135 formatter.write_str("the exact lane is depth-exempt and cannot be scored")
136 }
137 Self::DuplicateLane(lane) => write!(
138 formatter,
139 "candidate has more than one canonical contribution from the {lane} lane"
140 ),
141 Self::NonFiniteRawScore(lane) => {
142 write!(formatter, "{lane} contribution has a non-finite raw score")
143 }
144 Self::MissingLaneRule(lane) => {
145 write!(formatter, "scoring policy has no rule for the {lane} lane")
146 }
147 Self::MissingPlanShape(shape) => {
148 write!(formatter, "plan table has no entry for shape {shape}")
149 }
150 Self::MissingWeight(lane) => write!(formatter, "{lane} lane has no scoring weight"),
151 Self::MissingRrfConstant(lane) => {
152 write!(formatter, "{lane} lane has no RRF constant")
153 }
154 Self::InvalidWeight(lane) => write!(formatter, "{lane} lane has an invalid weight"),
155 Self::InvalidRrfConstant(lane) => {
156 write!(formatter, "{lane} lane has an invalid RRF constant")
157 }
158 Self::ExactLaneHasScoringRule => {
159 formatter.write_str("the exact lane must remain score-free")
160 }
161 }
162 }
163}
164
165impl std::error::Error for ScoringError {}
166
167pub fn admitted_contributions(
173 contributions: &[LaneContribution],
174 tier_depth: usize,
175) -> Result<Vec<AdmittedContribution>, ScoringError> {
176 let mut admitted = Vec::new();
177 let mut seen = BTreeMap::new();
178
179 for contribution in contributions {
180 if contribution.lane == SearchLaneKind::Exact {
181 return Err(ScoringError::ExactContribution);
182 }
183 if !contribution.raw_score.is_finite() {
184 return Err(ScoringError::NonFiniteRawScore(contribution.lane));
185 }
186 if seen.insert(contribution.lane, ()).is_some() {
187 return Err(ScoringError::DuplicateLane(contribution.lane));
188 }
189 if contribution.position < tier_depth {
190 admitted.push(AdmittedContribution {
191 lane: contribution.lane,
192 position: contribution.position,
193 raw_score: contribution.raw_score,
194 });
195 }
196 }
197
198 admitted.sort_by_key(|contribution| contribution.lane.default_plan_order_index());
199 Ok(admitted)
200}
201
202pub fn freeze_non_exact_scores(
208 contributions: &[LaneContribution],
209 tier_depth: usize,
210 policy: &ScoringPolicy,
211) -> Result<FrozenScores, ScoringError> {
212 let admitted = admitted_contributions(contributions, tier_depth)?;
213 if admitted.is_empty() {
214 return Err(ScoringError::EmptyAdmittedSet);
215 }
216
217 let mut fusion_score = 0.0f32;
218 let mut best: Option<(f32, usize, SearchLaneKind)> = None;
219
220 for contribution in &admitted {
221 let rule = policy
222 .rule(contribution.lane)
223 .ok_or(ScoringError::MissingLaneRule(contribution.lane))?;
224 fusion_score += rule.weight / (rule.rrf_constant + contribution.position as f32 + 1.0);
225
226 let proposed = (
227 contribution.raw_score,
228 rule.plan_order_index,
229 contribution.lane,
230 );
231 let replace = best.is_none_or(|current| {
232 proposed.0 > current.0 || (proposed.0 == current.0 && proposed.1 < current.1)
233 });
234 if replace {
235 best = Some(proposed);
236 }
237 }
238
239 let (lane_score, _, best_lane) = best.expect("non-empty admitted set has a best lane");
240 Ok(FrozenScores {
241 fusion_score,
242 lane_score,
243 best_lane,
244 admitted,
245 })
246}