aria-core 0.1.0

Generic adaptive sequencing engine — zero dependencies, domain-agnostic. Suggest(), feedback(). Works from item one.
Documentation
/// Manual end-to-end test — learning domain.
///
/// Simulates a user working through a curriculum of math + science items.
/// Prints skill progression and item selection across 15 interactions.
/// Run with: cargo run --example learning_simulation

use aria_core::{Engine, EngineConfig, Signal, Scoreable};
use aria_core::item::Item;
use aria_core::factor::{ChallengeFactor, SpacingFactor, CoverageFactor};
use aria_core::serialiser::Serialiser;

fn main() {
    println!("=== aria-core: Learning Domain Simulation ===\n");

    // --- Build engine ---
    let mut engine = Engine::new(EngineConfig {
        exploration_rate: 0.0, // deterministic for demo
        alpha: 0.1,            // faster skill movement so visible in 15 steps
    });

    // Register factors — caller's choice
    engine.add_factor(Box::new(ChallengeFactor::new(0.2)));
    engine.add_factor(Box::new(SpacingFactor::new(10))); // 10s interval for demo
    engine.add_factor(Box::new(CoverageFactor));
    engine.seed_rng(42);

    // Register items — caller's curriculum
    engine.add_items(vec![
        // Math track
        Item::new("counting",        0.05, "math"),
        Item::new("addition",        0.15, "math"),
        Item::new("multiplication",  0.30, "math"),
        Item::new("fractions",       0.45, "math"),
        Item::new("algebra_basics",  0.60, "math"),
        Item::new("quadratics",      0.75, "math")
            .with_prereqs(vec!["algebra_basics".into()]),
        Item::new("calculus_intro",  0.90, "math")
            .with_prereqs(vec!["quadratics".into()]),

        // Science track (no prereqs — runs in parallel)
        Item::new("sci_observation", 0.10, "science"),
        Item::new("sci_hypothesis",  0.25, "science"),
        Item::new("sci_experiment",  0.50, "science"),
        Item::new("sci_analysis",    0.70, "science"),
        Item::new("sci_research",    0.90, "science"),
    ]).unwrap();

    println!("Items registered: {}", engine.item_count());
    println!("Factors registered: {}\n", engine.factor_count());

    println!("{:<5} {:<22} {:<10} {:<8} {:<8} {:<10}",
        "Step", "Item", "Category", "Success", "Effort", "Skill");
    println!("{}", "-".repeat(70));

    // --- Simulate 15 interactions ---
    // Alternates easy successes and a few failures to show adaptation
    let interactions: Vec<(bool, f32)> = vec![
        (true,  0.8),  // 1  hard success
        (true,  0.3),  // 2  easy success → optimism up
        (true,  0.2),  // 3  easy
        (false, 0.9),  // 4  failure → optimism eases
        (true,  0.5),  // 5
        (true,  0.4),  // 6
        (true,  0.2),  // 7  easy → optimism climbs
        (true,  0.3),  // 8
        (false, 0.7),  // 9  failure
        (true,  0.5),  // 10
        (true,  0.4),  // 11
        (true,  0.3),  // 12
        (true,  0.2),  // 13
        (true,  0.5),  // 14
        (true,  0.3),  // 15
    ];

    for (step, (success, effort)) in interactions.iter().enumerate() {
        let item = engine.suggest("alice").unwrap();
        let item_id = item.id().to_string();
        let category = item.category().to_string();

        engine.feedback("alice", &item_id, Signal::new(*success, *effort)).unwrap();

        let state = engine.get_state("alice").unwrap();
        println!("{:<5} {:<22} {:<10} {:<8} {:<8.2} {:<.4}",
            step + 1,
            item_id,
            category,
            if *success { "" } else { "" },
            effort,
            state.skill,
        );
    }

    // --- Show final state ---
    let state = engine.get_state("alice").unwrap();
    println!("\n=== Final State ===");
    println!("Skill:             {:.4}", state.skill);
    println!("Optimism bias:     {:.4}", state.optimism_bias);
    println!("Target:            {:.4}", state.target());
    println!("Interactions:      {}", state.interaction_count);
    println!("Resolved items:    {:?}", state.resolved_set);

    println!("\n=== Category Coverage ===");
    let mut cats: Vec<_> = state.category_count.iter().collect();
    cats.sort_by_key(|(k, _)| k.as_str());
    for (cat, count) in cats {
        println!("  {:<12} → {} interactions", cat, count);
    }

    // --- Serialise / deserialise round-trip ---
    println!("\n=== Serialisation Round-Trip ===");
    let encoded = Serialiser::encode(state);
    println!("Encoded keys: {}", encoded.len());
    let decoded = Serialiser::decode(&encoded).unwrap();
    assert!((decoded.skill - state.skill).abs() < 1e-5, "skill mismatch after round-trip");
    assert_eq!(decoded.interaction_count, state.interaction_count);
    println!("Round-trip: ✓ skill={:.4} interactions={}", decoded.skill, decoded.interaction_count);

    // --- Prereq demonstration ---
    println!("\n=== Prerequisite Gating ===");
    println!("calculus_intro requires: quadratics → algebra_basics");
    let can_see_calculus = state.resolved_set.contains("quadratics");
    println!("User resolved quadratics: {}", can_see_calculus);
    println!("(calculus_intro will unlock once quadratics is resolved)");

    println!("\n=== Custom Domain Example (ecommerce) ===");
    demo_ecommerce();
}

/// Quick demo showing the same engine used for a completely different domain.
fn demo_ecommerce() {
    use aria_core::factor::ChallengeFactor;

    struct BudgetFactor;
    impl aria_core::factor::Factor for BudgetFactor {
        fn name(&self) -> &str { "budget" }
        fn score(&self, item: &dyn Scoreable, state: &aria_core::ProfileState, _now: u64) -> f32 {
            // score_proxy = price_ratio (0=free, 1=most expensive)
            // ProfileState.skill = budget_willingness
            let target = state.target();
            let diff = item.score_proxy() - target;
            (-diff * diff / 0.08).exp() // bandwidth=0.2
        }
    }

    let mut engine = Engine::new(EngineConfig {
        exploration_rate: 0.0,
        alpha: 0.1,
    });
    engine.add_factor(Box::new(BudgetFactor));
    engine.add_factor(Box::new(ChallengeFactor::new(0.25))); // reused as price-fit
    engine.seed_rng(1);

    engine.add_items(vec![
        Item::new("budget_headphones",  0.1, "audio"),
        Item::new("mid_headphones",     0.4, "audio"),
        Item::new("premium_headphones", 0.9, "audio"),
        Item::new("budget_speaker",     0.15, "audio"),
        Item::new("smartwatch_basic",   0.3, "wearables"),
        Item::new("smartwatch_pro",     0.8, "wearables"),
    ]).unwrap();

    // User with mid budget
    let mut state = aria_core::ProfileState::new();
    state.skill = 0.35; // budget willingness
    engine.load_state("shopper", state);

    print!("Suggestions for mid-budget shopper: ");
    for _ in 0..3 {
        let item = engine.suggest("shopper").unwrap();
        let id = item.id().to_string();
        print!("{} ", id);
        engine.feedback("shopper", &id, Signal::new(true, 0.5)).unwrap();
    }
    println!();
}