Status: DRAFT / discussion. Nothing here is implemented yet. This doc defines what the score means and the formula we intend to use, before writing code.
1. What the score is
A single number 0–100 shown on the home screen inside the blob. It is not an
accounting figure — it is an emotional, reputation-like signal about the
user’s financial behavior over time. It drives three things:
- The big number in the blob (
AscBlobScore) - The blob mood —
0 = anxious/chaotic,100 = calm(ascBlob.mood.ts) - The blob color —
scoreLow → scoreHigh(ascBlob.color.ts)
Today it is a hardcoded mock (80 / 50 / 35 / 13 in budget.mocks.ts). This doc
defines the formula that will replace those constants.
Core idea: a stock, not a monthly grade (credit-score model)
The score is built over time and persists across months. It does not reset each cycle. One bad month barely dents it; a good streak slowly lifts it. New users start at a neutral 50 (blank-slate, like a fresh credit file).
| Layer | Symbol | Resets? | Role |
|---|---|---|---|
| Cycle Health | H (0–1) | per cycle | “How am I doing this period” — internal input |
| Score | S (0–100) | never | The persistent blob number — drifts toward H slowly |
daysElapsed lives only inside H. S carries across months untouched.
Consistency is emergent. There is deliberately no “momentum” term inside H.
If every cycle’s H is good, S stays high on its own — that is the reward for
staying constant. A user who is consistently well under budget must never lose
points merely because this month was marginally worse than the last.
Design principles
- Honest but never shaming. Real overspend moves the number, but
Sis floored at ~8 and moves with inertia — the blob never dies. - Judge the net, not the envelope. Overspending one category while saving another is good money management, not a failure. See §5.
- Earned slowly, lost slowly. Consistency builds the score.
- No delayed feedback. Everything is computed mid-cycle. See §4.2.
- Robust to batch data. See §2.
2. Data reality: batch feeds, not live
There is no Open Finance integration yet. Users import statements — realistically weekly or monthly, in lumps. Consequences:
- The last few days are almost always empty (statement lag). → No “recent N days” signals. We never use rolling day windows.
- Transactions carry real dates, so spend-to-date vs expected-by-date is valid even when the data arrived all at once.
His recomputed when new data is fed / on snapshot refresh;Supdates at the same time, damped by how much new information arrived (§6).
3. Spending is not linear — per-category expected curves
The problem. expected = budget × daysElapsed/daysTotal assumes money leaks
out uniformly. It doesn’t. Rent hits day 1 and never again. On day 18 the linear
model claims Moradia “should” be at R$1.320 — but rent was fully paid on day 1,
so it sits at R$2.200 and the naive check screams 67% over pace when the user is
perfectly fine.
The fix. Expected is shaped per category:
expected_i(day) = budget_i × f_i(day)
where f_i is that category’s own spending shape. v1 uses two shapes:
| Shape | Categories | f_i(day) | Projection |
|---|---|---|---|
| Committed | Moradia, Assinaturas, financing | 1.0 from day 1 — the obligation is known and whole, owed whether posted yet or not | never extrapolated; projected = committed budget |
| Flow | Mercado, Alimentação, Transporte, Lazer | daysElapsed / daysTotal — genuinely accrues over time | spent × daysTotal/daysElapsed |
So paying R$2.200 of rent on day 1 reads as 100% expected, 0% over.
Classifying is free. The essentiality algorithm already reads frequency, recurrence, and rigidity. Rigid + recurring + once-monthly → committed. Frequent + variable + spread out → flow. Same infrastructure, no new data.
v2: learn each category’s real
f_icurve from its transaction history (step function for rent, ramp for groceries, spiky for leisure) instead of bucketing into two shapes.
4. Cycle Health H
H = clamp( 0.65 · Pace + 0.35 · Savings , 0, 1 )
4.1 Pace — “am I net under the line right now?” (0.65)
Computed per category, rolled up on the net:
per category: variance_i = spent_i − expected_i(day) (− surplus, + over)
net: netVariance = Σ variance_i
Case A — netVariance ≤ 0 (net under the line):
Pace = 1 # full stop. Zero penalty.
The overspend was absorbed by surplus elsewhere. The budget cap already encodes the savings goal, so being net under means the goal is being met — there is nothing to penalize. Per-category overs still fire insights (§5), but cost zero.
Case B — netVariance > 0 (genuinely over overall):
Only now does essentiality matter — and only the uncompensated amount is
penalized:
positiveVariance = Σ max(variance_i, 0)
discShare = Σ max(variance_i,0) over discretionary / positiveVariance
essShare = 1 − discShare
k_blend = 2.5·discShare + 1.2·essShare # two tiers only
Pace = clamp( 1 − k_blend · (netVariance / Σ budget_i) , 0, 1 )
An overspend driven by discretionary stings ~2× more than one driven by an essential shock. Normalizing to total budget keeps the response proportional — a small slip stays a small dip.
No under-budget bonus: being under is just Pace = 1. We don’t reward
under-living.
4.2 Savings — “where does this month land vs my goal?” (0.35)
Mid-cycle, no delay, using the shape-aware projection from §3:
projectedSpend = Σ committed budget_i # never extrapolated
+ (Σ flow spent_i) × daysTotal/daysElapsed # extrapolated
plannedSavings = income − Σ budget_i (≈ globalSavingsGoal)
projectedSavings = income − projectedSpend
Savings = clamp( projectedSavings / plannedSavings , 0, 1 )
Why this is not just Pace restated. Savings is a leveraged quantity: it’s the thin gap between income and budget, so a small budget miss is a large savings miss. In §7, spending 7% over budget wipes out 39% of the savings goal. Pace measures process (am I managing day to day); Savings measures consequence (what this costs the goal). The sensitivity difference is the information.
Fallback if plannedSavings ≤ 0:
clamp(1 − max(0, projectedSpend − Σbudget)/Σbudget, 0, 1).
5. Two channels: score on the net, detect on the category
We still compute every category — expected, spent, variance, essentiality. That per-category data powers the UI and the coach. What changes is only how it rolls up into the one number.
| Channel | Granularity | Behavior |
|---|---|---|
| Score | net / aggregate | Compensation is automatic. Over on Lazer − under on Mercado nets out; number stays calm. |
| Insight | per category | Always notices. Surfaces “Lazer passou do previsto” — never touches the score. |
Example — Lazer +R$200, Mercado −R$300:
netVariance = +200 − 300 = −100 → net under → Pace = 1, score steady
insight fires: "Você passou do previsto em Lazer, mas compensou no Mercado 👍"
Awareness without punishment — and here it’s framed as praise for the trade-off. This is the “surface one insight at a time, never shame” principle in mechanics.
6. Persisting into the Score S
target = H × 100
S_new = clamp( S_old + α(n) · rate · (target − S_old) , 8 , 100 )
S_old— previous persisted score. New user: S₀ = 50.α(n)— tenure-scaled responsiveness. See §6.1.rate— fraction of new information since the last update, so a tiny partial feed barely movesSwhile a full month moves it fully:rate = clamp(newDaysCovered / daysTotal, 0, 1).- Inertia = memory. A single bad
Honly nudgesS; sustained badHerodes it; sustained goodHbuilds it.
rate also solves the early-month problem for free: on day 2 there’s almost no
new information, so S barely moves regardless of how noisy H is.
6.1 Tenure: α scales with how much history exists
A cycle should count in proportion to how much it adds to what we already
know. Month 2 is ~50% of everything we know about a user; month 180 is ~0.6%.
An identical H must therefore move the score far more for a newcomer than for a
veteran.
α(n) = max( α_min , 1 / (n + n₀) )
n = cycles already incorporated (evidence, NOT calendar age — see below)
n₀ = 3 # the starting 50 is worth ~3 months of evidence
α_min = 0.08 # floor; reached at ~1 year of history
Why 1/n is the right shape. With α = 1/n exactly, S is the running
average of every H ever recorded — the update rule and the average are the same
thing. The score isn’t an arbitrary decay curve; it is literally “your average
cycle health, weighted by evidence.”
Why n₀ exists. Without it, the first-ever cycle has α = 1 and slams S
straight to H — one imported month and you’re at 95. n₀ = 3 gives the neutral
50 enough weight that it can’t be blown away instantly; a new user moves
meaningfully but still has to earn the climb.
Why the floor exists. Pure 1/n reaches α ≈ 0.006 by month 180 and the score
freezes: genuine deterioration would take years to surface, and — worse — a
user who reformed could never climb back, which destroys the coaching value. Real
credit scores solve this by ageing old data out; we use a floor. Recent behavior
always retains some weight.
| Cycle | n | α | Feel |
|---|---|---|---|
| 1st | 0 | 0.333 | Forming — big moves |
| 2nd | 1 | 0.250 | Still forming |
| 3rd | 2 | 0.200 | |
| 6th | 5 | 0.125 | Settling |
| 12th | 11 | 0.083 | Nearly at floor — ~1 year |
| 24th+ | 23+ | 0.080 | Mature, stable forever |
Same perfect month (H = 1.0), different tenure:
Month 2 (n=1, α=0.250): S 58 → 58 + 0.250×(100−58) = 68.5 (+10.5)
Month 180 (n=179, α=0.080): S 78 → 78 + 0.080×(100−78) = 79.8 (+1.8)
~6× more movement for the newcomer. The early user is still writing their story; the veteran already has one.
n counts evidence, not calendar time. A user who signed up 2 years ago but
has only ever fed 3 months has n = 3, not 24. Otherwise inertia could be
earned by doing nothing, which is backwards. n increments at cycle close;
repeated within-cycle feeds are handled by rate (whose values sum to 1 across a
cycle), not by incrementing n.
This replaces the separate “stability floor” mechanic. Tenure resistance now falls out of α-decay automatically — a veteran’s score resists damage because each month is a small slice of their evidence. No bolted-on ratchet needed.
7. Worked example
Marina, day 18 of 30. Income R$6.500, total budget R$5.480, so the
savings goal is R$1.020. Her persisted score is S_old = 62; her last
import covered through day 4.
Per-category variance (shape-aware)
| Category | Shape | Budget | Expected (day 18) | Spent | Variance |
|---|---|---|---|---|---|
| Moradia | committed | 2200 | 2200 (full, day 1) | 2200 | 0 |
| Assinaturas | committed | 180 | 180 | 145 | −35 |
| Mercado | flow | 1200 | 720 | 760 | +40 |
| Alimentação | flow | 800 | 480 | 590 | +110 |
| Transporte | flow | 500 | 300 | 280 | −20 |
| Lazer | flow | 600 | 360 | 470 | +110 |
The §3 fix in action: the old linear model would have called Moradia
expected = 1320vsspent = 2200→ “67% over!” — a false alarm on 40% of her budget. Shape-aware, rent paid on day 1 is exactly on plan: variance 0.
netVariance = 0 − 35 + 40 + 110 − 20 + 110 = +205
Pace (Case B — net over)
positiveVariance = 40 + 110 + 110 = 260
discShare = (110 + 110)/260 = 0.846 # Alimentação + Lazer
essShare = 40/260 = 0.154 # Mercado
k_blend = 2.5(0.846) + 1.2(0.154) = 2.30
Pace = 1 − 2.30 × (205 / 5480) = 1 − 0.086 = 0.914
Surpluses absorbed R$55 of the R$260 gross overspend — only the net R$205 is
penalized. And because the overspend is overwhelmingly discretionary, k_blend
lands near the harsh end (2.30 of a possible 2.5). Still, Pace stays high at
0.914: R$205 against a R$5.480 budget is a small slip, and the score treats it
like one.
Savings
projectedSpend = (2200 + 180) = 2380 # committed, not extrapolated
+ (760+590+280+470) × 30/18 = 3500 # flow, extrapolated
= 5880
projectedSavings = 6500 − 5880 = 620
plannedSavings = 6500 − 5480 = 1020
Savings = 620/1020 = 0.608
Leverage, visible: she’s projected 7% over budget (400/5480) — but that erases 39% of her savings goal. This is precisely what Pace alone cannot see, and why Savings is its own term.
Cycle Health → Score
Marina is on her 5th cycle, so n = 4 → α = 1/(4+3) = 0.143.
H = 0.65(0.914) + 0.35(0.608) = 0.594 + 0.213 = 0.807 → target = 81
rate = (18 − 4)/30 = 0.467
S_new = 62 + 0.143 × 0.467 × (81 − 62) = 62 + 1.27 = 63.3 → ~63
Her blob ticks 62 → 63. This month is “worth” an 81, but the score only creeps up ~1 point — she has some history now, and only half a month of new data arrived.
The payoff is the bad month. If next cycle (n = 5, α = 0.125) H collapsed
to a target of 20 with a full month of data:
S_new = 62 + 0.125 × 1.0 × (20 − 62) = 62 − 5.25 = 56.75
A disastrous month costs ~5 points, not 42. Two good months pull it back. Hard to build, hard to lose, always recoverable — and by month 24 the same disaster would cost only ~3.4 points.
Insights fired (score untouched)
- “Alimentação e Lazer passaram do previsto — juntos, R$220 acima.”
- “Assinaturas e Transporte estão abaixo do previsto, isso compensou parte.”
8. Guardrails / edge cases
- Early cycle:
ratedampsSmovement when little new data has arrived — no special-casing needed. - Floor at 8 on
S; the blob is never fully dead. - No data at all → onboarding/neutral state,
S = 50, not 0. - Ease, don’t snap. The displayed score animates toward
S_new. - Divide-by-zero: guard
Σ budget = 0,daysElapsed = 0,plannedSavings ≤ 0,positiveVariance = 0.
9. Resolved decisions
- ✅ Score is a persistent stock (credit-score model), not a monthly grade.
- ✅ No Momentum term — consistency is emergent from
S’s inertia. Never lose points for a good month being marginally worse than the last. - ✅ Per-category expected curves (committed vs flow) — no linear assumption.
- ✅ No delayed feedback — savings is projected mid-cycle, shape-aware.
- ✅ Score on the net, detect on the category — compensation is free.
- ✅ Net under budget → zero score penalty, however the spending was shaped. The budget cap already encodes the savings goal; a user who is net under is succeeding, and penalizing the shape of a success is auditor behavior.
- ✅ Essentiality (two tiers, 2.5 / 1.2) bites only when net over.
- ✅ Computed on the backend;
Spersisted per user. - ✅ Displayed score eases, no snap. No under-budget bonus.
- ✅ New users start at S₀ = 50.
- ✅ Tenure via α-decay:
α(n) = max(0.08, 1/(n+3)). A cycle counts in proportion to how much it adds to existing evidence. Replaces the separate stability-floor mechanic. - ✅
ncounts cycles of evidence fed, not calendar age.
10. Still open
n₀ = 3andα_min = 0.08— tune against real scenarios.ratedefinition: days-covered vs amount-covered.- Asymmetric
α_up/α_down? - Pace/Savings split at 65/35 — right balance?
- Committed-category edge case: what if a committed charge is missing by
mid-cycle (rent not yet paid on day 20)? Treat as pending (
variance 0) or flag? - Staleness: a user who stops feeding for 6+ months returns with
nintact, so their score is sluggish on stale evidence. Decaynafter long gaps, or leave it?