Scoring
How value equations turn contributions into scores
The Scoring domain in @holons/core/scoring is the engine that turns a holon's REA event stream into per-user scores. It is the working implementation of the value equation — the concept referenced across the rest of the docs.
Where REA decides what counts as a recorded event and Council / DNA decide what the holon stands for, scoring decides how much each event is worth. Every UI in the Harvest monorepo calls the same scoring functions, so "what's my score?" produces the same answer in the web app, the Telegram bot, the CLI, and via the MCP server.
The pipeline
┌────────────────────────────┐
│ REA events (rea_events) │
└──────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ REAAggregator │
│ → UserAggregates │
│ → currencyBalances │
└──────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ calculateUserScore() │
│ with ScoreEquation │
└──────────────┬──────────────┘
│
▼
scoreThree steps, three pieces of state:
REA events — every action that should count produced an event (see REA Accounting).
Aggregates — the aggregator reduces the event stream to per-user counters (
UserAggregates) and per-currency balances.Score — a pure function multiplies aggregates by the equation's weights.
The value equation
DEFAULT_EQUATION ships with sensible starting values:
initiated
1
Quests created
completed
2
Quests completed (deliberately worth 2× initiating)
sent
1
Appreciations sent
received
1
Appreciations received
collaboration
1
Time-logging events
wants
1
Wants declared
offers
1
Offers declared
currencies.hour
1
Hours logged (canonical currency)
That "completed 2× initiated" default isn't accidental — it encodes one of the holon's most basic principles: finishing things matters more than starting them.
Each holon can change every weight. The equation is loaded from the holon's settings lens, cached synchronously for instant UI access, and live-updated via subscribeToEquationChanges().
User aggregates
These are produced by REAAggregator from the event stream, or by toAggregates() from legacy data shapes. ZERO_USER_AGGREGATES is a useful default while REA queries are in flight.
The score formula
Plain weighted sum. No magic, no neural net, no decay (yet). Every weight is multiplied by its aggregate; everything is added. The result is a single number per user.
For per-currency balances (currencyBalances: Record<string, number>), the score adds one term per currency the user holds, weighted by equation.currencies[currency].
Operations
The public functions exported by @holons/core/scoring:
calculateUserScore(aggregates, equation?, balances?)
Pure: total score for one user
calculateScoreFromUserData(userData, equation?)
Convenience: aggregates + score in one call
calculateAllUserScores(holonId, equation?)
Score every user in a holon
getScoreBreakdown(aggregates, equation?, balances?)
Per-field contribution (for "where do my points come from?")
getActionScore(actionType, equation?)
What's one of these actions worth right now?
calculatePercentageShare(userScore, allScores)
One user's slice of the total
calculateTaskCompletionScores(...)
Specialized: per-task completion delta
loadEquation(holonId)
Read the holon's equation from settings
getCachedEquation(holonId)
Synchronous read of the cached equation
preloadEquation(holonId)
Warm the cache
subscribeToEquationChanges(holonId, cb)
Live updates
migrateEquation(raw)
Fold legacy / loose-shape data into canonical form
The split between pure scoring helpers (calculateUserScore, getScoreBreakdown, …) and impure loaders (loadEquation, subscribeToEquationChanges) means every UI can render a score live from cached state without round-tripping to the substrate.
Migration: legacy hours → currencies.hour
Historically the equation had a top-level hours weight. Newer code reads currencies.hour. migrateEquation() folds the legacy shape into the new one on load, and also folds flat top-level currency keys (equation.euro = 0) — the shape telegram-ui historically wrote — into currencies.euro.
The migration is idempotent and safe to call on already-migrated data. The deprecated top-level hours field is preserved (set equal to currencies.hour) so unmigrated consumers keep computing the same score.
Subscriptions and caching
getCachedEquation(holonId) returns the cached equation synchronously, useful in render-paths that can't await. The cache is populated by preloadEquation() and kept fresh by subscribeToEquationChanges(), which listens to the underlying settings lens.
This pattern is why a slider in the web dashboard can instantly re-render every user's score when a holon adjusts a weight — the cache updates, the pure score function re-runs, every UI hears about it.
MCP tool surface
The MCP server exposes 9 tools in the scoring domain:
per-user score (
calculate_user_score)all-user scoring (
calculate_all_scores)score breakdown (
get_score_breakdown)per-action delta (
get_action_score)equation load/migration (
load_equation,migrate_equation)and the supporting variants
Agents connected via MCP can answer "what's my contribution?" and "what would happen if we changed the equation?" without writing any score-computation code of their own.
See also
REA Accounting — the event stream this domain consumes
Glossary: value equation — the protocol concept
Glossary: epoch — the time windows over which scores are distributed
Funding Flow — how scores feed splitter/threshold distributions
Last updated
Was this helpful?