aria-engine

How ARIA works

Adaptive Readiness Index Algorithm — a complete guide from first principles to implementation.


Explain like I'm 5

The short version

Imagine a teacher who always picks the perfect next question for you — not too easy, not too hard, and not one they just asked five seconds ago. That's ARIA.

It keeps a small model of you — how capable you are right now — and uses it to score every available item. The highest-scoring item wins. After you answer, it updates your model. Repeat.

🎯
Challenge fit
Items that are slightly harder than what you can currently do score highest. Too easy or too hard both score low.
⏱️
Spacing fit
Items you haven't seen in a while score higher. Something you just answered scores near zero — give it time.
🗂️
Coverage fit
Topics you've barely touched score higher. Prevents you drilling the same category over and over while others go cold.
🌱
Optimism floor
Even after failure, ARIA still aims above your current level. It never gives up on your growth. The floor is hardcoded — non-negotiable.

All three scores are multiplied together — not added. That means all three must agree. A perfect-difficulty item seen 10 seconds ago still scores near zero. One weak signal kills the suggestion.


Core concepts

What the algorithm tracks

Every user has a ProfileState — a small data structure ARIA updates after each interaction. You never touch it directly. The engine manages it.

Field What it means Range
skill Current estimated ability. Starts at 0, grows toward 1 as the user succeeds. The engine never lets it go backwards on failure — it just moves toward 0 more slowly. 0.0 – 1.0
optimism_bias How far above skill the engine targets. Grows when the user finds things easy. Shrinks slightly on failure. Has a hard floor of 0.05 — always targeting growth. 0.05 – 0.35
last_seen Timestamp of last interaction per item. Used by the spacing factor to surface items that are due for review. unix timestamp
category_count How many times each category has been interacted with. Used by coverage factor to balance across topics. integer per category
resolved_set Items successfully completed. Used for prerequisite gating — advanced items stay locked until their dependencies are resolved. set of item IDs

Each item has three required fields the engine uses for scoring — everything else is caller-defined metadata.

FieldWhat it means
score_proxy Normalised difficulty or complexity, 0–1. What this means is your call: difficulty in learning, price ratio in ecommerce, remoteness in travel.
category A string grouping this item. Used for coverage balancing. "algebra", "electronics", "beach" — whatever fits your domain.
prerequisites IDs of items that must be resolved before this one is eligible. Optional. The engine validates these for cycles on registration.

The formula

Readiness score

Every eligible item gets a readiness score. The item with the highest score wins. The formula is multiplicative — all factors must agree.

readiness(item) =
challenge_fit × spacing_fit × coverage_fit × (1 + noise)
challenge = exp( −(score_proxy − target)² / 2σ² )
where target = skill + optimism_bias, σ = bandwidth (default 0.2)

spacing   = 1 − exp( −elapsed / optimal_interval )
where elapsed = now − last_seen (seconds)

coverage  = 1 / ( 1 + count[category] / mean_count )

noise      = xorshift64() × exploration_rate (default 0.05)

Why multiplicative? An item that's perfect difficulty but was seen 30 seconds ago still scores near zero. Additive scoring would still recommend it. Multiplicative means every dimension must independently justify the suggestion.

Why the noise? Without any randomness, the same item wins every time from identical state. A small noise term (5% by default) breaks ties and gives slight exploration — the system occasionally surfaces a non-obvious item. Set exploration_rate = 0 for fully deterministic behaviour, useful in tests.


The three factors

Challenge, spacing, coverage

🎯 Challenge fit
Gaussian bell curve centred at the user's growth target. Items at exactly skill + optimism score 1.0. Items far above or below score near zero. The bandwidth parameter controls how forgiving this is — wider means more variety, narrower means stricter gating.
exp(−(d − target)² / 2σ²)
⏱️ Spacing fit
Based on the forgetting curve. An item never seen scores 1.0. An item seen 1 second ago scores near 0. An item not seen for longer than optimal_interval approaches 1.0 again — it's due for review. Interval is caller-configurable: hours for learning, days for ecommerce, months for travel.
1 − exp(−elapsed / interval)
🗂️ Coverage fit
Inverse of how over-represented a category is relative to average. A category the user has never touched scores 1.0. A category they've seen three times more than average scores near 0.25. This prevents the engine from drilling one area while others go cold.
1 / (1 + count / mean_count)

These are the built-in reference factors. They're a starting point, not a constraint. You can add your own factors by implementing the Factor trait (Rust), a class with a score() method (Python / TypeScript), and registering them with engine.add_factor(). The pipeline multiplies all factors together regardless of how many you register.

Challenge factor — live preview

Adjust skill and optimism to see the Gaussian shift. The peak is always at skill + optimism.

User skill 0.40
Optimism bias 0.15
Bandwidth (σ) 0.20
Challenge factor Gaussian curve.

Item difficulty (x) → challenge score (y). Peak marks the growth target.

Spacing factor — time since last seen

The score starts near zero right after seeing an item, then recovers toward 1.0 as time passes.

Optimal interval (hrs) 24h
Spacing factor forgetting curve.

Time elapsed since last seen (hours) → spacing score. Red marker = optimal interval.


Live demo

Watch it sequence

A fixed set of items across two categories. Each step suggests the best item, you pick the outcome, and the user model updates. Watch skill climb and coverage balance itself out.

User profile

0.00
skill
0.10
optimism bias
0.10
target

Item scores (right now)


State updates

After every interaction — O(1)

When you call feedback(), the engine runs these rules to produce a new ProfileState. The old state is never mutated — each update is a pure function, making state easy to persist, snapshot, and restore.

performance = success × (0.5 + 0.5 × (1 − effort))
Easy success → 1.0 · Hard success → 0.5 · Failure → 0.0

skill = skill + α × (performance − skill)
α = learning rate, default 0.05. Exponential moving average.
ConditionOptimism updateWhy
Success + effort < 0.4 optimism += 0.02 Easy win — push the target higher
Failure (any effort) optimism −= 0.01 Ease back slightly — but never to zero
Success + effort ≥ 0.4 unchanged Good challenge — target is right
Always clamped to [0.05, 0.35] The floor (0.05) is the philosophical core — engine always believes in growth

The optimism floor is hardcoded and non-configurable. It is not a default — it is an invariant. After any number of failures, the engine still targets at least 5% above where the user is. The algorithm is built on the assumption that growth is always possible.


Prior art

What ARIA is built on

ARIA synthesises ideas from several established fields. None of these were sufficient on their own — that's why ARIA exists.

Spaced repetition
Forgetting curve
Ebbinghaus (1885) showed memory decays exponentially with time. SM-2 (Anki) applies this to flashcards. ARIA's spacing factor is a direct application — but generalised beyond memory to any domain.
Cognitive science
Zone of proximal development
Vygotsky's ZPD: learning is most effective when the challenge is just beyond current ability — not too easy, not too hard. The challenge factor's Gaussian centred at skill + optimism directly implements this.
Control theory
Exponential moving average
The skill update rule (skill += α × (perf − skill)) is an EMA — a standard signal processing primitive. It gives recent interactions more weight while smoothing over noise.
Multi-armed bandit
Exploration vs exploitation
The noise term borrows the explore/exploit framing from bandit algorithms. A small random perturbation prevents the system from locking onto a local optimum — occasionally surfacing unexpected items.
Information retrieval
Inverse frequency weighting
TF-IDF and similar retrieval techniques down-weight frequent items. ARIA's coverage factor applies the same logic to categories — inverse frequency ensures balance across topics.
ARIA's contribution
Domain-agnostic composition
None of the above generalise cleanly beyond their original domain. ARIA's contribution is composing them into a single multiplicative pipeline with no domain assumptions — just items, scores, and feedback.

API

Language-agnostic surface

The same API in every language. The mental model transfers completely across Rust, Python, and TypeScript.

// 1. Build the engine
let mut engine = Engine::new(EngineConfig {
    exploration_rate: 0.05,
    alpha: 0.05,
});

// 2. Register factors — your domain, your logic
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("intro", 0.2, "basics"),
    Item::new("advanced", 0.7, "basics")
        .with_prereqs(vec!["intro".into()]),
]).unwrap();

// 4. Suggest → feedback loop
let item = engine.suggest("user_1").unwrap();
engine.feedback("user_1", item.id(), Signal::new(true, 0.4)).unwrap();

// 5. Persist state
let snapshot = Serialiser::encode(engine.get_state("user_1").unwrap());
// store snapshot to DB / Redis / cookie…
let restored = Serialiser::decode(&snapshot).unwrap();
engine.load_state("user_1", restored);
# 1. Build the engine
engine = Engine(exploration_rate=0.05, alpha=0.05)

# 2. Register factors
engine.add_factor(ChallengeFactor())
engine.add_factor(SpacingFactor())
engine.add_factor(CoverageFactor())

# 3. Register items
engine.add_items([
    Item(id="intro",    score_proxy=0.2, category="basics"),
    Item(id="advanced", score_proxy=0.7, category="basics",
         prerequisites=["intro"]),
])

# 4. Suggest → feedback loop
item = engine.suggest("user_1")
engine.feedback("user_1", item.id, Signal(success=True, effort=0.4))

# 5. Persist state
snapshot = engine.get_state("user_1").to_dict()
# store to DB / Redis / JSON file…
engine.load_state("user_1", ProfileState.from_dict(snapshot))
// 1. Build the engine
const engine = new Engine({ explorationRate: 0.05, alpha: 0.05 });

// 2. Register factors
engine.addFactor(new ChallengeFactor());
engine.addFactor(new SpacingFactor());
engine.addFactor(new CoverageFactor());

// 3. Register items
engine.addItems([
  { id: "intro",    scoreProxy: 0.2, category: "basics" },
  { id: "advanced", scoreProxy: 0.7, category: "basics",
    prerequisites: ["intro"] },
]);

// 4. Suggest → feedback loop
const item = engine.suggest("user_1");
engine.feedback("user_1", item.id, { success: true, effort: 0.4 });

// 5. Persist state
const snapshot = engine.getState("user_1").toJSON();
// store to localStorage / DB / cookie…
engine.loadState("user_1", ProfileState.fromJSON(snapshot));

Custom factor example

Implement Factor to inject any domain logic into the pipeline. The engine multiplies your factor's score with the built-ins automatically.

struct RecencyBoostFactor;

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

    fn score(&self, item: &dyn Scoreable, _state: &ProfileState, _now: u64) -> f32 {
        if item.metadata().get("is_new") == Some(&"true".to_string()) {
            1.0
        } else {
            0.7
        }
    }
}

engine.add_factor(Box::new(RecencyBoostFactor));
class RecencyBoostFactor:
    def name(self) -> str:
        return "recency_boost"

    def score(self, item, state, now: int) -> float:
        if item.metadata.get("is_new") == "true":
            return 1.0
        return 0.7

engine.add_factor(RecencyBoostFactor())
class RecencyBoostFactor implements Factor {
  name = "recency_boost";

  score(item: Scoreable, state: ProfileState, now: number): number {
    return item.metadata["is_new"] === "true" ? 1.0 : 0.7;
  }
}

engine.addFactor(new RecencyBoostFactor());

Performance

Complexity guarantees

O(log n)
suggest() with >500 items
Max-heap selection
O(n)
suggest() with ≤500 items
Linear scan — cache friendly
O(1)
feedback() state update
HashMap operations only

The selector automatically switches strategy at 500 items. Below the threshold a linear scan is faster in practice due to cache locality. Above it, the engine builds a max-heap and returns the winner in O(log n).

State updates touch only HashMaps — no allocations in the hot path beyond standard map operations. The prerequisite graph is validated once on registration (topological sort, O(n + e)) and never re-validated at query time.

Space complexity: O(I + T) where I = item count and T = total unique categories across all users. Per-user state grows with the number of items interacted with, not the total item pool.