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_cents is guardrail_amount_cents, denied when the policy denies.

  • Not applied — the offer is the score-assigned band: assigned_amount_cents is tier_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:

  1. 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 -1 limit-from-profile signal. Denied (score below the tier floor, or access revoked with the guardrails applied): denied, no exceptions. Either way the evaluation status is OK, the error text is cleared, and deciding_rulebook is float_tiers — the rulebook evaluation’s own health and result are not consulted.

  2. Authoritative model override — the predictor’s own approve/deny (decision logic).

  3. 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 its authoritative_v2 bool, 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_at is the MLResult’s invoked_at.

  • denied — an enforced assignment denied the user; every float tier is disabled. assigned_at is set as for assigned.

  • no_data — an enforced user with no assignment: the float check produced none ([no-assignment], including bad standing), or Profile found 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_updated Segment 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_offered marker is set) and an existing marker no longer raises the response ladder.

Persistence

  • MLResult — float_tier_assignment snapshot, 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_assignment on the summary-history and historical-evaluation responses (schema: API spec, FloatTierAssignment); the HistoricalEvaluation copy is not.

  • Settlement: an assignment with authoritative_v2 set settles the MLResult the same way an authoritative prediction does — Payback Predictor V2.

Observability

  • underwriting.float_tiers.assignment — per computed assignment; tags rank, tier (none when denied), guardrail, approval, assigned_amount_cents, authoritative_v2, guardrails_applied.

  • underwriting.float_tiers.authoritative — per decision an enforced assignment replaced; tags approval (the assignment’s) and rulebook_pass independently (the override-metric pattern), plus tier, guardrail and guardrails_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); tags tier, guardrail and guardrails_applied.

Source: pkg/datadog/metrics.go