kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::float_cmp,
    clippy::if_not_else
)]

//! Stack machine executing compiled predicate programs.
//!
//! The stack is a fixed-size `SmallVec` sized from `Program::max_stack`. All
//! values are `f32`; booleans are 0.0 / 1.0.
//!
//! Performance notes:
//! - `#[inline(always)]` on `pop`/`push`/`peek` to ensure they vanish.
//! - The big `match` is the hot loop. Rust's codegen for exhaustive matches
//!   over a C-style enum is a jump table at -C opt-level=3, which is what
//!   the release profile uses.
//! - We deliberately avoid branch-predictor-unfriendly patterns like
//!   early-exit on type errors; all error-like conditions (div-by-zero) are
//!   handled by producing a sentinel value (0.0 for div-by-zero), not by
//!   panicking. Panicking in scoring is a crate invariant (Conventions #9).

use super::bytecode::{Op, Program};
use crate::location::state::LocationView;
use smallvec::SmallVec;

/// Executes a compiled predicate program against a location state, returning the
/// result as an `f32`. If the program is unbalanced (crate bug), returns 0.0 rather
/// than panicking.
#[must_use]
pub fn run(prog: &Program, view: &LocationView<'_>) -> f32 {
    let mut st: SmallVec<[f32; 16]> = SmallVec::with_capacity(prog.max_stack);
    for op in &prog.ops {
        match *op {
            Op::PushF32(v) => st.push(v),
            Op::LoadI64 { offset } => st.push(view.read_i64(offset as usize) as f32),
            Op::LoadF32 { offset } => st.push(view.read_f32(offset as usize)),
            Op::LoadF64 { offset } => st.push(view.read_f64(offset as usize) as f32),
            Op::LoadU32 { offset } => {
                #[allow(clippy::cast_precision_loss)]
                {
                    st.push(view.read_u32(offset as usize) as f32);
                }
            }
            Op::Neg => {
                let a = pop(&mut st);
                st.push(-a);
            }
            Op::Not => {
                let a = pop(&mut st);
                st.push(if a == 0.0 { 1.0 } else { 0.0 });
            }
            Op::Add => binop(&mut st, |a, b| a + b),
            Op::Sub => binop(&mut st, |a, b| a - b),
            Op::Mul => binop(&mut st, |a, b| a * b),
            Op::Div => binop(&mut st, |a, b| if b == 0.0 { 0.0 } else { a / b }),
            Op::Lt => binop(&mut st, |a, b| if a < b { 1.0 } else { 0.0 }),
            Op::Le => binop(&mut st, |a, b| if a <= b { 1.0 } else { 0.0 }),
            Op::Gt => binop(&mut st, |a, b| if a > b { 1.0 } else { 0.0 }),
            Op::Ge => binop(&mut st, |a, b| if a >= b { 1.0 } else { 0.0 }),
            Op::Eq => binop(&mut st, |a, b| if a == b { 1.0 } else { 0.0 }),
            Op::Ne => binop(&mut st, |a, b| if a != b { 1.0 } else { 0.0 }),
            Op::And => binop(&mut st, |a, b| if a != 0.0 && b != 0.0 { 1.0 } else { 0.0 }),
            Op::Or => binop(&mut st, |a, b| if a != 0.0 || b != 0.0 { 1.0 } else { 0.0 }),
            Op::MinA => binop(&mut st, f32::min),
            Op::MaxA => binop(&mut st, f32::max),
            Op::Abs => {
                let a = pop(&mut st);
                st.push(a.abs());
            }
        }
    }
    // If the compiler produced a balanced program, exactly one value remains.
    // Otherwise (crate bug), return 0.0 rather than panic.
    st.last().copied().unwrap_or(0.0)
}

#[inline(always)]
#[allow(clippy::inline_always)]
fn pop(st: &mut SmallVec<[f32; 16]>) -> f32 {
    st.pop().unwrap_or(0.0)
}

#[inline(always)]
#[allow(clippy::inline_always)]
fn binop<F: Fn(f32, f32) -> f32>(st: &mut SmallVec<[f32; 16]>, f: F) {
    let b = pop(st);
    let a = pop(st);
    st.push(f(a, b));
}

#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
    use super::super::{parser::Parser, typecheck::compile};
    use super::*;
    use crate::event::{AttrSet, KindRef};
    use crate::location::state::LocationState;
    use crate::schema::attr::Value;
    use crate::schema::{AttrType, SchemaBuilder};
    use smallvec::smallvec;

    #[allow(clippy::unwrap_used)]
    fn setup(expr: &str, male: f32, dwell: i64) -> (LocationState<()>, Program) {
        let mut b = SchemaBuilder::new();
        let _ = b.kind(
            "audience",
            &[("male_frac", AttrType::F32), ("dwell", AttrType::Int)],
        );
        let schema = b.build();
        let mut st: LocationState<()> = LocationState::new(schema.clone());
        let kid = schema.kind("audience").unwrap();
        let attrs = AttrSet {
            entries: smallvec![
                (schema.attr("male_frac").unwrap(), Value::F32(male)),
                (schema.attr("dwell").unwrap(), Value::Int(dwell)),
            ],
        };
        st.apply_update(KindRef::Id(kid), &attrs);
        let ast = Parser::new(expr).parse().unwrap();
        let prog = compile(&ast, &schema).unwrap();
        (st, prog)
    }

    #[test]
    fn arithmetic() {
        let (st, p) = setup("$audience.male_frac + 1.0", 0.25, 0);
        assert!((run(&p, &st.view()) - 1.25).abs() < 1e-6);
    }

    #[test]
    fn comparison_returns_boolean_as_f32() {
        let (st, p) = setup("$audience.male_frac > 0.5", 0.7, 0);
        assert_eq!(run(&p, &st.view()), 1.0);
        let (st, p) = setup("$audience.male_frac > 0.5", 0.3, 0);
        assert_eq!(run(&p, &st.view()), 0.0);
    }

    #[test]
    fn logical_short_operands() {
        let (st, p) = setup("$audience.male_frac > 0.5 && $audience.dwell > 10", 0.7, 12);
        assert_eq!(run(&p, &st.view()), 1.0);
    }

    #[test]
    fn division_by_zero_is_zero_not_panic() {
        let (st, p) = setup("1.0 / ($audience.male_frac - $audience.male_frac)", 0.0, 0);
        assert_eq!(run(&p, &st.view()), 0.0);
    }
}