# 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
```toml
[dependencies]
aria-core = "0.1.0"
```
---
## Quickstart
```rust
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.
```rust
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:
| 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.
```rust
pub trait Factor: Send + Sync {
fn name(&self) -> &str;
fn score(&self, item: &dyn Scoreable, state: &ProfileState, now: u64) -> f32;
}
```
**Built-in reference factors:**
| `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:**
```rust
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:
```rust
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:
```rust
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`:
```rust
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:
```rust
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
```rust
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
| ≤ 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