use crate::profiler::CostModel;
use crate::types::{BackendKind, DataShape};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MigrationDecision {
pub target: BackendKind,
pub cost_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct PolicyEngine<const D: usize> {
improvement_threshold: f64,
hysteresis_window: usize,
observations_since_migration: usize,
migrating: bool,
current_backend: BackendKind,
}
impl<const D: usize> PolicyEngine<D> {
pub fn new(initial_backend: BackendKind) -> Self {
Self {
improvement_threshold: 0.77,
hysteresis_window: 1000,
observations_since_migration: 0,
migrating: false,
current_backend: initial_backend,
}
}
pub fn with_config(
initial_backend: BackendKind,
improvement_threshold: f64,
hysteresis_window: usize,
) -> Self {
Self {
improvement_threshold,
hysteresis_window,
observations_since_migration: 0,
migrating: false,
current_backend: initial_backend,
}
}
pub fn current_backend(&self) -> BackendKind {
self.current_backend
}
pub fn improvement_threshold(&self) -> f64 {
self.improvement_threshold
}
pub fn hysteresis_window(&self) -> usize {
self.hysteresis_window
}
pub fn observations_since_migration(&self) -> usize {
self.observations_since_migration
}
pub fn is_migrating(&self) -> bool {
self.migrating
}
pub fn on_migration_started(&mut self) {
self.migrating = true;
self.observations_since_migration = 0;
}
pub fn on_migration_complete(&mut self, target: BackendKind) {
self.current_backend = target;
self.migrating = false;
}
pub fn tick(&mut self, shape: &DataShape<D>) -> Option<MigrationDecision> {
self.observations_since_migration += 1;
if self.migrating {
return None;
}
if self.observations_since_migration <= self.hysteresis_window {
return None;
}
let current_cost = self.cost_for(self.current_backend, shape);
if current_cost <= 0.0 {
return None;
}
let (target, best_cost) = [
BackendKind::RTree,
BackendKind::KDTree,
BackendKind::Grid,
BackendKind::Quadtree,
]
.iter()
.copied()
.filter(|&c| c != self.current_backend && !(c == BackendKind::Quadtree && D > 4))
.map(|c| (c, self.cost_for(c, shape)))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
let cost_ratio = best_cost / current_cost;
if cost_ratio >= self.improvement_threshold {
return None;
}
Some(MigrationDecision { target, cost_ratio })
}
fn cost_for(&self, backend: BackendKind, shape: &DataShape<D>) -> f64 {
match backend {
BackendKind::RTree => CostModel::<D>::rtree_cost(shape),
BackendKind::KDTree => CostModel::<D>::kdtree_cost(shape),
BackendKind::Grid => CostModel::<D>::grid_cost(shape),
BackendKind::Quadtree => CostModel::<D>::quadtree_cost(shape),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{BBox, DataShape, Point, QueryMix};
use proptest::prelude::*;
fn make_shape<const D: usize>(
n: usize,
clustering_coef: f64,
overlap_ratio: f64,
selectivity: f64,
) -> DataShape<D> {
DataShape {
point_count: n,
bbox: BBox::new(Point::new([0.0; D]), Point::new([1.0; D])),
skewness: [0.0; D],
clustering_coef,
overlap_ratio,
effective_dim: D as f64,
query_mix: QueryMix {
range_frac: 1.0,
knn_frac: 0.0,
join_frac: 0.0,
mean_selectivity: selectivity,
},
}
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 200,
..Default::default()
})]
#[test]
fn prop_policy_hysteresis_guard(
hysteresis_window in 1usize..200,
ticks_in_window in 1usize..200,
) {
let shape = make_shape::<2>(100_000, 5.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::with_config(
BackendKind::KDTree,
0.77,
hysteresis_window,
);
engine.on_migration_started();
engine.on_migration_complete(BackendKind::KDTree);
let ticks = ticks_in_window.min(hysteresis_window);
for _ in 0..ticks {
prop_assert!(engine.tick(&shape).is_none());
}
}
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 200,
..Default::default()
})]
#[test]
fn prop_policy_improvement_threshold(
n in 100usize..100_000,
selectivity in 0.001f64..0.1,
) {
let shape = make_shape::<2>(n, 1.0, 0.0, selectivity);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::KDTree, 0.0, 0);
for _ in 0..5 {
prop_assert!(engine.tick(&shape).is_none());
}
}
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 200,
..Default::default()
})]
#[test]
fn prop_no_concurrent_migrations(
n in 100usize..100_000,
clustering_coef in 1.0f64..10.0,
selectivity in 0.001f64..0.1,
) {
let shape = make_shape::<2>(n, clustering_coef, 0.0, selectivity);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::KDTree, 0.77, 0);
engine.on_migration_started();
prop_assert!(engine.is_migrating());
for _ in 0..10 {
prop_assert!(engine.tick(&shape).is_none());
prop_assert!(engine.is_migrating());
}
}
}
#[test]
fn hysteresis_prevents_immediate_remigration() {
let shape = make_shape::<2>(100_000, 10.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::KDTree, 0.77, 5);
for _ in 0..6 {
engine.tick(&shape);
}
let decision = engine.tick(&shape);
assert!(
decision.is_some(),
"expected migration decision after window"
);
let target = decision.unwrap().target;
engine.on_migration_started();
engine.on_migration_complete(target);
assert!(
engine.tick(&shape).is_none(),
"expected None immediately after migration"
);
}
#[test]
fn no_migration_when_already_migrating() {
let shape = make_shape::<2>(100_000, 10.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::KDTree, 0.77, 0);
engine.on_migration_started();
assert!(engine.is_migrating());
assert!(engine.tick(&shape).is_none());
assert!(engine.is_migrating());
}
#[test]
fn quadtree_excluded_at_d5() {
let shape = make_shape::<5>(10_000, 1.0, 0.0, 0.01);
let mut engine = PolicyEngine::<5>::with_config(BackendKind::KDTree, 0.77, 0);
for _ in 0..2 {
engine.tick(&shape);
}
if let Some(d) = engine.tick(&shape) {
assert_ne!(d.target, BackendKind::Quadtree);
}
}
#[test]
fn migration_fires_after_hysteresis_window() {
let shape = make_shape::<2>(100_000, 10.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::KDTree, 0.77, 3);
for i in 1..=3 {
assert!(
engine.tick(&shape).is_none(),
"tick {i}: expected None within window"
);
}
assert!(
engine.tick(&shape).is_some(),
"tick 4: expected migration decision after window"
);
}
#[test]
fn no_migration_when_threshold_not_met() {
let shape = make_shape::<2>(10_000, 1.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::with_config(BackendKind::RTree, 0.0, 0);
for _ in 0..5 {
assert!(engine.tick(&shape).is_none());
}
}
#[test]
fn observations_counter_increments_each_tick() {
let shape = make_shape::<2>(1000, 1.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::new(BackendKind::RTree);
assert_eq!(engine.observations_since_migration(), 0);
engine.tick(&shape);
assert_eq!(engine.observations_since_migration(), 1);
engine.tick(&shape);
assert_eq!(engine.observations_since_migration(), 2);
}
#[test]
fn on_migration_started_resets_counter() {
let shape = make_shape::<2>(1000, 1.0, 0.0, 0.01);
let mut engine = PolicyEngine::<2>::new(BackendKind::RTree);
for _ in 0..10 {
engine.tick(&shape);
}
assert_eq!(engine.observations_since_migration(), 10);
engine.on_migration_started();
assert_eq!(engine.observations_since_migration(), 0);
assert!(engine.is_migrating());
}
#[test]
fn current_backend_updates_on_complete_not_start() {
let mut engine = PolicyEngine::<2>::new(BackendKind::KDTree);
assert_eq!(engine.current_backend(), BackendKind::KDTree);
engine.on_migration_started();
assert_eq!(engine.current_backend(), BackendKind::KDTree);
engine.on_migration_complete(BackendKind::RTree);
assert_eq!(engine.current_backend(), BackendKind::RTree);
}
}