Float Tier Users
The float_tier_* predefined states create users whose bank history, balances, pay cadence and float history mirror the production users that the payback predictor v2 model (underwriting-api) placed in a given float tier.
They exist so float tiers, which decide approval and amount from the model score in production, can be exercised in test and in floatme-flutter.
Every other predefined state scores in the DENY range or the bottom tier in test.
For example, float_ready at rank 1 scores about 0.57 against a 0.6785 threshold, and recurring_7d tops out around 0.83.
How the Model Decides
-
The model’s
risk_scoreis P(repaid within 7 days of due), so higher is better. -
Rank is prior floats + 1, capped at 8. "New" means ranks 1–2 and "existing" means rank 3 and up.
-
The tier table (GrowthBook
underwriting.float_tiers_table.config) maps a score and rank to a tier. A score below the rank’s lowest cut point is denied.
The table below shows production values from a sample of 450 results scored on 2026-09-28. The authoritative cut points live in GrowthBook.
| Band | Rank | Deny below | Tier amounts |
|---|---|---|---|
new |
1 |
0.6785 |
A $100, B $80, C $40, D $20 |
new |
2 |
0.676 |
A $100, B $80, C $40, D $20 |
existing |
3 |
0.7791 |
A $200, B $100, C $80, D $40 |
existing |
4 |
0.6519 |
A $200, B $100, C $80, D $40, E $20 |
existing |
5 / 6 / 7 / 8+ |
0.6048 / 0.5859 / 0.581 / 0.6033 |
A $200, B $100, C $80, D $40, E $20 |
Scores observed in the sample:
-
New band: A ≥ 0.97, B about 0.88–0.94, C about 0.80–0.87, D from the threshold to about 0.79.
-
Existing band: A ≥ 0.98, B about 0.92–0.98, C about 0.83–0.92, D about 0.70–0.83, E from the threshold to about 0.70.
What Separates the Tiers
The analysis lives in the Hex project "Nate’s Data Shenanigans", in the cells labelled QA tier mimic: ….
It joins the sample to:
-
the model’s captured input features (
staging.stg_machine_learning__underwriting_v2_results); -
the posted transactions on each user’s main account in the 45 days before scoring (
staging.stg_plaid_transactions).
The table shows medians per user. Transaction counts cover 45 days; balance counts cover the last 30.
| New A | New C | New DENY | Existing A | Existing C | Existing DENY | |
|---|---|---|---|---|---|---|
Transactions |
284 |
207 |
128 |
339 |
251 |
176 |
Paychecks (typical amount) |
5.5 ($1,176) |
3 ($1,318) |
4 ($617) |
6 ($1,465) |
4 ($1,212) |
3 ($536) |
Pay cadence |
weekly |
bi-weekly |
mixed; 42% have no wages |
weekly |
bi-weekly |
bi-weekly; 28% have no wages |
Cash advances in (distinct apps) |
16 (6.5) |
16 (8.5) |
6 (2) |
22 (7) |
17 (6) |
9 (2) |
Personal-loan payments |
52 |
28 |
5 |
47 |
26 |
7 |
P2P in / out |
8.5 / 15 |
8.5 / 12.5 |
18 / 9 |
9 / 15 |
12 / 16 |
16 / 10 |
Current balance |
$456 |
$18 |
$0 |
$438 |
$139 |
$0 |
Average available balance |
$718 |
$32 |
$0 |
$556 |
$102 |
$0 |
Days below $100 / below $0 |
2 / 0 |
1.5 / 0 |
16.5 / 5.5 |
3 / 0 |
15 / 2 |
19.5 / 8 |
Account age (days) |
934 |
2,123 |
2,356 |
725 |
2,033 |
2,165 |
Prior-float on-time rate |
– |
– |
– |
1.0 |
0.83 |
0.41 |
Days since last repayment |
– |
– |
– |
10 |
5.5 |
396 |
In short, high-tier users run more money through the account. They are paid weekly, borrow from several earned-wage-access apps and repay them, and keep positive balances. Denied users show thin volume, small or missing paychecks, peer-to-peer inflows standing in for income, and balances at or below zero for much of the month.
What Each Profile Controls
Profiles live in pkg/api/float_tier_profiles.json.
Each one is built by handleCreateFloatTierIntegrationUser in pkg/api/float_tiers.go.
| Model features | Source at inference | Profile knob |
|---|---|---|
Category aggregates ( |
Primary-account transactions, 45 days back from the latest one, grouped by Plaid detailed category |
|
|
Transactions from the last 32–33 days |
|
|
Transactions whose name contains a competitor app (Brigit, Dave Inc, Earnin, Cleo, Credit Genie, MoneyLion Instacash, Empower Advance, …) |
The |
|
Cached Plaid balances |
|
|
transactions-service balance history |
|
|
Days to insight’s next instant-collection payday |
|
|
float-service floats, with the payback date taken from the float’s latest COMPLETED collection attempt |
|
|
|
|
|
User |
|
Several features are not controlled:
-
error_countanderror_ratiocome from Usio debit statuses and are always 0 for these users. -
app_opens_count_30dandapp_opens_count_90dcome from a feature store built from production app tracks, so they are null in test. -
created_dowandcreated_domcome from the request time.
In the existing profiles, the late repayments are the oldest floats, so the latest repayment is on time. Tier guardrails key off how late the last repayment was, so they leave the score’s tier in place.
Anonymization
-
Merchants: descriptors are brand names that appear across many production users, with no store numbers, reference ids or account masks.
-
Payroll: deposits use payroll-processor descriptors (ADP, Paychex, Gusto, Paylocity, UKG), never an employer name taken from a user.
-
Amounts: drawn uniformly from ranges around the production 25th–75th percentile for each category. Paychecks vary ±3% around a round figure. No production transaction is copied.
-
Counts: start from the tier medians, not any one user’s history. The C and DENY profiles were then pushed past the medians until the test model scored them in band (see Calibration Results).
Generation is seeded by the state name. Every run builds the same history relative to today, so a score only moves when the profile moves.
Test Environment Prerequisites
-
Model artifact parity. Test serves
underwriting-v2-xgboost-training-2026-07-20-14-20-09-773, while prod serves the MLflowchampionmodel (underwriting_v2_model_s3_uri_overridesin the machine-learning repo’sdeploy/variables.tf). The same features can score differently, so reproducing production tiers in test means serving the production artifact there. -
GrowthBook (test). Turn
underwriting.float_tiers.rollouton for these users so the tier assignment decides the float. Setunderwriting.float_tiers_table.configto the production table; the hardcoded fallback in underwriting-api’sfloat_tier_table.gouses different cut points and amounts. The threshold table already matches, since test logs show 0.6785 for rank 1. -
Model inputs. The model only runs when two things exist, and the state creates both:
-
a rule-runner evaluation from the last 7 days, created when the Plaid item is added;
-
a next payday from insight, taken from the employment record.
-
Calibrating in Test
-
Create the user. Use
POST /qa/integration/userwith the state, or log in to floatme-flutter asname+float_tier_new_a@integrationtest.floatme.io. -
Open the float screen in the app. Or call underwriting directly:
POST /{user_id}/recalculate, wait about 30 seconds, thenGET /{user_id}/float_check?amount=20.float_checkandPOST /{user_id}/underwriting/instantrun the float decision, which invokes the model;GET /{user_id}/underwriting/eligibilitydoes not. -
Read the score from Datadog:
env:test service:underwriting "payback predictor v2: inference complete" @user_id:<user id>. The log carries@risk_score,@rank,@thresholdand@term(thecontract_lag). -
Adjust counts, amount ranges, balance bands or pay cadence in
float_tier_profiles.json, then repeat.
Check categories on the first run.
Plaid does not document how Sandbox categorizes custom-user descriptions.
Read one of the new user’s transactions back from transactions-service and confirm categories.detailed is populated, for example FOOD_AND_DRINK_FAST_FOOD for MCDONALD’S.
If transactions come back uncategorized, every category feature is null, and only balances, transaction volume, pay cadence and float history move the score.
Calibration Results
The C and DENY profiles built from production medians scored too high on the test model. At 2026-10-08 they scored 0.928 (rank 1 C), 0.955 (rank 7 C) and 0.698 (rank 7 DENY). They were tuned one change at a time, using custom users with the same histories. The changes that moved the test model most:
-
Smaller paychecks, which also lengthen
contract_lag. -
Fewer transactions.
-
More days below $100 or overdrawn.
-
For returning users, an older last repayment and more late repayments.
Cash advances from apps outside the model’s competitor list lowered the score a little. Overdraft fees and a negative current balance did not lower it.
Scores after calibration, on the test model artifact:
| State | Rank | Score | Production band for that score |
|---|---|---|---|
|
1 |
0.979 |
A (≥ ~0.97) |
|
1 |
0.842 |
C (~0.80–0.87) |
|
1 |
0.520 |
DENY (< 0.6785) |
|
7 |
0.991 |
A (≥ ~0.98) |
|
7 |
0.886 |
C (~0.84–0.91) |
|
7 |
0.498 |
DENY (< 0.581) |
Re-check these whenever the test model artifact or the profiles change.
Adding a Profile
-
Add the profile under
profilesinpkg/api/float_tier_profiles.json, keyed by the new state name. -
Add the state to the
stateenum inspec/qa_api.yamland to the float tier case inhandleCreateIntegrationUserRequest, then runmake generate.host. -
Test_floatTierCheckingAccountchecks every profile. It requires at most 250 transactions and under 55 kB of config (Plaid’s custom-user limits), dates inside the 45-day window, and 30 days of balance history.