aria-core 0.1.0

Generic adaptive sequencing engine — zero dependencies, domain-agnostic. Suggest(), feedback(). Works from item one.
Documentation
# 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:

| 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.

```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:**

| 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:**

```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

| 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