Float Tiers
A score-based float-limit assignment: the Payback Predictor V2
risk score and the user’s float rank pick a max eligible float amount from a
GrowthBook-configured bands table (pkg/underwriting_model/float_tiers.go).
The assignment is computed and persisted for every scored evaluation. Whether it drives the
user’s decision and responses is a per-user flag (underwriting.float_tiers.rollout, read once at
snapshot time — see Enforcement): for enforced users the assignment replaces the rulebook
decision and every legacy float-limit source; for everyone else it is shadow data with no
user-facing effect. A second per-user flag (underwriting.float_tiers_guardrails.rollout, also
read once at snapshot time) decides what the assignment offers: the
trigger policy's outcome when on, the score-assigned band alone when off — the
policy’s outcome is then recorded as shadow data.
Where it runs
Inside Predictor.RunAndSave, immediately after SageMaker returns the risk score — same
choke point, good-standing gate, and fail-closed
behavior as the predictor. No score means no assignment. The result is
snapshotted onto the evaluation’s MLResult
(float_tier_assignment), so every later call sharing the evaluation carries the same
assignment without re-deriving it from live flags.
Float ranks
The table is keyed by float rank — the same rank the predictor persists as MLResult.rank:
rank n means the user is requesting their nth float (prior_borrow_count + 1 from the
RuleFloatRankV3 outcome, nil treated as 0, capped at 8). The rank is not persisted on the
snapshot (it duplicates MLResult.rank); the assignment metric tags it directly.
Tier table
FloatTierTable (pkg/underwriting_model/float_tier_table.go) is a list of entries, each
holding a band ladder (tier letter, amount_cents, min_score cutoff) and a guardrails
switch (omitted means off). Band amounts are cents, unlike float amounts and profile ladder
tiers (dollars) — readers convert at the boundary. An entry either names a specific float_rank or — with no
float_rank — is the default entry covering every rank without its own. Lookup takes the
rank’s specific entry first, then the default. Band order in the config doesn’t matter, but a
ladder’s amounts must descend along with its cutoffs — a band with a higher cutoff never offers
less than one below it. The score picks the band with the highest min_score it meets; a score
below the entry’s lowest cutoff is denied, no exceptions.
The table is GrowthBook-configurable (underwriting.float_tiers_table.config); on load/parse
failure, an empty table, or no entry covering the rank, the hardcoded defaultFloatTierTable
applies — see the source for the current entries and values. Which ranks get their own entry, band counts,
amounts, cutoffs, and guardrail applicability are all GrowthBook edits, not code changes; every
configured amount must be 100x a ladder tier.
Trigger policy (guardrails)
Computed only under tier-table entries with guardrails on, and only when the user has a repaid
prior float — every trigger is defined on the last repayment
(RuleFloatRank's last_repaid_amount / last_repaid_days_late features,
where days late = payback − due in calendar days; the feature’s dollar amount is converted to
cents on read). The policy is a deliberate asymmetric ratchet layered on top of the score —
gains stick, losses snap. By the last repaid float’s lateness:
-
On time (at most 7 days late, matching the model’s training label) —
max(current offer, target): a standing offer above the score-assigned band carries forward, snapped down to the nearest band at or below it (carried_forward); otherwise the target stands (none). -
8–15 days late — portfolio review: the target stands, up or down; the standing offer is ignored (
portfolio_review). -
16–59 days late —
min(current, target, floor): reset to the entry’s lowest band (reset_floor). -
60 or more days late — access revoked: the policy denies outright, whatever the score (
revoked). The last repaid float never changes for a user who can no longer fund, so an applied revocation is permanent absent a manual bypass.
A score below the entry’s lowest cutoff denies regardless of trigger (denied_floor — it also
wins the label when the revoked trigger would deny too). Triggers change only the policy’s
amount (guardrail_amount_cents, 0 when the policy denies); tier/tier_amount_cents always
record what the score alone assigned (revoked denials keep them, floor denials carry none).
What the assignment offers
The policy’s outcome is always computed and recorded (guardrail, guardrail_amount_cents).
Whether it is what the assignment offers — approved and assigned_amount_cents, the values
every reader acts on — is the per-user underwriting.float_tiers_guardrails.rollout flag, read
once at snapshot time and persisted as guardrails_applied (see Enforcement):
-
Applied — the offer is the policy’s:
assigned_amount_centsisguardrail_amount_cents, denied when the policy denies. -
Not applied — the offer is the score-assigned band:
assigned_amount_centsistier_amount_cents, approved whenever the score met a band. The recorded policy outcome is shadow data with no user-facing effect.
A score below the entry’s lowest cutoff is denied either way.
The standing offer
The on-time ratchet’s "current offer" is the assignment offer — assigned_amount_cents, whatever
produced it — recorded when the user’s last float was funded: the float-created handler
copies each funded float’s assignment onto its permanent
HistoricalEvaluation, and the most recent
float-funded record is read back per fresh assignment (funded loans are skipped; resolved only
for guardrail-on entries). A float-funded record with no assignment on it — the funding predates
float tiers, or the model didn’t score that evaluation — falls back to the funded amount itself,
normalized for retired amounts ($75 reads as the $80 band), so established borrowers keep their
ratchet through the transition. There is no standing offer — the trigger formulas then run on
the target alone — when the user has never funded a float (such users sit in the rank-1 entry
anyway), the last funded float was funded over a denial (bypass), or the lookup fails. The
resolved input is snapshotted as current_offer_amount_cents.
Enforcement
underwriting.float_tiers.rollout (per-user, flags) gates whether the assignment is
applied. It is read once, when the assignment is snapshotted, and persisted as the snapshot’s
authoritative_v2 bool; every later reader trusts the snapshot, never the live flag — the same
snapshot-reuse contract as the predictor’s authoritative (and for the same reasons:
in-flight consistency across calls sharing an evaluation, and settlement keying off the same
persisted field).
underwriting.float_tiers_guardrails.rollout (per-user) gates whether the trigger policy’s
outcome is what the assignment offers. It is read at the same point and persisted as
guardrails_applied, under the same snapshot-reuse contract. The two flags are independent in
code: the guardrails flag shapes what the assignment offers, the rollout flag whether that offer
is the user’s decision — a user in the guardrails rollout alone carries a guardrailed shadow
assignment with no user-facing effect.
Enforcement is independent of the predictor’s authoritative flags: inference and the MLResult save do not depend on them, so a user whose approve/deny is still rulebook-decided (model in shadow mode) can carry — and be decided by — an enforced assignment. A score is the one hard dependency: no score means no snapshot and nothing to enforce.
Decision
In pkg/floatdecision, shared by all five decision endpoints,
precedence is:
-
Enforced assignment — is the float decision: it replaces the rulebook result in both directions and wins over the model’s own authoritative override (the assignment already encodes the score). Approved: the decision amount is exactly
assigned_amount_cents, converted to dollars — a positive amount on the wire, not the legacy-1limit-from-profile signal. Denied (score below the tier floor, or access revoked with the guardrails applied): denied, no exceptions. Either way the evaluation status isOK, the error text is cleared, anddeciding_rulebookisfloat_tiers— the rulebook evaluation’s own health and result are not consulted. -
Authoritative model override — the predictor’s own approve/deny (decision logic).
-
Rulebook decision — unchanged, with the legacy CFI limit.
A flagged user the float check produced no assignment for never falls back to the rulebook
(decide, resolver.go). The live flag is read only in this case:
-
Not in good standing (or the standing outcome errored) — denied, status
OK. -
Model couldn’t run (SageMaker error, missing features or outcomes, save failure) — denied, status
NODATA.
Both denials record deciding_rulebook float_tiers and clear the error text. A user with no evaluation keeps the NOEVAL denial. Hard gates outside the rule
engine (active float, debit card, good standing) apply regardless; a float bypass still wins
over everything. The rulebook always still runs —
its outcomes feed the model’s cold features — it just stops deciding.
Response shaping
An enforced user’s float-profile-shaped responses never carry the stored profile limit. An
approved assignment enables the canonical ladder to exactly
the assigned amount (cents ÷ 100), raised or lowered past the stored limit. A denied
assignment, or no assignment for a user the flag enforces, disables every float tier. The stored
FloatProfile itself is never written.
pkg/floatdecision is the single shaping point:
-
Resolve(every decision endpoint) shapes from the assignment the float check itself just ran or reused. -
Profile(GetFloatProfile— float-service’s fee lookup during float creation) reads the latest evaluation’s MLResult snapshot and trusts itsauthoritative_v2bool, so an in-flight float creation sees the limit its decision was made under. The snapshot counts until its TTL expires, not just the day it was saved.
Every float-profile-shaped response carries a float_tiers object when float tiers shaped the
ladder, and omits it when legacy shaping did:
-
assigned— an enforced, approved assignment set the ladder.assigned_atis the MLResult’sinvoked_at. -
denied— an enforced assignment denied the user; every float tier is disabled.assigned_atis set as forassigned. -
no_data— an enforced user with no assignment: the float check produced none ([no-assignment], including bad standing), orProfilefound no unexpired MLResult carrying one for the latest evaluation. Every float tier is disabled.
Schema: API spec (FloatTiers).
What it supersedes
For enforced users the assignment is the only float-limit source:
-
Legacy CFI — the stored limit CFI maintains is not what the user is on. CFI still runs and saves for them, so the stored profile is current if the flag is turned off, but the rule runner (live flag read there — it runs before any score exists) does not send the
underwriting_float_limit_updatedSegment notification for a stored-limit change. -
Next-increase requirements — suppressed (never returned): tier moves are score-driven, so legacy requirements would be wrong.
-
$200 qualification — skipped (no
200_test_offeredmarker is set) and an existing marker no longer raises the response ladder.
Persistence
-
MLResult —
float_tier_assignmentsnapshot, written with the prediction, same TTL and same-UTC-day reuse. -
HistoricalEvaluation — the float-created handler copies the decisive evaluation’s snapshot onto the permanent record (
float_tier_assignment), the durable analysis record. The field is absent when there is no snapshot to copy — the model never scored the evaluation, or its MLResult already expired — so analysis must treat it as optional. -
API — the MLResult snapshot is exposed read-only as
model_result.float_tier_assignmenton the summary-history and historical-evaluation responses (schema: API spec,FloatTierAssignment); the HistoricalEvaluation copy is not. -
Settlement: an assignment with
authoritative_v2set settles the MLResult the same way an authoritative prediction does — Payback Predictor V2.
Observability
-
underwriting.float_tiers.assignment— per computed assignment; tagsrank,tier(nonewhen denied),guardrail,approval,assigned_amount_cents,authoritative_v2,guardrails_applied. -
underwriting.float_tiers.authoritative— per decision an enforced assignment replaced; tagsapproval(the assignment’s) andrulebook_passindependently (the override-metric pattern), plustier,guardrailandguardrails_applied. The predictor’s override metric is not emitted when this one is — the model’s override was superseded, not applied. -
underwriting.float_tiers.float_created— per float funded under an enforced assignment (emitted at settlement); tagstier,guardrailandguardrails_applied.
Source: pkg/datadog/metrics.go