$200 Float-Limit Test
This page documents a temporary experiment: certain high-risk-score, maxed-out-CFI-rank users are
offered a $200 float tier that’s otherwise hidden from everyone. $200 is a pre-existing, deliberately
hidden tier in this system (see Rulebooks/CFI internals — dynamo.FloatAmount200,
the CFI rule sub_rank_8_float_rank_6_prev_200_amt_200) — it was previously granted only via a one-off
ops script (scripts/200_float_test, reverted by scripts/undo_200_float_test). This feature replaces
that ad hoc mechanism with a GrowthBook-gated one: turning the master flag off immediately stops
new users from qualifying, and stops a fresh eligibility check from showing $200 to anyone
(including a previously-offered user) — but does not retroactively rug-pull a user already
mid-flow on a float they were already shown and are actively creating. See
Hot-Path-Only Tier Injection for exactly which endpoints do which, and why.
Where It Runs
The qualification check runs inside Checker.Check (pkg/float_evaluation/eligibility.go) —
evaluateTwoHundredFloatTest, called right after the user’s FloatProfile is loaded — so it only
evaluates during eligibility building (GetEligibilityCheck, InstantCheck), not on a raw
FloatRequest call (which never runs Eligibility.Check). It requires evaluation.CFIState (rank,
highest float) and a persisted MLResult (see Payback Predictor V2),
so it depends on the payback predictor v2 model having produced a prediction for the user’s current
evaluation — but is otherwise independent of that model’s own authoritative rollout state: it
reads RiskScore/Threshold directly off the persisted MLResult via MLResults.GetByEvaluationID,
bypassing `MaybeRunFloatCheck’s authoritative gate (which would otherwise hide the score entirely in
shadow mode).
Qualification Criteria
All of the following must hold (checked in this order, cheapest/no-I/O gates first):
-
underwriting.two_hundred_float_testGrowthBook flag is on for the user (master kill switch). -
The user’s base evaluation is already approved (
evaluation.FloatEvaluation.Approved) — this test never offers a higher tier to an otherwise-denied user. -
The user is not already organically increased to $200 (
FloatProfile.IsIncreasedTo(200)) — this test is for users who don’t already have it through the real CFI graduation path. -
evaluation.CFIState.FloatRank >= 8(completed-float count, not a capped rank). -
A
MLResultexists for the current evaluation, withRiskScore > 0.97— a single threshold;evaluation.CFIState.HighestFloatis no longer part of the qualification decision (an earlier version branched on whether the user had paid back a $100+ float, using two different thresholds — simplified to one, with rollout risk managed entirely by the two flags instead).
Criterion 2 means TwoHundredTestOffered can only ever be set when the current evaluation is
approved. Since it’s a permanent decision (never re-evaluated) while base approval is recomputed
fresh on every rule run, it’s possible in principle for a later evaluation to deny a
previously-offered user. warnIfTwoHundredTestOfferedButDenied (called from Check() right after
evaluateTwoHundredFloatTest, regardless of the master flag’s current state) logs a warning if
this combination is ever observed — a pure monitoring check, not expected to fire in practice.
Rollout: Two Independent Per-User Percentages
Both GrowthBook flags are evaluated per-user (GetFeatureEnabledForUser(ctx, flag, userID, false))
and are expected to be configured in GrowthBook as sticky percentage rollouts keyed by user ID —
"sticky" meaning a given user’s bucketing outcome for a flag doesn’t change across evaluations, the
same property MLResult.Authoritative snapshotting and TwoHundredTestAvailable/TwoHundredTestOffered
already rely on elsewhere in this feature:
-
underwriting.two_hundred_float_test— what fraction of users even enter the test at all (e.g. 1%). Users outside this rollout never reach the risk-score check. -
underwriting.two_hundred_float_test_authoritative— of the users who qualify (pass every criterion above), what fraction get the authoritative (treatment) arm vs. control (e.g. 50/50).
Once a user meets every criterion above, underwriting.two_hundred_float_test_authoritative buckets
them into the authoritative (treatment) or control arm — mirroring the
Payback Predictor V2 enabled/authoritative two-flag pattern. Two
booleans are persisted onto dynamo.FloatProfile, once, permanently:
| Field | Meaning |
|---|---|
|
The user met every qualification criterion, regardless of which arm they landed in. Set for both arms, so there’s a stable comparison population for analyzing the experiment. |
|
The user was additionally bucketed into the authoritative arm — only these users actually see the $200 tier. |
Once 200_test_available is true, evaluateTwoHundredFloatTest short-circuits immediately on every
future call — the decision, once made, is never revisited or recomputed, even if the user’s risk score
or CFI data changes later. While false, it’s cheap to re-evaluate on every eligibility check (one
extra MLResults.GetByEvaluationID read) until the user either qualifies or the test ends.
Important: these two fields are never cleared or touched by the CFI increase-check path
(pkg/rulerunner/increase.go, saveNewFloatProfile) — that function takes the loaded FloatProfile
by value and only explicitly overwrites CFIEnabled, Floats, Reason, Notes, and
LastCFIRuleName, so any other field (including these two) survives a normal, unrelated CFI increase
untouched by ordinary Go value-copy semantics. Locked in by a regression test
(TestIncreaseFloatLimitPreservesTwoHundredTestFlags).
Hot-Path-Only Tier Injection
The stored FloatProfile.Floats array is never modified by this feature — the $200 tier is
injected only into API responses, in toAPIFloatProfile (pkg/api/float_profile.go), the single
shared conversion function used by four call sites. A checkTwoHundredTestLive bool parameter
splits its behavior by caller:
twoHundredTestShows := profile.TwoHundredTestOffered &&
(!checkTwoHundredTestLive || masterFlagIsOnLiveForThisUser)
if twoHundredTestShows {
// enable every tier <= $200 (FloatProfile.SetToLimit, on a copy — never from.Floats itself),
// not just the $200 row, so there's no gap like $80/$100 disabled while $200 shows enabled
inject an enabled $200 FloatProfileSetting into the response
}
| Caller | checkTwoHundredTestLive |
Why |
|---|---|---|
|
|
The app’s actual "check my eligibility" call — floatme-flutter never calls |
|
|
Same reasoning — another app-facing eligibility-check response. |
|
|
Called by float-service’s fee lookup ( |
|
|
Not a live eligibility check; moot in practice since |
Why GetFloatProfile can’t just also check live: if it did, a live recheck there would let the
master flag be turned off in the exact gap between "shown $200 in the app" and "submits the $200
float creation" — silently dropping the tier from float-service’s fee lookup, which falls back to
the default fee for an unmatched amount rather than erroring (getFeeFromProfile). That’s the
rug-pull this split avoids: the user would be charged the wrong fee for a float they were already
shown and committed to. Splitting by caller instead gets both properties at once — a fresh
eligibility check honors the kill switch, but an in-flight creation is never second-guessed.
The injection runs after the existing $80/$100 "naive" tier-injection check in toAPIFloatProfile
(which inspects the last, by-Id, entry’s Amount) — running before it would have the $200 row
(Id: "8") sort last and break that check.
floatme-flutter requires no changes for this: its float-amount tier buttons are rendered dynamically
from whatever float_profile.floats array the backend returns (no fixed enum), and additive JSON
fields are safely ignored by already-installed app versions.
Float-Created Tracking
pkg/floatcreated/handler.go’s `trackTwoHundredFloatTest (called from CreateHistoricalFloatEval,
alongside the existing settleMLResult step) fires whenever a $200 float is actually created
(float.Amount == 200, from the user_float_created event float-service emits). The FloatProfile
is fetched once (only for $200 floats) and reused both here and for the historical-eval field below:
-
If the user’s
FloatProfile.TwoHundredTestOfferedistrue, it logs"200 float created under two hundred float test"and emitsunderwriting.two_hundred_float_test.float_created(a Datadog count) — the log gives an immediately-searchable per-float record alongside the aggregate metric; watch either to manually disableunderwriting.two_hundred_float_testonce volume reaches 2800. -
If
TwoHundredTestOfferedisfalse(a $200 float created some other way, e.g. an organic CFI graduation), it’s logged ("200 float created unrelated to two hundred float test") but not counted toward the cap.
There is no automated enforcement of the 2800 cap — this is a manual, metric-driven cutoff by design.
Historical Evaluation Field
dynamo.HistoricalEvaluation.TwoHundredTestFloat (pkg/dynamo/evaluation_results.go) is set
alongside the log/metric above, on the same historical-evaluation record CreateHistoricalFloatEval
already saves for every created float — true only when this specific float was $200 and created
under the test. This lets the data team query funding volume directly off historical evaluations
(joinable with all their other reporting) rather than relying solely on the Datadog metric.
Ops Checklist Before Enabling
-
float-service’s fraud-check ceiling (
MAX_FLOAT_AMOUNT,cmd/commons/config.goin float-service) must be deployed at ≥ 200 — its Terraform default is 200 (deploy/variables.tf:229-233in float-service), but the actual deployed value lives outside that repo and should be confirmed before enabling the test. -
GrowthBook’s loan/float fee schedule (
GBLoanFeesConfigName) should have a"200"entry — if missing, the injected $200 tier’s fee will show as$0rather than erroring, since `toAPIFloatSetting’s fee lookup is a plain map read with no missing-key guard. -
No code changes are required in float-service or floatme-flutter for this feature.
See Also
-
Payback Predictor V2 — the risk-score source and the
enabled/authoritativetwo-flag pattern this test mirrors -
Continuous Float Increase — the organic $200 graduation path this test is separate from
-
Float Profiles —
FloatProfileschema and persistence model