Adaptive Readiness Index Algorithm — a complete guide from first principles to implementation.
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.
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.
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.
| Field | What 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. |
Every eligible item gets a readiness score. The item with the highest score wins. The formula is multiplicative — all factors must agree.
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.
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.optimal_interval approaches 1.0 again — it's due for review. Interval is caller-configurable: hours for learning, days for ecommerce, months for travel.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.
Adjust skill and optimism to see the Gaussian shift. The peak is always at skill + optimism.
Item difficulty (x) → challenge score (y). Peak marks the growth target.
The score starts near zero right after seeing an item, then recovers toward 1.0 as time passes.
Time elapsed since last seen (hours) → spacing score. Red marker = optimal interval.
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.
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.
| Condition | Optimism update | Why |
|---|---|---|
| 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.
ARIA synthesises ideas from several established fields. None of these were sufficient on their own — that's why ARIA exists.
skill + optimism directly implements this.skill += α × (perf − skill)) is an EMA — a standard signal processing primitive. It gives recent interactions more weight while smoothing over noise.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));
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());
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.