Ascenda Wiki
Ascenda · record

Ascenda — Financial Health Score (Home Blob)

Source docs/score-model.md · synced 2026-08-20

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 mood0 = anxious/chaotic, 100 = calm (ascBlob.mood.ts)
  • The blob colorscoreLow → 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).

LayerSymbolResets?Role
Cycle HealthH (0–1)per cycle“How am I doing this period” — internal input
ScoreS (0–100)neverThe 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 S is 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.
  • H is recomputed when new data is fed / on snapshot refresh; S updates 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:

ShapeCategoriesf_i(day)Projection
CommittedMoradia, Assinaturas, financing1.0 from day 1 — the obligation is known and whole, owed whether posted yet or notnever extrapolated; projected = committed budget
FlowMercado, Alimentação, Transporte, LazerdaysElapsed / daysTotal — genuinely accrues over timespent × 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_i curve 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.

ChannelGranularityBehavior
Scorenet / aggregateCompensation is automatic. Over on Lazer − under on Mercado nets out; number stays calm.
Insightper categoryAlways 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 moves S while a full month moves it fully: rate = clamp(newDaysCovered / daysTotal, 0, 1).
  • Inertia = memory. A single bad H only nudges S; sustained bad H erodes it; sustained good H builds 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.

CyclenαFeel
1st00.333Forming — big moves
2nd10.250Still forming
3rd20.200
6th50.125Settling
12th110.083Nearly at floor — ~1 year
24th+23+0.080Mature, 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)

CategoryShapeBudgetExpected (day 18)SpentVariance
Moradiacommitted22002200 (full, day 1)22000
Assinaturascommitted180180145−35
Mercadoflow1200720760+40
Alimentaçãoflow800480590+110
Transporteflow500300280−20
Lazerflow600360470+110

The §3 fix in action: the old linear model would have called Moradia expected = 1320 vs spent = 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: rate damps S movement 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; S persisted 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.
  • n counts cycles of evidence fed, not calendar age.

10. Still open

  1. n₀ = 3 and α_min = 0.08 — tune against real scenarios.
  2. rate definition: days-covered vs amount-covered.
  3. Asymmetric α_up / α_down?
  4. Pace/Savings split at 65/35 — right balance?
  5. 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?
  6. Staleness: a user who stops feeding for 6+ months returns with n intact, so their score is sluggish on stale evidence. Decay n after long gaps, or leave it?