1use crate::binding::Binding;
18use hawdb_core::graph_rag::ScoringFeatureSource;
19use hawdb_core::{HawDBError, Result, RuntimeTaskContext, Value};
20use hawdb_plan_cypher::ScoringSeedGraphInput;
21use std::num::{NonZeroU64, NonZeroUsize};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct HostScorerDescriptor<'a> {
28 name: &'a str,
29 version: &'a str,
30 cpu_units_per_row: NonZeroU64,
31}
32
33impl<'a> HostScorerDescriptor<'a> {
34 pub fn new(name: &'a str, version: &'a str, cpu_units_per_row: NonZeroU64) -> Result<Self> {
35 if [name, version]
36 .iter()
37 .any(|value| value.is_empty() || value.chars().any(char::is_control))
38 {
39 return Err(HawDBError::Semantic(
40 "host scorer name and version must be nonempty without control characters".into(),
41 ));
42 }
43 Ok(Self {
44 name,
45 version,
46 cpu_units_per_row,
47 })
48 }
49
50 pub fn name(self) -> &'a str {
51 self.name
52 }
53
54 pub fn version(self) -> &'a str {
55 self.version
56 }
57
58 pub fn cpu_units_per_row(self) -> NonZeroU64 {
59 self.cpu_units_per_row
60 }
61
62 pub(crate) fn ensure_matches(self, actual: Self) -> Result<()> {
63 if self != actual {
64 return Err(HawDBError::Execution(
65 "host scorer identity changed from its planned identity or cost".into(),
66 ));
67 }
68 Ok(())
69 }
70}
71
72pub struct HostScorerBatch<'a> {
76 pub features: &'a [&'a dyn ScoringFeatureSource],
77 pub reference_time_millis: u64,
78 pub task_context: Option<&'a RuntimeTaskContext>,
79 pub scratch_account: &'a crate::QueryMemoryAccount,
80}
81
82impl HostScorerBatch<'_> {
83 pub fn checkpoint(&self) -> Result<()> {
84 crate::pipeline::runtime_checkpoint(self.task_context)
85 }
86}
87
88pub trait HostScorer {
97 fn descriptor(&self) -> HostScorerDescriptor<'_>;
98
99 fn score_batch(&mut self, request: HostScorerBatch<'_>, scores: &mut [f64]) -> Result<()>;
100}
101
102#[doc(hidden)]
104pub trait HostScorerProvider {
105 fn with_scorer(
106 &self,
107 run: &mut dyn FnMut(&mut dyn HostScorer) -> Result<crate::pipeline::BatchControl>,
108 ) -> Result<crate::pipeline::BatchControl>;
109}
110
111#[derive(Debug)]
114pub struct AccountedHostScores {
115 scores: Box<[f64]>,
116 _reservation: crate::QueryMemoryLease,
117}
118
119impl AccountedHostScores {
120 pub fn scores(&self) -> &[f64] {
121 &self.scores
122 }
123}
124
125pub(crate) struct FrozenHostScorerIdentity {
126 name: Box<str>,
127 version: Box<str>,
128 cpu_units_per_row: NonZeroU64,
129 _reservation: crate::QueryMemoryLease,
130}
131
132impl FrozenHostScorerIdentity {
133 pub(crate) fn new(
134 descriptor: HostScorerDescriptor<'_>,
135 account: &crate::QueryMemoryAccount,
136 ) -> Result<Self> {
137 let bytes = descriptor
138 .name()
139 .len()
140 .checked_add(descriptor.version().len())
141 .ok_or_else(|| HawDBError::Execution("host scorer identity size overflow".into()))?;
142 let reservation = account.reserve(bytes)?;
143 Ok(Self {
144 name: descriptor.name().into(),
145 version: descriptor.version().into(),
146 cpu_units_per_row: descriptor.cpu_units_per_row(),
147 _reservation: reservation,
148 })
149 }
150
151 fn ensure_matches(&self, scorer: &dyn HostScorer) -> Result<()> {
152 HostScorerDescriptor {
153 name: &self.name,
154 version: &self.version,
155 cpu_units_per_row: self.cpu_units_per_row,
156 }
157 .ensure_matches(scorer.descriptor())
158 }
159}
160
161pub fn execute_host_scorer(
169 scorer: &mut dyn HostScorer,
170 request: HostScorerBatch<'_>,
171 max_candidate_rows: NonZeroUsize,
172) -> Result<AccountedHostScores> {
173 execute_host_scorer_with_identity(scorer, request, max_candidate_rows, None)
174}
175
176pub(crate) fn execute_host_scorer_with_identity(
177 scorer: &mut dyn HostScorer,
178 request: HostScorerBatch<'_>,
179 max_candidate_rows: NonZeroUsize,
180 frozen: Option<&FrozenHostScorerIdentity>,
181) -> Result<AccountedHostScores> {
182 request.checkpoint()?;
183 let rows = request.features.len();
184 if rows > max_candidate_rows.get() {
185 return Err(HawDBError::Execution(format!(
186 "host scoring cohort has {rows} rows, exceeding candidate limit {max_candidate_rows}"
187 )));
188 }
189 let account = request.scratch_account;
190 if rows == 0 {
191 return Ok(AccountedHostScores {
192 scores: Box::new([]),
193 _reservation: account.reserve(0)?,
194 });
195 }
196 let owned;
197 let identity = match frozen {
198 Some(identity) => identity,
199 None => {
200 owned = FrozenHostScorerIdentity::new(scorer.descriptor(), account)?;
201 &owned
202 }
203 };
204 identity.ensure_matches(scorer)?;
205 let output_bytes = rows
206 .checked_mul(std::mem::size_of::<f64>())
207 .ok_or_else(|| HawDBError::Execution("host scorer output size overflow".into()))?;
208 let reservation = account.reserve(output_bytes)?;
209 let mut scores = vec![f64::NAN; rows].into_boxed_slice();
212 let task_context = request.task_context;
213 request.checkpoint()?;
214 scorer.score_batch(request, &mut scores)?;
215 crate::pipeline::runtime_checkpoint(task_context)?;
216 identity.ensure_matches(scorer)?;
217 for (index, score) in scores.iter().enumerate() {
218 crate::pipeline::runtime_checkpoint(task_context)?;
219 if !score.is_finite() {
220 return Err(HawDBError::Execution(format!(
221 "host scorer did not produce a finite score for candidate {index}"
222 )));
223 }
224 }
225 Ok(AccountedHostScores {
226 scores,
227 _reservation: reservation,
228 })
229}
230
231#[cfg(test)]
232mod host_tests;
233
234const SEED_SCORE_ANNOTATION: &str = "\0hawdb.scoring.seed_score";
235const SEED_HOP_ANNOTATION: &str = "\0hawdb.scoring.hops";
236
237pub(crate) fn annotate_seed(values: &mut std::collections::BTreeMap<String, Value>, score: f64) {
238 values.insert(SEED_SCORE_ANNOTATION.into(), Value::Float(score));
239 values.insert(SEED_HOP_ANNOTATION.into(), Value::Int(0));
240}
241
242fn next_seed_hop(binding: &Binding, hop: usize, matched: bool) -> Result<Value> {
243 let previous = binding
244 .values
245 .get(SEED_HOP_ANNOTATION)
246 .ok_or_else(|| HawDBError::Execution("seed scoring lost producer hop provenance".into()))?;
247 let next = if !matched || previous == &Value::Null {
248 Value::Null
249 } else {
250 let Value::Int(previous) = previous else {
251 return Err(HawDBError::Execution("invalid seed hop provenance".into()));
252 };
253 let hop = i64::try_from(hop).map_err(|_| {
254 HawDBError::Execution("seed hop distance exceeds supported range".into())
255 })?;
256 Value::Int(
257 previous
258 .checked_add(hop)
259 .ok_or_else(|| HawDBError::Execution("seed hop distance overflow".into()))?,
260 )
261 };
262 Ok(next)
263}
264
265pub(crate) fn seed_hop_payload_reduction(
266 binding: &Binding,
267 hop: usize,
268 matched: bool,
269) -> Result<usize> {
270 let next = next_seed_hop(binding, hop, matched)?;
271 let previous = binding
272 .values
273 .get(SEED_HOP_ANNOTATION)
274 .expect("provenance key was checked");
275 Ok(crate::binding::value_payload_bytes(previous)
276 .saturating_sub(crate::binding::value_payload_bytes(&next)))
277}
278
279pub(crate) fn advance_seed_hop(binding: &mut Binding, hop: usize, matched: bool) -> Result<()> {
280 let next = next_seed_hop(binding, hop, matched)?;
281 *binding
282 .values
283 .get_mut(SEED_HOP_ANNOTATION)
284 .expect("provenance key was checked") = next;
285 Ok(())
286}
287
288pub(crate) fn borrowed_seed_annotations(
289 binding: &Binding,
290) -> impl Iterator<Item = (&'static str, &Value)> {
291 [SEED_SCORE_ANNOTATION, SEED_HOP_ANNOTATION]
292 .into_iter()
293 .filter_map(|name| binding.values.get(name).map(|value| (name, value)))
294}
295
296pub(crate) fn strip_seed_annotations(binding: &mut Binding) {
297 binding
298 .values
299 .retain(|name, _| !name.starts_with(hawdb_plan_cypher::SCORING_PROVENANCE_PREFIX));
300}
301
302pub struct BindingScoreFeatures<'a> {
309 binding: &'a Binding,
310 score_column: &'a str,
311 seed_graph_input: Option<&'a ScoringSeedGraphInput>,
312}
313
314impl<'a> BindingScoreFeatures<'a> {
315 pub fn new(binding: &'a Binding, score_column: &'a str) -> Self {
316 Self {
317 binding,
318 score_column,
319 seed_graph_input: None,
320 }
321 }
322
323 pub(crate) fn with_seed_graph_input(
324 binding: &'a Binding,
325 score_column: &'a str,
326 source: Option<&'a ScoringSeedGraphInput>,
327 ) -> Self {
328 Self {
329 binding,
330 score_column,
331 seed_graph_input: source,
332 }
333 }
334
335 fn property(&self, name: &str) -> Option<&Value> {
336 match self.seed_graph_input {
337 Some(source) => self
338 .binding
339 .nodes
340 .get(source.candidate_variable())?
341 .properties
342 .get(name),
343 None => self.binding.values.get(name),
344 }
345 }
346
347 fn numeric(&self, value: Option<&Value>) -> Option<f64> {
348 match value? {
349 Value::Float(number) => Some(*number),
350 Value::Int(number) => Some(*number as f64),
351 _ => None,
352 }
353 }
354}
355
356impl ScoringFeatureSource for BindingScoreFeatures<'_> {
357 fn returned_value(&self, column: &str) -> Option<&Value> {
358 if column.contains('\0') {
359 return None;
360 }
361 self.binding.values.get(column)
362 }
363
364 fn search_score(&self) -> Option<f64> {
365 if self
366 .seed_graph_input
367 .is_some_and(|source| source.kind() == hawdb_plan_cypher::ScoringSeedKind::Graph)
368 {
369 return None;
370 }
371 self.numeric(self.binding.values.get(if self.seed_graph_input.is_some() {
372 SEED_SCORE_ANNOTATION
373 } else {
374 self.score_column
375 }))
376 }
377
378 fn graph_seed_score(&self) -> Option<f64> {
379 let source = self.seed_graph_input?;
380 if source.kind() != hawdb_plan_cypher::ScoringSeedKind::Graph {
381 return None;
382 }
383 self.numeric(self.binding.values.get(SEED_SCORE_ANNOTATION))
384 }
385
386 fn hop_distance(&self) -> Option<usize> {
387 let source = self.seed_graph_input?;
388 self.binding.nodes.get(source.candidate_variable())?;
389 match self.binding.values.get(SEED_HOP_ANNOTATION)? {
390 Value::Int(hops) => usize::try_from(*hops).ok(),
391 _ => None,
392 }
393 }
394
395 fn numeric_property(&self, property: &str) -> Option<f64> {
396 self.numeric(self.property(property))
397 }
398
399 fn timestamp_millis(&self, property: &str) -> Option<u64> {
400 match self.property(property)? {
401 Value::Int(millis) => u64::try_from(*millis).ok(),
402 _ => None,
403 }
404 }
405}
406
407pub fn reference_time_millis() -> u64 {
410 hawdb_core::time::SystemTime::now()
411 .duration_since(hawdb_core::time::UNIX_EPOCH)
412 .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
413 .unwrap_or(0)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::BindingScoreFeatures;
419 use crate::binding::Binding;
420 use hawdb_core::graph_rag::{ScoreFeature, ScoringFeatureSource, ScoringSpec, ScoringTerm};
421 use hawdb_core::Value;
422 use std::collections::BTreeMap;
423
424 fn binding(values: impl IntoIterator<Item = (&'static str, Value)>) -> Binding {
425 Binding {
426 values: values
427 .into_iter()
428 .map(|(name, value)| (name.to_string(), value))
429 .collect::<BTreeMap<_, _>>(),
430 nodes: BTreeMap::new(),
431 relationships: BTreeMap::new(),
432 }
433 }
434
435 #[test]
436 fn reads_the_declared_score_column_and_properties() {
437 let row = binding([
438 ("score", Value::Float(0.25)),
439 ("pagerank", Value::Int(65_000)),
440 ("updated_at", Value::Int(1_000)),
441 ("title", Value::String("not numeric".to_string())),
442 ]);
443 let features = BindingScoreFeatures::new(&row, "score");
444
445 assert_eq!(features.search_score(), Some(0.25));
446 assert_eq!(features.numeric_property("pagerank"), Some(65_000.0));
447 assert_eq!(features.timestamp_millis("updated_at"), Some(1_000));
448 assert_eq!(features.numeric_property("title"), None);
449 assert_eq!(features.graph_seed_score(), None);
450 assert_eq!(features.hop_distance(), None);
451
452 let spec = ScoringSpec {
453 terms: vec![
454 ScoringTerm {
455 weight: 2.0,
456 feature: ScoreFeature::SearchScore,
457 },
458 ScoringTerm {
459 weight: 1.0,
460 feature: ScoreFeature::NodeProperty("pagerank".to_string()),
461 },
462 ],
463 decay: Vec::new(),
464 };
465 let evaluation = spec.evaluate(&features, 0);
466 assert_eq!(evaluation.combined_score, 2.0 * 0.25 + 65_000.0);
467 }
468
469 #[test]
470 fn absent_and_non_numeric_features_report_instead_of_scoring_zero() {
471 let row = binding([
472 ("score", Value::Float(0.5)),
473 ("title", Value::String("text".to_string())),
474 ("negative_time", Value::Int(-5)),
475 ]);
476 let features = BindingScoreFeatures::new(&row, "score");
477 assert_eq!(features.numeric_property("absent"), None);
478 assert_eq!(features.numeric_property("title"), None);
479 assert_eq!(features.timestamp_millis("negative_time"), None);
480
481 let spec = ScoringSpec {
482 terms: vec![ScoringTerm {
483 weight: 1.0,
484 feature: ScoreFeature::NodeProperty("absent".to_string()),
485 }],
486 decay: Vec::new(),
487 };
488 let evaluation = spec.evaluate(&features, 0);
489 assert_eq!(evaluation.combined_score, 0.0);
490 assert_eq!(
491 evaluation.missing_features,
492 vec![ScoreFeature::NodeProperty("absent".to_string())]
493 );
494 }
495
496 #[test]
497 fn a_missing_score_column_reports_missing_instead_of_zero() {
498 let row = binding([("id", Value::String("row".to_string()))]);
499 let features = BindingScoreFeatures::new(&row, "score");
500 assert_eq!(features.search_score(), None);
501
502 let spec = ScoringSpec {
503 terms: vec![ScoringTerm {
504 weight: 1.0,
505 feature: ScoreFeature::SearchScore,
506 }],
507 decay: Vec::new(),
508 };
509 let evaluation = spec.evaluate(&features, 0);
510 assert!(evaluation
511 .missing_features
512 .contains(&ScoreFeature::SearchScore));
513 }
514
515 #[test]
516 fn rerank_keeps_the_best_rows_with_a_deterministic_tie_break() {
517 use crate::observer::NoopExecutionObserver;
518 use crate::pipeline::{
519 BatchControl, BatchExecutionContext, BindingBatch, BindingBatchSource,
520 };
521 use crate::{ExecutionLimit, ExecutionMemoryConfig, QueryMemoryLedger};
522 use hawdb_core::{Catalog, Result};
523 use hawdb_plan_cypher::PhysicalPlan;
524
525 struct Source(Vec<Binding>);
526 impl BindingBatchSource for Source {
527 fn execute(
528 &mut self,
529 _: &PhysicalPlan,
530 limit: ExecutionLimit,
531 emit: &mut dyn FnMut(BindingBatch) -> Result<BatchControl>,
532 ) -> Result<BatchControl> {
533 assert_eq!(limit, ExecutionLimit::unlimited());
534 emit(std::mem::take(&mut self.0))
535 }
536 }
537 let original: Vec<_> = [1.0, 3.0, 3.0, 2.0]
538 .into_iter()
539 .enumerate()
540 .map(|(ordinal, score)| {
541 binding([
542 ("value", Value::Int(ordinal as i64)),
543 ("score", Value::Float(score)),
544 ])
545 })
546 .collect();
547 let catalog = Catalog::default();
548 let memory = ExecutionMemoryConfig::default();
549 let ledger = QueryMemoryLedger::new(memory.query_memory_bytes);
550 let spec = ScoringSpec {
551 terms: vec![ScoringTerm {
552 weight: 1.0,
553 feature: ScoreFeature::SearchScore,
554 }],
555 decay: Vec::new(),
556 };
557 for limit in [3, 0] {
558 let mut source = Source(original.clone());
559 let mut retained = Vec::new();
560 crate::transform::stream_scoring_rerank_batches(
561 &PhysicalPlan::EmptyExec,
562 "score",
563 &spec,
564 limit,
565 &mut source,
566 BatchExecutionContext {
567 catalog: &catalog,
568 memory: &memory,
569 memory_ledger: &ledger,
570 task_context: None,
571 observer: &NoopExecutionObserver,
572 },
573 ExecutionLimit::unlimited(),
574 &mut |batch| {
575 retained.extend(batch);
576 Ok(BatchControl::Continue)
577 },
578 )
579 .unwrap();
580 let expected = if limit == 0 {
581 Vec::new()
582 } else {
583 vec![Value::Int(1), Value::Int(2), Value::Int(3)]
584 };
585 assert_eq!(
586 retained
587 .iter()
588 .map(|row| row.values["value"].clone())
589 .collect::<Vec<_>>(),
590 expected
591 );
592 assert_eq!(retained.len(), limit);
593 assert_eq!(ledger.snapshot().used_bytes, 0);
594 }
595 }
596}