kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
//! Builder for [`Engine<T>`][crate::engine::engine::Engine] — collects config
//! options and constructs the engine.

use crate::clock::{Clock, SystemClock};
use crate::decide::{Decide, DefaultDecider};
use crate::engine::config::EngineConfig;
use crate::engine::engine::Engine;
use crate::location::table::LocationTable;
use crate::schema::attr::AttrType;
use crate::scoring::VectorBackend;
use crate::Schema;
use std::sync::Arc;

/// Errors that can occur when calling [`EngineBuilder::build`].
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
    /// [`EngineBuilder::schema`] was never called.
    #[error("EngineBuilder: schema is required")]
    MissingSchema,
    /// The kind name passed to [`EngineBuilder::with_embedding_slot`] is not in the schema.
    #[error("unknown kind: {0}")]
    UnknownKind(String),
    /// The attribute name passed to [`EngineBuilder::with_embedding_slot`] is not in the schema.
    #[error("unknown attribute: {0}")]
    UnknownAttr(String),
    /// The attribute is declared in the schema but not in the specified kind.
    #[error("attribute '{attr}' is not declared in kind '{kind}'")]
    AttrNotInKind {
        /// Kind name.
        kind: String,
        /// Attribute name.
        attr: String,
    },
    /// The embedding slot attribute exists but is not of type `AttrType::F32Arr`.
    #[error("embedding slot '{kind}.{attr}' must be AttrType::F32Arr")]
    EmbeddingSlotNotF32Arr {
        /// Kind name.
        kind: String,
        /// Attribute name.
        attr: String,
    },
}

/// Fluent builder for [`Engine<T>`].
///
/// Call [`EngineBuilder::schema`] (required) then [`EngineBuilder::build`].
/// All other setters are optional and have sensible defaults.
pub struct EngineBuilder<T: Clone + Send + Sync + 'static> {
    schema: Option<Arc<Schema>>,
    clock: Option<Arc<dyn Clock>>,
    decider: Option<Arc<dyn Decide>>,
    default_vector_backend: VectorBackend,
    n_shards: usize,
    embedding_slot_name: Option<(String, String)>,
    _marker: std::marker::PhantomData<fn() -> T>,
}

impl<T: Clone + Send + Sync + 'static> EngineBuilder<T> {
    /// Create a new builder with all-defaults configuration.
    #[must_use]
    pub fn new() -> Self {
        Self {
            schema: None,
            clock: None,
            decider: None,
            default_vector_backend: VectorBackend::Linear,
            n_shards: 256,
            embedding_slot_name: None,
            _marker: std::marker::PhantomData,
        }
    }

    /// Set the schema (required before calling [`build`][Self::build]).
    #[must_use]
    pub fn schema(mut self, s: Arc<Schema>) -> Self {
        self.schema = Some(s);
        self
    }

    /// Set a concrete clock implementation.
    #[must_use]
    pub fn clock<C: Clock + 'static>(mut self, c: C) -> Self {
        self.clock = Some(Arc::new(c));
        self
    }

    /// Set a pre-boxed clock.
    #[must_use]
    pub fn clock_arc(mut self, c: Arc<dyn Clock>) -> Self {
        self.clock = Some(c);
        self
    }

    /// Set a concrete decider implementation.
    #[must_use]
    pub fn decider<D: Decide + 'static>(mut self, d: D) -> Self {
        self.decider = Some(Arc::new(d));
        self
    }

    /// Set the default vector backend used when no explicit backend is specified
    /// in a `ScorerSpec::Vector`.
    #[must_use]
    pub const fn default_vector_backend(mut self, b: VectorBackend) -> Self {
        self.default_vector_backend = b;
        self
    }

    /// Set the number of shards in the location table (default: 256).
    #[must_use]
    pub const fn n_shards(mut self, n: usize) -> Self {
        self.n_shards = n;
        self
    }

    /// Declare which schema attribute holds the per-location embedding.
    ///
    /// Both `kind` and `attr` must name entries already present in the schema
    /// supplied to [`EngineBuilder::schema`]. The attribute must be typed
    /// `AttrType::F32Arr`; otherwise [`build`][Self::build] returns
    /// [`BuildError::EmbeddingSlotNotF32Arr`].
    #[must_use]
    pub fn with_embedding_slot(mut self, kind: impl Into<String>, attr: impl Into<String>) -> Self {
        self.embedding_slot_name = Some((kind.into(), attr.into()));
        self
    }

    /// Consume the builder and return an [`Engine<T>`].
    ///
    /// # Errors
    ///
    /// Returns [`BuildError`] if:
    /// - No schema was provided ([`BuildError::MissingSchema`]).
    /// - [`with_embedding_slot`][Self::with_embedding_slot] was called with names that don't
    ///   resolve in the schema ([`BuildError::UnknownKind`], [`BuildError::UnknownAttr`],
    ///   [`BuildError::AttrNotInKind`], [`BuildError::EmbeddingSlotNotF32Arr`]).
    pub fn build(self) -> Result<Engine<T>, BuildError> {
        let schema = self.schema.ok_or(BuildError::MissingSchema)?;

        let embedding_slot = if let Some((ref kn, ref an)) = self.embedding_slot_name {
            let kind_id = schema
                .kind_names
                .get(kn)
                .ok_or_else(|| BuildError::UnknownKind(kn.clone()))?;
            let attr_id = schema
                .attr_names
                .get(an)
                .ok_or_else(|| BuildError::UnknownAttr(an.clone()))?;
            let slot = schema
                .slot_layout
                .resolve(kind_id, attr_id)
                .ok_or_else(|| BuildError::AttrNotInKind {
                    kind: kn.clone(),
                    attr: an.clone(),
                })?;
            if !matches!(slot.ty, AttrType::F32Arr) {
                return Err(BuildError::EmbeddingSlotNotF32Arr {
                    kind: kn.clone(),
                    attr: an.clone(),
                });
            }
            Some((kind_id, attr_id))
        } else {
            None
        };

        Ok(Engine {
            schema: schema.clone(),
            table: Arc::new(LocationTable::new(schema)),
            config: EngineConfig {
                clock: self.clock.unwrap_or_else(|| Arc::new(SystemClock)),
                decider: self.decider.unwrap_or_else(|| Arc::new(DefaultDecider)),
                default_vector_backend: self.default_vector_backend,
                n_shards: self.n_shards,
                embedding_slot,
            },
            metrics: Arc::new(crate::metrics::EngineMetrics::new()),
        })
    }
}

impl<T: Clone + Send + Sync + 'static> Default for EngineBuilder<T> {
    fn default() -> Self {
        Self::new()
    }
}