aria-py
Adaptive Readiness Index Algorithm — adaptive sequencing engine for any domain.
Python implementation of aria-engine. Given a pool of items and a stream of user feedback, aria-py selects the best next item for each user — from the very first interaction, with no training data, no configuration, and no external dependencies.
Two methods. That's the API.
engine.suggest("user_id") # → best next item
engine.feedback("user_id", "item_id", signal) # → update user model
What it does
Most sequencing solutions are either domain-locked (Anki's SM-2 is memory-only), too heavy to deploy cold (BKT and IRT require pre-calibration), or stateless (bandits don't model growth). aria-py does none of those things.
The core algorithm — ARIA, Adaptive Readiness Index Algorithm — scores every eligible item using three multiplicatively composed factors:
- Challenge fit — Gaussian centred at
skill + optimism_bias. Targets slightly above where the user currently is. - Spacing fit — Forgetting curve on time since last seen. Surfaces items that are due.
- Coverage fit — Inverse category frequency. Prevents over-drilling one area.
The optimism bias has a hard floor. The engine always believes the user can do better than their current level, even after failure.
Domain is entirely caller-defined. The engine ships no built-in domains. You define what items are, what scoring means, and how state evolves:
| Domain | difficulty |
topic |
|---|---|---|
| Learning | question difficulty | subject |
| E-commerce | price ratio | product category |
| Travel | remoteness / adventure level | region |
| Content | reading complexity | genre |
| Jobs | seniority level | industry |
Install
pip install aria-py
Requires Python 3.9+. Zero runtime dependencies.
Quickstart
from aria import Engine, Item, Signal
engine = Engine()
engine.add_items([
Item(id="intro", difficulty=0.1, topic="math"),
Item(id="fractions", difficulty=0.4, topic="math"),
Item(id="algebra", difficulty=0.7, topic="math", prereqs=["fractions"]),
Item(id="reading", difficulty=0.2, topic="lang"),
Item(id="writing", difficulty=0.5, topic="lang"),
])
# suggest → feedback loop
for _ in range(10):
item = engine.suggest("alice")
print(f"→ {item.id} (difficulty={item.difficulty}, topic={item.topic})")
engine.feedback("alice", item.id, Signal(success=True, effort=0.5))
# inspect user model
state = engine.get_state("alice")
print(f"skill={state.skill:.3f} optimism={state.optimism_bias:.3f}")
print(f"solved={state.solved_set}")
API
Engine
Engine(
config: Config | None = None, # optional tuning — see Config below
*,
default_factors: bool = True, # auto-registers Challenge, Spacing, Coverage
seed: int | None = None, # seed RNG for deterministic behaviour (tests)
)
| Method | Returns | Description |
|---|---|---|
.add_items(items) |
Engine |
Register items. Validates prereq graph. Chainable. |
.add_factor(factor) |
Engine |
Append a custom factor to the pipeline. Chainable. |
.suggest(user_id, now?) |
Item |
Return the best next item for this user. |
.feedback(user_id, item_id, signal, now?) |
None |
Update user model after an interaction. |
.get_state(user_id) |
UserState |
Read current user model. Creates fresh state if new. |
.load_state(user_id, state) |
None |
Restore a UserState object directly. |
.export_state(user_id) |
dict[str, Any] |
Serialise state to a JSON-safe dict. |
.import_state(user_id, data) |
None |
Restore state from a dict produced by export_state. |
Item
Item(
id: str, # unique identifier
difficulty: float, # 0.0 – 1.0 normalised challenge proxy
topic: str, # category string for coverage balancing
prereqs: list[str] = [], # item IDs that must be solved first
metadata: dict[str, Any] = {}, # arbitrary caller data; available to custom factors
)
Signal
Signal(
success: bool, # did the user complete / answer correctly?
effort: float, # 0.0 (trivial) – 1.0 (maximum effort)
)
Config
Config(
exploration_rate: float = 0.05, # noise on scores; 0 = fully deterministic
optimism_floor: float = 0.05, # hard lower bound — do not change in production
optimism_ceiling: float = 0.35, # upper bound on optimism_bias
bandwidth: float = 0.2, # σ of challenge Gaussian; wider = more variety
alpha: float = 0.05, # EMA learning rate for skill updates
optimal_interval: float = 86_400.0, # spacing review window in seconds (24h default)
heap_threshold: int = 500, # switch to heap selection above this item count
)
Scoring formula
readiness(item) = challenge × spacing × coverage × (1 + noise)
challenge = exp( −(difficulty − target)² / 2σ² ) target = skill + optimism_bias
spacing = 1 − exp( −elapsed / optimal_interval )
coverage = 1 / ( 1 + topic_count[topic] / mean_topic_count )
noise = random() × exploration_rate
All three factors are multiplied together — all must agree. A perfect-difficulty item seen 10 seconds ago still scores near zero. One weak signal suppresses the item entirely.
State update rules
Called internally by feedback() after every interaction. O(1).
performance = success × (0.5 + 0.5 × (1 − effort))
skill = skill + α × (performance − skill) # exponential moving average
optimism += 0.02 if success and effort < 0.4 # easy win → raise target
optimism −= 0.01 if not success # ease back slightly
optimism unchanged otherwise # good challenge — target is right
optimism always clamped to [optimism_floor, optimism_ceiling]
The optimism floor (0.05) is a non-negotiable invariant — after any number of failures the engine still targets at least 5% above the user's current skill. It is not a default value; it is hardcoded behaviour. Growth is the assumption.
Persistence
UserState serialises to a plain JSON-safe dict. You own the storage layer.
import json
# save after session
snapshot = engine.export_state("alice")
db.set("aria:alice", json.dumps(snapshot))
# restore on next session / server restart
engine.import_state("alice", json.loads(db.get("aria:alice")))
Custom factors
Subclass Factor and register with engine.add_factor(). The pipeline multiplies your score with the built-ins automatically. Order of registration is order of multiplication.
from aria import Engine, Factor, Item
from aria.models import Config, UserState
class RecencyBoostFactor(Factor):
name = "recency_boost"
def score(self, item: Item, state: UserState, now: float, config: Config) -> float:
# boost items flagged as newly added content
return 1.0 if item.metadata.get("is_new") else 0.7
engine = Engine()
engine.add_factor(RecencyBoostFactor())
Turn off the built-ins entirely and supply only your own:
engine = Engine(default_factors=False)
engine.add_factor(MyChallengeFactor())
engine.add_factor(MySpacingFactor())
Prerequisites
Items can declare prerequisites. Locked items are never returned by suggest() until all their dependencies are in solved_set.
engine.add_items([
Item("intro", difficulty=0.1, topic="math"),
Item("algebra", difficulty=0.6, topic="math", prereqs=["intro"]),
Item("calculus", difficulty=0.9, topic="math", prereqs=["algebra"]),
])
# calculus is invisible until intro and algebra are both solved
The registry validates the prerequisite graph for cycles and dangling references on add_items() — once, at registration time, not at every query.
Design decisions
Multiplicative pipeline, not additive. All factors must agree. A near-zero in any single factor suppresses the item entirely. This prevents an item from sneaking through on one strong signal while failing every other dimension.
Optimism floor is non-negotiable. Hardcoded minimum 0.05. The engine always targets slightly above current skill, even after repeated failure. Growth is the invariant.
Immutable state updates. feedback() constructs a new UserState — it never mutates in place. Easy to persist, snapshot, and reason about.
No external dependencies. stdlib only. numpy and pandas are explicitly excluded — the engine must be droppable into any project without dependency conflicts.
Heap activation threshold. Linear scan for ≤500 items (cache-friendly). Max-heap for >500 items (O(log n)). The switch is automatic based on config.heap_threshold.
Caller owns persistence. The engine is in-memory only. Serialise state to any store with export_state / import_state. The engine has no opinion on where you keep data.
Performance
| Operation | Complexity | Notes |
|---|---|---|
feedback() state update |
O(1) | Dict operations only, no allocations |
suggest() ≤ 500 items |
O(n) | Linear scan — cache-friendly |
suggest() > 500 items |
O(n log n) | Max-heap selection |
add_items() prereq validation |
O(n + e) | Kahn's topological sort, once on registration |
Space: O(I + T) per user, where I = items interacted with, T = unique topics seen. Not total item pool size.
Development
git clone https://github.com/JayMGurav/aria-engine
cd aria-engine/aria-py
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest # 50 tests, < 0.1s
mypy aria --strict # zero errors
ruff check aria # zero warnings
Monorepo layout
aria-engine/
├── aria-core/ Rust — crates.io/crates/aria-core ✅ v0.1.0
├── aria-py/ Python — pypi.org/project/aria-py ✅ v0.1.0 ← you are here
└── aria-ts/ TypeScript / WASM — npm (planned) 🔜
All packages expose the same API surface and module structure. The mental model transfers completely across languages.
License
MIT — see LICENSE
Metadata
Release files for aria-py 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| aria_py-0.1.1.tar.gz | 21.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aria_py-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 36.5 kB
Release files / aria_py-0.1.1.tar.gz
| Download URL | aria_py-0.1.1.tar.gz |
|---|---|
| Size | 21.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5c8fea85718848edf4320d9104f56c0d6f6e83a64c2522b5a6eb07c0e0a780e0
|
|
BLAKE2b-256 checksum How to use checksums |
d7d5970e6f9c9ffe1936819485cb9675c988a4019049bbab5208b3c8ab8aa617
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.5
|
Release files / aria_py-0.1.1-py3-none-any.whl
| Download URL | aria_py-0.1.1-py3-none-any.whl |
|---|---|
| Size | 15.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f8bda198081d9bf303c3a12844f71753225c35bbfcc4c2a56d5fd7feb1ae509e
|
|
BLAKE2b-256 checksum How to use checksums |
9422a5399ddfdc35afe737b0bbfd64c4b55cae94c5479dc5c34c75f8e1668d4d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.5
|
Security for aria-py 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.