aria-core 0.1.0

Generic adaptive sequencing engine — zero dependencies, domain-agnostic. Suggest(), feedback(). Works from item one.
Documentation
  • Coverage
  • 53.1%
    60 out of 113 items documented1 out of 60 items with examples
  • Size
  • Source code size: 67.72 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.57 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 3s Average build duration of successful builds.
  • all releases: 3s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • Repository
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • JayMGurav

aria-core

Generic adaptive sequencing engine. Zero dependencies. Domain-agnostic.

Given a pool of items and a stream of user feedback, aria-core selects the best next item for each user at any point in time — from the very first interaction, with no pre-training required.

Domain is entirely caller-defined. The engine ships no built-in domains. You define items, scoring factors, and optionally state update logic. The same engine works for learning platforms, product recommendation, travel suggestions, content feeds, or any sequencing problem.


Install

[dependencies]
aria-core = "0.1.0"

Quickstart

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

// 1. Build engine
let mut engine = Engine::new(EngineConfig::default());

// 2. Register factors — built-ins or your own
engine.add_factor(Box::new(ChallengeFactor::default()));
engine.add_factor(Box::new(SpacingFactor::default()));
engine.add_factor(Box::new(CoverageFactor));

// 3. Register items — any domain
engine.add_items(vec![
    Item::new("algebra_basics", 0.2, "algebra"),
    Item::new("quadratic_eq",   0.6, "algebra"),
    Item::new("integration",    0.9, "calculus")
        .with_prereqs(vec!["quadratic_eq".into()]),
]).unwrap();

// 4. Suggest
let item = engine.suggest("user_42").unwrap();

// 5. Report feedback
engine.feedback("user_42", item.id(), Signal::new(true, 0.4)).unwrap();

Core Concepts

Items

Items implement the Scoreable trait. Use the built-in Item struct or implement Scoreable on your own type.

pub trait Scoreable: Send + Sync {
    fn id(&self) -> &str;
    fn score_proxy(&self) -> f32;   // normalised 0–1, caller defines semantics
    fn category(&self) -> &str;     // for coverage balancing
    fn prerequisites(&self) -> &[String];  // optional gating
    fn metadata(&self) -> &HashMap<String, String>;
}

score_proxy semantics are caller-defined:

Domain score_proxy meaning
Learning Difficulty (0=easy, 1=hard)
Ecommerce Price ratio
Travel Remoteness / adventure level
Content Reading level / complexity

Factors

Factors implement Factor and return a score in [0.0, 1.0]. The engine multiplies all factor scores — a near-zero from any single factor suppresses the item.

pub trait Factor: Send + Sync {
    fn name(&self) -> &str;
    fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
}

Built-in reference factors:

Factor What it does
ChallengeFactor Gaussian centred at skill + optimism_bias. Items too easy or too hard score low.
SpacingFactor Forgetting curve on time since last seen. Recently seen items score low.
CoverageFactor Inverse topic frequency. Under-represented categories score higher.

Custom factor example:

struct RecencyBoostFactor;

impl Factor for RecencyBoostFactor {
    fn name(&self) -> &str { "recency_boost" }

    fn score(&self, item: &dyn Scoreable, state: &ProfileState, _now: u64) -> f32 {
        // Boost items marked "new" in metadata
        if item.metadata().get("is_new").map(|v| v == "true").unwrap_or(false) {
            1.5_f32.min(1.0) // capped at 1.0 — factors are [0,1]
        } else {
            0.8
        }
    }
}

engine.add_factor(Box::new(RecencyBoostFactor));

Signal

Feedback reported after each interaction:

Signal::new(success: bool, effort: f32)  // effort: 0.0=effortless, 1.0=max friction

Semantics are caller-defined. The default StateUpdater derives a performance score:

performance = success × (0.5 + 0.5 × (1 - effort))
skill       = skill + alpha × (performance - skill)

ProfileState

User state the engine maintains per user:

pub struct ProfileState {
    pub skill: f32,                          // 0–1, caller-defined semantics
    pub optimism_bias: f32,                  // engine always targets slightly above skill
    pub last_seen: HashMap<String, u64>,     // item_id → unix timestamp
    pub category_count: HashMap<String, u32>,
    pub resolved_set: HashSet<String>,       // items successfully completed
    pub interaction_count: u64,
    pub extended: HashMap<String, f32>,      // caller-defined numeric state
    pub extended_str: HashMap<String, String>, // caller-defined string state
}

Persistence

The engine is in-memory. Persist state with the Serialiser:

use aria_core::serialiser::Serialiser;

// Save
let encoded: HashMap<String, String> = Serialiser::encode(engine.get_state("user").unwrap());
// → store encoded as JSON, in a DB row, cookie, etc.

// Restore on next session
let state = Serialiser::decode(&encoded).unwrap();
engine.load_state("user", state);

Custom StateUpdater

Override the default skill update logic entirely:

use aria_core::updater::StateUpdater;

struct MyUpdater;

impl StateUpdater for MyUpdater {
    fn update(&self, state: &ProfileState, item: &dyn Scoreable, signal: &Signal, now: u64) -> ProfileState {
        let mut next = state.clone();
        // your logic here
        next
    }
}

engine.set_updater(Box::new(MyUpdater));

Scoring Formula

score(item) = Π factors(item, state) × (1 + noise)

challenge = exp(-(score_proxy - (skill + optimism))² / (2 × bandwidth²))
spacing   = 1 - exp(-(elapsed / optimal_interval))
coverage  = 1 / (1 + category_count[category] / mean_category_count)
noise     = xorshift64() × exploration_rate

Multiplicative pipeline: all factors must agree. A near-zero in any factor zeroes the result.


Configuration

EngineConfig {
    exploration_rate: f32,  // noise fraction, default 0.05. Set 0.0 for deterministic.
    alpha: f32,             // skill learning rate, default 0.05
}

ChallengeFactor::new(bandwidth: f32)        // default 0.2
SpacingFactor::new(optimal_interval_secs: u64)  // default 86400 (24h)

Performance

Item count Selection strategy
≤ 500 Linear scan (cache-friendly)
> 500 Max-heap O(log n)

State updates are O(1). No allocations in the hot path beyond HashMap operations.


Examples

cargo run --example learning_simulation

License

MIT