Skip to main content

hawdb_executor/
scoring.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Row-level feature source for the typed scoring contract.
16
17use 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/// Validated identity and logical per-row cost for a host's batch scorer.
24/// Query attachments include name/version in their cache identity. This cost
25/// is a planning hint, not elapsed time.
26#[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
72/// Borrowed inputs for the host-scoring escape hatch.
73/// Scratch allocations must reserve this query account before allocation and
74/// release their leases before returning; feature rows cannot escape the call.
75pub 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
88/// Batch scorer contract for a concrete formula that templates cannot express.
89///
90/// A host query attachment supplies the callback for the current request. The
91/// engine owns a score slice exactly matching the complete finite feature-row
92/// count, checkpoints cancellation before/after the call, and validates every
93/// finite output before TopN or consumer delivery. Implementations must write
94/// every score in input order, be deterministic for the declared identity,
95/// parameters and clock, and obey the scratch/cancellation contract.
96pub trait HostScorer {
97    fn descriptor(&self) -> HostScorerDescriptor<'_>;
98
99    fn score_batch(&mut self, request: HostScorerBatch<'_>, scores: &mut [f64]) -> Result<()>;
100}
101
102/// Borrowed, request-owned access to a callback. Cache entries never retain it.
103#[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/// Engine-owned callback scores whose allocation remains charged until drop.
112/// The borrower cannot detach the score buffer from its query reservation.
113#[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
161/// Executes one complete, explicitly bounded cohort supplied by the engine.
162///
163/// The caller must stage and account the complete candidate/feature cohort;
164/// `max_candidate_rows` is independent of the final result window. Output and
165/// the frozen callback identity are admitted before allocation. No callback
166/// result is available until cancellation, identity and every score validate.
167/// This primitive does not itself attach callbacks to query plans or cache keys.
168pub 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    // NaN distinguishes an unwritten slot from every accepted finite score,
210    // including legitimate zero and negative values.
211    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
302/// Reads scoring features from one row.
303///
304/// The legacy source reads the declared score column and returned value aliases.
305/// The opt-in retriever source reads engine-owned original score/observed hop
306/// annotations and properties of its canonical pinned candidate node. Graph-seed
307/// relevance is supplied only by the declared canonical graph-seed producer.
308pub 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
407/// Wall clock the specification ages timestamp features against, in epoch
408/// milliseconds.
409pub 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}