diff --git a/crates/khive-runtime/Cargo.toml b/crates/khive-runtime/Cargo.toml index 9093b839..e3623dd1 100644 --- a/crates/khive-runtime/Cargo.toml +++ b/crates/khive-runtime/Cargo.toml @@ -28,6 +28,7 @@ chrono = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } lattice-embed = { workspace = true } +parking_lot = { workspace = true } rusqlite = { version = "0.33" } tempfile = "3" diff --git a/crates/khive-runtime/src/lib.rs b/crates/khive-runtime/src/lib.rs index 3cec2b3b..4e39ffda 100644 --- a/crates/khive-runtime/src/lib.rs +++ b/crates/khive-runtime/src/lib.rs @@ -26,6 +26,7 @@ pub mod fusion; pub mod graph_traversal; pub mod objectives; pub mod operations; +pub mod registry; pub mod pack; pub mod portability; pub mod retrieval; @@ -44,6 +45,7 @@ pub use objectives::{ VectorSimilarityObjective, }; pub use operations::{NoteSearchHit, QueryResult, Resolved}; +pub use registry::{ObjectiveRegistry, RegisteredObjective}; pub use pack::{ DispatchHook, KindHook, PackFactory, PackRegistration, PackRegistry, PackRuntime, VerbRegistry, VerbRegistryBuilder, diff --git a/crates/khive-runtime/src/registry.rs b/crates/khive-runtime/src/registry.rs new file mode 100644 index 00000000..cfab6f1c --- /dev/null +++ b/crates/khive-runtime/src/registry.rs @@ -0,0 +1,346 @@ +//! Objective registry for dynamic dispatch. +//! +//! Runtime infrastructure: named registration, lookup, defaults. +//! Lives in khive-runtime (not khive-fold) per ADR-058. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +use parking_lot::RwLock; + +use khive_fold::objective::{Objective, ObjectiveContext, ObjectiveError, ObjectiveResult, Selection}; + +/// A type-erased objective wrapper. +pub struct RegisteredObjective { + pub name: String, + pub description: Option, + objective: Box>, +} + +impl fmt::Debug for RegisteredObjective { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RegisteredObjective") + .field("name", &self.name) + .field("description", &self.description) + .finish_non_exhaustive() + } +} + +impl RegisteredObjective { + pub fn new(name: impl Into, objective: Box>) -> Self { + Self { + name: name.into(), + description: None, + objective, + } + } + + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Raw score (no precision weighting). Use `select()` for ranked selection + /// that applies `score * precision` per ADR-059. + pub fn score(&self, candidate: &T, context: &ObjectiveContext) -> f64 { + self.objective.score(candidate, context) + } + + pub fn select<'a>( + &self, + candidates: &'a [T], + context: &ObjectiveContext, + ) -> ObjectiveResult> { + self.objective.select(candidates, context) + } +} + +struct RegistryInner { + objectives: HashMap>>, + default: Option, +} + +/// Registry of named objectives. +/// +/// Thread-safe: all operations are behind a single `RwLock`. +pub struct ObjectiveRegistry { + inner: RwLock>, +} + +impl fmt::Debug for ObjectiveRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let inner = self.inner.read(); + f.debug_struct("ObjectiveRegistry") + .field("count", &inner.objectives.len()) + .field("default", &inner.default) + .finish() + } +} + +impl Default for ObjectiveRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ObjectiveRegistry { + pub fn new() -> Self { + Self { + inner: RwLock::new(RegistryInner { + objectives: HashMap::new(), + default: None, + }), + } + } + + pub fn register( + &self, + name: impl Into, + objective: Box>, + ) -> Option>> { + let name = name.into(); + let registered = Arc::new(RegisteredObjective::new(name.clone(), objective)); + self.inner.write().objectives.insert(name, registered) + } + + pub fn register_with_desc( + &self, + name: impl Into, + description: impl Into, + objective: Box>, + ) -> Option>> { + let name = name.into(); + let registered = Arc::new( + RegisteredObjective::new(name.clone(), objective).with_description(description), + ); + self.inner.write().objectives.insert(name, registered) + } + + pub fn set_default(&self, name: impl Into) -> ObjectiveResult<()> { + let name = name.into(); + let mut inner = self.inner.write(); + if !inner.objectives.contains_key(&name) { + return Err(ObjectiveError::NotFound(name)); + } + inner.default = Some(name); + Ok(()) + } + + pub fn get(&self, name: &str) -> ObjectiveResult>> { + self.inner + .read() + .objectives + .get(name) + .cloned() + .ok_or_else(|| ObjectiveError::NotFound(name.to_string())) + } + + pub fn get_default(&self) -> ObjectiveResult>> { + let inner = self.inner.read(); + match inner.default.as_ref() { + Some(name) => inner + .objectives + .get(name) + .cloned() + .ok_or_else(|| ObjectiveError::NotFound(name.clone())), + None => Err(ObjectiveError::NotFound("No default set".to_string())), + } + } + + pub fn list(&self) -> Vec { + let inner = self.inner.read(); + let mut names: Vec = inner.objectives.keys().cloned().collect(); + names.sort(); + names + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.read().objectives.contains_key(name) + } + + /// Raw score via a named objective (no precision weighting). + pub fn score( + &self, + name: &str, + candidate: &T, + context: &ObjectiveContext, + ) -> ObjectiveResult { + let objective = self.get(name)?; + Ok(objective.score(candidate, context)) + } + + pub fn select<'a>( + &self, + name: &str, + candidates: &'a [T], + context: &ObjectiveContext, + ) -> ObjectiveResult> { + let objective = self.get(name)?; + objective.select(candidates, context) + } + + pub fn select_default<'a>( + &self, + candidates: &'a [T], + context: &ObjectiveContext, + ) -> ObjectiveResult> { + let objective = self.get_default()?; + objective.select(candidates, context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use khive_fold::objective::objective_fn; + + #[test] + fn register_and_get() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + let old = registry.register("max", Box::new(obj)); + assert!(old.is_none()); + assert!(registry.contains("max")); + assert!(!registry.contains("min")); + } + + #[test] + fn register_overwrites() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj1 = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + let obj2 = objective_fn(|n: &i32, _ctx: &ObjectiveContext| -(*n as f64)); + assert!(registry.register("test", Box::new(obj1)).is_none()); + assert!(registry.register("test", Box::new(obj2)).is_some()); + + let candidates = vec![1, 5, 3]; + let selection = registry + .select("test", &candidates, &ObjectiveContext::new()) + .unwrap(); + assert_eq!(*selection.item, 1); + } + + #[test] + fn select_by_name() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + registry.register("max", Box::new(obj)); + + let candidates = vec![1, 5, 3]; + let selection = registry + .select("max", &candidates, &ObjectiveContext::new()) + .unwrap(); + assert_eq!(*selection.item, 5); + } + + #[test] + fn default_objective() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + registry.register("max", Box::new(obj)); + registry.set_default("max").unwrap(); + + let candidates = vec![1, 5, 3]; + let selection = registry + .select_default(&candidates, &ObjectiveContext::new()) + .unwrap(); + assert_eq!(*selection.item, 5); + } + + #[test] + fn list_objectives_sorted() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj1 = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + let obj2 = objective_fn(|n: &i32, _ctx: &ObjectiveContext| -(*n as f64)); + let obj3 = objective_fn(|n: &i32, _ctx: &ObjectiveContext| (*n as f64).abs()); + registry.register("zebra", Box::new(obj1)); + registry.register("alpha", Box::new(obj2)); + registry.register("middle", Box::new(obj3)); + + let names = registry.list(); + assert_eq!(names, vec!["alpha", "middle", "zebra"]); + } + + #[test] + fn get_nonexistent_returns_error() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let result = registry.get("nope"); + assert!(matches!(result, Err(ObjectiveError::NotFound(ref s)) if s == "nope")); + } + + #[test] + fn get_default_without_setting_returns_error() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let result = registry.get_default(); + assert!(matches!(result, Err(ObjectiveError::NotFound(_)))); + } + + #[test] + fn set_default_nonexistent_returns_error() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let result = registry.set_default("ghost"); + assert!(matches!(result, Err(ObjectiveError::NotFound(ref s)) if s == "ghost")); + } + + #[test] + fn score_via_registry() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64 * 2.0); + registry.register("double", Box::new(obj)); + + let score = registry.score("double", &5, &ObjectiveContext::new()).unwrap(); + assert!((score - 10.0).abs() < 1e-12); + } + + #[test] + fn select_default_via_registry() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| -(*n as f64)); + registry.register("min", Box::new(obj)); + registry.set_default("min").unwrap(); + + let candidates = vec![1, 5, 3]; + let selection = registry + .select_default(&candidates, &ObjectiveContext::new()) + .unwrap(); + assert_eq!(*selection.item, 1); + } + + #[test] + fn debug_impls() { + let registry: ObjectiveRegistry = ObjectiveRegistry::new(); + let obj = objective_fn(|n: &i32, _ctx: &ObjectiveContext| *n as f64); + registry.register("test", Box::new(obj)); + let debug = format!("{:?}", registry); + assert!(debug.contains("ObjectiveRegistry")); + assert!(debug.contains("count: 1")); + + let registered = registry.get("test").unwrap(); + let debug = format!("{:?}", registered); + assert!(debug.contains("RegisteredObjective")); + assert!(debug.contains("test")); + } + + #[test] + fn concurrent_read_write() { + let registry = Arc::new(ObjectiveRegistry::::new()); + + std::thread::scope(|s| { + for i in 0..8 { + let reg = Arc::clone(®istry); + s.spawn(move || { + let name = format!("obj_{i}"); + let obj = objective_fn(move |n: &i32, _ctx: &ObjectiveContext| *n as f64 + i as f64); + reg.register(name.clone(), Box::new(obj)); + + assert!(reg.contains(&name)); + + let candidates = vec![1, 2, 3]; + let _ = reg.select(&name, &candidates, &ObjectiveContext::new()); + }); + } + }); + + assert_eq!(registry.list().len(), 8); + } +}