kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
//! Scenario test for the audience-targeting walkthrough documented in
//! `wiki/Scenarios/Audience Bytecode Matching.md`.
//!
//! The goal is to exercise the full predicate-bytecode pipeline and the public
//! ingest API semantics with a concrete business mapping:
//! - Action 1 targets young men aged 20-30.
//! - Action 2 targets adult women aged 20-40.
//! - State updates overwrite the current observed audience mix.
//! - Triggers score the currently registered live actions against the latest
//!   stored state and return the winner.

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

use kitt_score::clock::TestClock;
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};
use smallvec::smallvec;

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 predicate_bytecode_matches_age_gender_audience_groups() {
    // 1. Declare the schema that translates business audience buckets into
    // typed engine attributes.
    let mut b = SchemaBuilder::new();
    let kid_audience = b.kind(
        "audience",
        &[
            ("male_20_30_frac", AttrType::F32),
            ("female_20_40_frac", AttrType::F32),
            ("other_frac", AttrType::F32),
        ],
    );
    let schema = b.build();
    let aid_male_20_30 = schema.attr("male_20_30_frac").unwrap();
    let aid_female_20_40 = schema.attr("female_20_40_frac").unwrap();
    let aid_other = schema.attr("other_frac").unwrap();

    // 2. Build the engine with a deterministic clock so the action windows are stable.
    let clock = TestClock::at(10_000);
    let engine: Engine<&'static str> = Engine::builder()
        .schema(schema)
        .clock_arc(clock.clone())
        .build()
        .unwrap();

    // 3. Provision one location. This creates the actual mutable byte buffer.
    let loc = LocId(7);
    engine
        .upsert_location(&LocationDef {
            id: loc,
            kinds_allowed: vec![kid_audience],
            ref_attrs: vec![],
        })
        .unwrap();

    // 4. Before any actions exist, a trigger produces NoWinner.
    let empty_trigger = Trigger {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet::new(),
    };
    assert!(matches!(
        engine.ingest_trigger(empty_trigger),
        Ingested::NoWinner
    ));

    // 5. Register the two campaigns. Each predicate is compiled now, not later.
    let reg_1 = engine.ingest_action(ActionIngest {
        location: loc,
        action_id: ActionId::from("101"),
        start: 9_000,
        end: 20_000,
        priority: 0,
        kind: KindRef::Id(kid_audience),
        scorer: ScorerSpec::Predicate("$audience.male_20_30_frac"),
        payload: "creative-young-male",
        post: None,
    });
    assert!(matches!(reg_1, Ingested::Registered(id) if id == ActionId::from("101")));

    let reg_2 = engine.ingest_action(ActionIngest {
        location: loc,
        action_id: ActionId::from("202"),
        start: 9_000,
        end: 20_000,
        priority: 0,
        kind: KindRef::Id(kid_audience),
        scorer: ScorerSpec::Predicate("$audience.female_20_40_frac"),
        payload: "creative-female-adult",
        post: None,
    });
    assert!(matches!(reg_2, Ingested::Registered(id) if id == ActionId::from("202")));

    // 6. First camera observation: the audience is mostly men aged 20-30.
    let first_update = StateUpdate {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet {
            entries: smallvec![
                (aid_male_20_30, Value::F32(0.62)),
                (aid_female_20_40, Value::F32(0.21)),
                (aid_other, Value::F32(0.17)),
            ],
        },
    };
    assert!(matches!(
        engine.ingest_update(first_update),
        Ingested::Updated
    ));

    // 7. Trigger: the young-male action should win because 0.62 > 0.21.
    let first_trigger = Trigger {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet::new(),
    };
    match engine.ingest_trigger(first_trigger) {
        Ingested::Decided(outcome) => {
            assert_eq!(outcome.action_id, ActionId::from("101"));
            assert_eq!(outcome.payload, "creative-young-male");
            assert!((outcome.score.score - 0.62).abs() < 1e-6);
            assert_eq!(outcome.score.priority, 0);
        }
        other => panic!("expected first decision, got {}", ingested_debug(&other)),
    }

    // 8. Second observation overwrites the same stored slots for the same kind.
    // Now adult women dominate the measured audience.
    let second_update = StateUpdate {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet {
            entries: smallvec![
                (aid_male_20_30, Value::F32(0.18)),
                (aid_female_20_40, Value::F32(0.67)),
                (aid_other, Value::F32(0.15)),
            ],
        },
    };
    assert!(matches!(
        engine.ingest_update(second_update),
        Ingested::Updated
    ));

    // 9. Trigger again: same actions, new state snapshot, opposite winner.
    let second_trigger = Trigger {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet::new(),
    };
    match engine.ingest_trigger(second_trigger) {
        Ingested::Decided(outcome) => {
            assert_eq!(outcome.action_id, ActionId::from("202"));
            assert_eq!(outcome.payload, "creative-female-adult");
            assert!((outcome.score.score - 0.67).abs() < 1e-6);
            assert_eq!(outcome.score.priority, 0);
        }
        other => panic!("expected second decision, got {}", ingested_debug(&other)),
    }

    // 10. Advance time beyond both actions' end time. Expiry is lazy and is
    // applied on trigger, so the next trigger should return NoWinner.
    clock.set(25_000);
    let expired_trigger = Trigger {
        location: loc,
        kind: KindRef::Id(kid_audience),
        attrs: AttrSet::new(),
    };
    assert!(matches!(
        engine.ingest_trigger(expired_trigger),
        Ingested::NoWinner
    ));
}