kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
//! Per-(`KindId`, `AttrId`) → byte-offset map inside a location's state buffer.
//!
//! When we receive a `StateUpdate` of kind K carrying attribute A, we need to
//! write its value at a precomputed offset. `SlotLayout` computes these
//! offsets once at schema-build time, honoring alignment requirements.
//!
//! Layout strategy: concatenate kinds in declaration order; within a kind,
//! place attributes by declaration order with padding to maintain alignment.
//! A given kind's attributes form a contiguous region so that kind-level
//! operations (e.g., "reset this kind") are cache-contiguous.
//!
//! There is no bin-packing optimization here; declaration order wins. This is
//! simple, deterministic, and makes the memory layout trivially inspectable
//! for debugging — we prefer clarity over saving a few bytes per location.

use crate::schema::attr::AttrType;
use crate::{AttrId, KindId};

/// Physical placement of a single attribute slot in the per-location buffer.
#[derive(Clone, Copy, Debug)]
pub struct Slot {
    /// Byte offset from buffer start.
    pub offset: u32,
    /// Attribute type.
    pub ty: AttrType,
}

/// Total byte-layout for one event kind.
#[derive(Clone, Debug)]
pub struct KindLayout {
    /// Offset where this kind's attribute block begins.
    pub region_start: u32,
    /// Length of the block (sum of slots + padding).
    pub region_len: u32,
    /// `slots[i]` for local index `i` in 0..n within the kind.
    pub slots: Vec<Slot>,
    /// `AttrId` → local index within this kind.
    pub attr_index: ahash::AHashMap<AttrId, usize>,
}

/// Full layout across all kinds. Owned by `Schema`.
#[derive(Clone, Debug)]
pub struct SlotLayout {
    /// Layout for each kind, indexed by `KindId`.
    pub kinds: Vec<KindLayout>,
    /// Total bytes required.
    pub total_bytes: u32,
}

impl SlotLayout {
    /// Resolve the absolute offset and type for a (`KindId`, `AttrId`) pair.
    #[must_use]
    pub fn resolve(&self, kind: KindId, attr: AttrId) -> Option<Slot> {
        let kl = self.kinds.get(usize::from(kind.0))?;
        let &local = kl.attr_index.get(&attr)?;
        let slot = kl.slots[local];
        Some(Slot {
            offset: kl.region_start + slot.offset,
            ty: slot.ty,
        })
    }
}

const fn align_up(off: u32, align: u32) -> u32 {
    (off + align - 1) & !(align - 1)
}

/// Builder used by `SchemaBuilder`.
pub(crate) struct SlotLayoutBuilder {
    kinds: Vec<KindLayout>,
    cursor: u32,
}

impl SlotLayoutBuilder {
    /// Create a new `SlotLayoutBuilder`.
    pub const fn new() -> Self {
        Self {
            kinds: Vec::new(),
            cursor: 0,
        }
    }

    /// Register a kind and its attribute list.
    pub fn push_kind(&mut self, attrs: &[(AttrId, AttrType)]) {
        let mut local_slots = Vec::with_capacity(attrs.len());
        let mut attr_index = ahash::AHashMap::with_capacity(attrs.len());
        let mut local_off: u32 = 0;
        for (i, &(aid, ty)) in attrs.iter().enumerate() {
            #[allow(clippy::cast_possible_truncation)]
            {
                local_off = align_up(local_off, ty.slot_align() as u32);
            }
            local_slots.push(Slot {
                offset: local_off,
                ty,
            });
            attr_index.insert(aid, i);
            #[allow(clippy::cast_possible_truncation)]
            {
                local_off += ty.slot_width() as u32;
            }
        }
        // Align the kind's region to the max alignment we'll observe — 8 bytes is enough today.
        self.cursor = align_up(self.cursor, 8);
        let region_start = self.cursor;
        let region_len = local_off;
        self.cursor += region_len;
        self.kinds.push(KindLayout {
            region_start,
            region_len,
            slots: local_slots,
            attr_index,
        });
    }

    /// Build the final `SlotLayout`.
    #[must_use]
    pub fn build(self) -> SlotLayout {
        SlotLayout {
            kinds: self.kinds,
            total_bytes: self.cursor,
        }
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]
    use super::*;

    #[test]
    fn aligns_within_and_across_kinds() {
        let mut b = SlotLayoutBuilder::new();
        // Kind 0: [F32 (4), Int (8, aligned to 8)]
        b.push_kind(&[(AttrId(0), AttrType::F32), (AttrId(1), AttrType::Int)]);
        // Kind 1: [Int, F32]
        b.push_kind(&[(AttrId(2), AttrType::Int), (AttrId(3), AttrType::F32)]);
        let layout = b.build();

        // Kind 0: offset 0 = F32 (4 bytes), then pad to 8, then Int (8 bytes) → 16 bytes.
        assert_eq!(layout.kinds[0].region_start, 0);
        assert_eq!(layout.kinds[0].region_len, 16);
        assert_eq!(layout.kinds[0].slots[0].offset, 0);
        assert_eq!(layout.kinds[0].slots[1].offset, 8);

        // Kind 1: starts at 16 (aligned), Int at 16, F32 at 24, total 16 + 12 = 28. Then total_bytes = 28.
        assert_eq!(layout.kinds[1].region_start, 16);
        assert_eq!(layout.kinds[1].slots[0].offset, 0);
        assert_eq!(layout.kinds[1].slots[1].offset, 8);
        assert_eq!(layout.total_bytes, 28);
    }

    #[test]
    fn resolve_picks_correct_slot() {
        let mut b = SlotLayoutBuilder::new();
        b.push_kind(&[(AttrId(0), AttrType::F32), (AttrId(1), AttrType::Int)]);
        let layout = b.build();
        let slot = layout.resolve(KindId(0), AttrId(1)).unwrap();
        assert_eq!(slot.offset, 8);
        assert_eq!(slot.ty, AttrType::Int);
    }

    #[test]
    fn resolve_returns_none_for_unknown() {
        let b = SlotLayoutBuilder::new();
        let layout = b.build();
        assert!(layout.resolve(KindId(0), AttrId(0)).is_none());
    }
}