kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
//! End-to-end test of the vector linear backend: register two actions with
//! cosine scorers, write an embedding to the location, trigger, assert the
//! most-aligned action wins.

#![allow(clippy::unwrap_used)]
#![allow(clippy::panic)]

use kitt_score::event::{ActionIngest, AttrSet, KindRef, StateUpdate, Trigger};
use kitt_score::location::LocationDef;
use kitt_score::schema::attr::{AttrType, Value};
use kitt_score::{ActionId, Engine, Ingested, LocId, SchemaBuilder, ScorerSpec, VectorMetric};

// Small helper because `Ingested` isn't Debug (it contains `T`).
const fn ingested_debug<T>(i: &Ingested<T>) -> &'static str {
    match i {
        Ingested::Updated => "Updated",
        Ingested::Registered(_) => "Registered",
        Ingested::Decided(_) => "Decided",
        Ingested::NoWinner => "NoWinner",
        Ingested::ReloadInProgress => "ReloadInProgress",
        Ingested::Rejected(_) => "Rejected",
    }
}

#[test]
fn vector_cosine_scorer_picks_most_aligned_action() {
    let mut b = SchemaBuilder::new();
    let loc_kid = b.kind("loc", &[("embed", AttrType::F32Arr)]);
    let trig_kid = b.kind("k", &[]);
    let schema = b.build();
    let embed_aid = schema.attr_names.get("embed").unwrap();

    let engine: Engine<()> = Engine::builder()
        .schema(schema)
        .with_embedding_slot("loc", "embed")
        .build()
        .unwrap();

    // Declare location with permitted kinds.
    engine
        .upsert_location(&LocationDef {
            id: LocId(1),
            kinds_allowed: vec![loc_kid, trig_kid],
            ref_attrs: vec![],
        })
        .unwrap();

    // Write the location's embedding via StateUpdate.
    let embedding = [1.0_f32, 0.0, 0.0];
    let mut attrs = AttrSet::new();
    attrs.push(embed_aid, Value::F32Arr(&embedding));
    let _ = engine.ingest_update(StateUpdate {
        location: LocId(1),
        kind: KindRef::Id(loc_kid),
        attrs,
    });

    // Two actions with orthogonal target vectors.
    // Action 10: target [0, 1, 0] — orthogonal to [1, 0, 0], cosine = 0.
    // Action 11: target [1, 0, 0] — parallel to [1, 0, 0], cosine = 1.
    let _ = engine.ingest_action(ActionIngest {
        location: LocId(1),
        action_id: ActionId::from("10"),
        start: 0,
        end: i64::MAX,
        priority: 0,
        kind: KindRef::Id(trig_kid),
        scorer: ScorerSpec::Vector {
            target: &[0.0, 1.0, 0.0],
            metric: VectorMetric::Cosine,
        },
        payload: (),
        post: None,
    });
    let _ = engine.ingest_action(ActionIngest {
        location: LocId(1),
        action_id: ActionId::from("11"),
        start: 0,
        end: i64::MAX,
        priority: 0,
        kind: KindRef::Id(trig_kid),
        scorer: ScorerSpec::Vector {
            target: &[1.0, 0.0, 0.0],
            metric: VectorMetric::Cosine,
        },
        payload: (),
        post: None,
    });

    let t = Trigger {
        location: LocId(1),
        kind: KindRef::Id(trig_kid),
        attrs: AttrSet::new(),
    };
    match engine.ingest_trigger(t) {
        Ingested::Decided(d) => assert_eq!(d.action_id, ActionId::from("11")),
        other => panic!("expected Decided(11), got {}", ingested_debug(&other)),
    }
}

#[test]
fn vector_scorer_rejected_when_target_exceeds_max_embedding_dim() {
    use kitt_score::schema::attr::MAX_EMBEDDING_DIM;

    let mut b = SchemaBuilder::new();
    let loc_kid = b.kind("loc", &[("embed", AttrType::F32Arr)]);
    let trig_kid = b.kind("k", &[]);
    let schema = b.build();

    let engine: Engine<()> = Engine::builder()
        .schema(schema)
        .with_embedding_slot("loc", "embed")
        .build()
        .unwrap();

    engine
        .upsert_location(&LocationDef {
            id: LocId(1),
            kinds_allowed: vec![loc_kid, trig_kid],
            ref_attrs: vec![],
        })
        .unwrap();

    let oversized: Vec<f32> = vec![0.0; MAX_EMBEDDING_DIM + 1];
    let result = engine.ingest_action(ActionIngest {
        location: LocId(1),
        action_id: ActionId::from("1"),
        start: 0,
        end: i64::MAX,
        priority: 0,
        kind: KindRef::Id(trig_kid),
        scorer: ScorerSpec::Vector {
            target: &oversized,
            metric: VectorMetric::Dot,
        },
        payload: (),
        post: None,
    });
    assert!(
        matches!(result, Ingested::Rejected(_)),
        "expected Rejected for oversized target, got {}",
        ingested_debug(&result)
    );
}

#[test]
fn vector_scorer_rejected_without_embedding_slot() {
    let mut b = SchemaBuilder::new();
    let _ = b.kind("k", &[]);
    let schema = b.build();

    // Engine built WITHOUT with_embedding_slot.
    let engine: Engine<()> = Engine::builder().schema(schema).build().unwrap();
    engine
        .upsert_location(&LocationDef {
            id: LocId(1),
            kinds_allowed: vec![],
            ref_attrs: vec![],
        })
        .unwrap();

    let result = engine.ingest_action(ActionIngest {
        location: LocId(1),
        action_id: ActionId::from("1"),
        start: 0,
        end: i64::MAX,
        priority: 0,
        kind: KindRef::Name("k"),
        scorer: ScorerSpec::Vector {
            target: &[1.0, 0.0],
            metric: VectorMetric::Dot,
        },
        payload: (),
        post: None,
    });
    assert!(
        matches!(result, Ingested::Rejected(_)),
        "expected Rejected, got {}",
        ingested_debug(&result)
    );
}