use super::*;
use crate::{QueryMemoryClass, QueryMemoryLedger};
use hawdb_core::RuntimeCancellationToken;
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
enum Behavior {
Complete,
Incomplete,
Nonfinite,
Error,
Cancel,
ChangeIdentity,
Panic,
}
struct CohortScorer {
behavior: Behavior,
calls: usize,
changed: bool,
cancellation: Option<RuntimeCancellationToken>,
scratch_bytes: usize,
}
impl CohortScorer {
fn new(behavior: Behavior) -> Self {
Self {
behavior,
calls: 0,
changed: false,
cancellation: None,
scratch_bytes: 0,
}
}
}
impl HostScorer for CohortScorer {
fn descriptor(&self) -> HostScorerDescriptor<'_> {
HostScorerDescriptor::new(
"cohort",
if self.changed { "v2" } else { "v1" },
NonZeroU64::MIN,
)
.unwrap()
}
fn score_batch(&mut self, request: HostScorerBatch<'_>, scores: &mut [f64]) -> Result<()> {
self.calls += 1;
assert_eq!(request.reference_time_millis, 1_234);
assert_eq!(scores.len(), request.features.len());
assert!(scores.iter().all(|score| score.is_nan()));
let _scratch = request.scratch_account.reserve(self.scratch_bytes)?;
let maximum = request
.features
.iter()
.map(|feature| feature.numeric_property("authority").unwrap())
.fold(0.0, f64::max);
for (index, (feature, score)) in request.features.iter().zip(scores.iter_mut()).enumerate()
{
if matches!(self.behavior, Behavior::Incomplete) && index == 1 {
continue;
}
*score = feature.numeric_property("authority").unwrap() / maximum;
}
match self.behavior {
Behavior::Nonfinite => scores[1] = f64::INFINITY,
Behavior::Error => {
return Err(HawDBError::Execution("synthetic scorer failure".into()))
}
Behavior::Cancel => {
assert!(self.cancellation.as_ref().unwrap().cancel());
}
Behavior::ChangeIdentity => self.changed = true,
Behavior::Panic => panic!("synthetic scorer panic"),
Behavior::Complete | Behavior::Incomplete => {}
}
Ok(())
}
}
fn rows() -> Vec<Binding> {
[1.0, 4.0, 100.0]
.into_iter()
.map(|authority| {
Binding::values(BTreeMap::from([(
"authority".into(),
Value::Float(authority),
)]))
})
.collect()
}
fn request<'a>(
features: &'a [&'a dyn ScoringFeatureSource],
account: &'a crate::QueryMemoryAccount,
task_context: Option<&'a RuntimeTaskContext>,
) -> HostScorerBatch<'a> {
HostScorerBatch {
features,
reference_time_millis: 1_234,
task_context,
scratch_account: account,
}
}
#[test]
fn callback_observes_the_full_cohort_and_scores_keep_their_reservation() {
let rows = rows();
let values: Vec<_> = rows
.iter()
.map(|row| BindingScoreFeatures::new(row, "score"))
.collect();
let features: Vec<&dyn ScoringFeatureSource> = values.iter().map(|value| value as _).collect();
let ledger = QueryMemoryLedger::new(NonZeroUsize::new(40).unwrap());
let account = ledger.account(
QueryMemoryClass::BlockingState,
"host",
NonZeroUsize::new(40).unwrap(),
);
let mut scorer = CohortScorer::new(Behavior::Complete);
scorer.scratch_bytes = 8;
let output = execute_host_scorer(
&mut scorer,
request(&features, &account, None),
NonZeroUsize::new(3).unwrap(),
)
.unwrap();
assert_eq!(output.scores(), &[0.01, 0.04, 1.0]);
assert_eq!(scorer.calls, 1);
assert_eq!(ledger.snapshot().peak_bytes, 40);
assert_eq!(ledger.snapshot().used_bytes, 24);
drop(output);
assert_eq!(ledger.snapshot().used_bytes, 0);
}
#[test]
fn candidate_and_output_limits_reject_before_callback_and_release_admission() {
let rows = rows();
let values: Vec<_> = rows
.iter()
.map(|row| BindingScoreFeatures::new(row, "score"))
.collect();
let features: Vec<&dyn ScoringFeatureSource> = values.iter().map(|value| value as _).collect();
for (bytes, max_rows, expected) in [(32, 2, "candidate limit"), (31, 3, "memory")] {
let ledger = QueryMemoryLedger::new(NonZeroUsize::new(bytes).unwrap());
let account = ledger.account(
QueryMemoryClass::BlockingState,
"host",
NonZeroUsize::new(bytes).unwrap(),
);
let mut scorer = CohortScorer::new(Behavior::Complete);
let error = execute_host_scorer(
&mut scorer,
request(&features, &account, None),
NonZeroUsize::new(max_rows).unwrap(),
)
.unwrap_err();
assert!(error.to_string().contains(expected), "{error}");
assert_eq!(scorer.calls, 0);
assert_eq!(ledger.snapshot().used_bytes, 0);
}
}
#[test]
fn incomplete_nonfinite_callback_error_and_identity_change_emit_no_scores() {
let rows = rows();
let values: Vec<_> = rows
.iter()
.map(|row| BindingScoreFeatures::new(row, "score"))
.collect();
let features: Vec<&dyn ScoringFeatureSource> = values.iter().map(|value| value as _).collect();
for (behavior, expected) in [
(Behavior::Incomplete, "candidate 1"),
(Behavior::Nonfinite, "candidate 1"),
(Behavior::Error, "synthetic scorer failure"),
(Behavior::ChangeIdentity, "identity changed"),
] {
let ledger = QueryMemoryLedger::new(NonZeroUsize::new(32).unwrap());
let account = ledger.account(
QueryMemoryClass::BlockingState,
"host",
NonZeroUsize::new(32).unwrap(),
);
let mut scorer = CohortScorer::new(behavior);
let error = execute_host_scorer(
&mut scorer,
request(&features, &account, None),
NonZeroUsize::new(3).unwrap(),
)
.unwrap_err();
assert!(error.to_string().contains(expected), "{error}");
assert_eq!(scorer.calls, 1);
assert_eq!(ledger.snapshot().used_bytes, 0);
}
}
#[test]
fn cancellation_before_and_during_callback_releases_all_scores() {
let rows = rows();
let values: Vec<_> = rows
.iter()
.map(|row| BindingScoreFeatures::new(row, "score"))
.collect();
let features: Vec<&dyn ScoringFeatureSource> = values.iter().map(|value| value as _).collect();
for precancelled in [false, true] {
let token = RuntimeCancellationToken::new();
let task = RuntimeTaskContext::without_deadline(token.clone());
if precancelled {
assert!(token.cancel());
}
let ledger = QueryMemoryLedger::new(NonZeroUsize::new(32).unwrap());
let account = ledger.account(
QueryMemoryClass::BlockingState,
"host",
NonZeroUsize::new(32).unwrap(),
);
let mut scorer = CohortScorer::new(Behavior::Cancel);
scorer.cancellation = Some(token);
let result = execute_host_scorer(
&mut scorer,
request(&features, &account, Some(&task)),
NonZeroUsize::new(3).unwrap(),
);
assert!(result.is_err());
assert_eq!(scorer.calls, usize::from(!precancelled));
assert_eq!(ledger.snapshot().used_bytes, 0);
}
}
#[test]
fn callback_unwind_releases_owned_score_and_identity_buffers() {
let rows = rows();
let values: Vec<_> = rows
.iter()
.map(|row| BindingScoreFeatures::new(row, "score"))
.collect();
let features: Vec<&dyn ScoringFeatureSource> = values.iter().map(|value| value as _).collect();
let ledger = QueryMemoryLedger::new(NonZeroUsize::new(32).unwrap());
let account = ledger.account(
QueryMemoryClass::BlockingState,
"host",
NonZeroUsize::new(32).unwrap(),
);
let mut scorer = CohortScorer::new(Behavior::Panic);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
execute_host_scorer(
&mut scorer,
request(&features, &account, None),
NonZeroUsize::new(3).unwrap(),
)
}));
assert!(result.is_err());
assert_eq!(scorer.calls, 1);
assert_eq!(ledger.snapshot().used_bytes, 0);
}
#[test]
fn empty_cohort_allocates_no_scores_and_skips_callback() {
let ledger = QueryMemoryLedger::new(NonZeroUsize::MIN);
let account = ledger.account(QueryMemoryClass::BlockingState, "host", NonZeroUsize::MIN);
let mut scorer = CohortScorer::new(Behavior::Complete);
let output =
execute_host_scorer(&mut scorer, request(&[], &account, None), NonZeroUsize::MIN).unwrap();
assert!(output.scores().is_empty());
assert_eq!(scorer.calls, 0);
assert_eq!(ledger.snapshot().used_bytes, 0);
}