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_score is 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 (max_, min_, std_, total_, transactions_ per category), expense_amount, income_amount, net_cash_flow

Primary-account transactions, 45 days back from the latest one, grouped by Plaid detailed category

spend entries drawing from pools, plus paychecks

low_transaction_count, transfers, grocery_essential_spend_count

Transactions from the last 32–33 days

spend counts

total_inflows, total_repayments

Transactions whose name contains a competitor app (Brigit, Dave Inc, Earnin, Cleo, Credit Genie, MoneyLion Instacash, Empower Advance, …)

The cash_advance_in, cash_advance_repayment and cash_advance_fees pools

available_balance, current_balance

Cached Plaid balances

starting_balance

average_available, number_of_days, min_daily_current_balance_30d, days_available_balance_below_100_in_30d, days_available_balance_below_0_in_30d

transactions-service balance history

balance_history bands (30 days, written via QAAddBalances)

contract_lag

Days to insight’s next instant-collection payday

paychecks cadence and pay_frequency, which is written to the employment record

prior_borrow_count, prior_borrow_on_time_rate, prior_borrow_max_repaid, days_since_last_repayment, and rank

float-service floats, with the payback date taken from the float’s latest COMPLETED collection attempt

prior_floats (COMPLETED RDS floats plus a collection-history log for each)

sub_rank

Sub rank N in the float profile notes

sub_rank

floatme_account_age_days

User DateJoined

account_age_days

Several features are not controlled:

  • error_count and error_ratio come from Usio debit statuses and are always 0 for these users.

  • app_opens_count_30d and app_opens_count_90d come from a feature store built from production app tracks, so they are null in test.

  • created_dow and created_dom come 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

  1. Model artifact parity. Test serves underwriting-v2-xgboost-training-2026-07-20-14-20-09-773, while prod serves the MLflow champion model (underwriting_v2_model_s3_uri_overrides in the machine-learning repo’s deploy/variables.tf). The same features can score differently, so reproducing production tiers in test means serving the production artifact there.

  2. GrowthBook (test). Turn underwriting.float_tiers.rollout on for these users so the tier assignment decides the float. Set underwriting.float_tiers_table.config to the production table; the hardcoded fallback in underwriting-api’s float_tier_table.go uses different cut points and amounts. The threshold table already matches, since test logs show 0.6785 for rank 1.

  3. 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

  1. Create the user. Use POST /qa/integration/user with the state, or log in to floatme-flutter as name+float_tier_new_a@integrationtest.floatme.io.

  2. Open the float screen in the app. Or call underwriting directly: POST /{user_id}/recalculate, wait about 30 seconds, then GET /{user_id}/float_check?amount=20. float_check and POST /{user_id}/underwriting/instant run the float decision, which invokes the model; GET /{user_id}/underwriting/eligibility does not.

  3. Read the score from Datadog: env:test service:underwriting "payback predictor v2: inference complete" @user_id:<user id>. The log carries @risk_score, @rank, @threshold and @term (the contract_lag).

  4. 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

float_tier_new_a

1

0.979

A (≥ ~0.97)

float_tier_new_c

1

0.842

C (~0.80–0.87)

float_tier_new_deny

1

0.520

DENY (< 0.6785)

float_tier_existing_a

7

0.991

A (≥ ~0.98)

float_tier_existing_c

7

0.886

C (~0.84–0.91)

float_tier_existing_deny

7

0.498

DENY (< 0.581)

Re-check these whenever the test model artifact or the profiles change.

Adding a Profile

  1. Add the profile under profiles in pkg/api/float_tier_profiles.json, keyed by the new state name.

  2. Add the state to the state enum in spec/qa_api.yaml and to the float tier case in handleCreateIntegrationUserRequest, then run make generate.host.

  3. Test_floatTierCheckingAccount checks 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.