feat(flags): multivariate flags with per-rule rollout and percentage splits - #66
Merged
Merged
Conversation
StrategyRule gains rollout_variant + rollout_percentage; MULTIVARIATE evaluation checks the rule's own rollout first, then falls through to the flag's global split. Same md5 bucket feeds both paths.
VariantViewSet with whole-set validation; StrategyRuleSerializer enforces same-flag, MULTIVARIATE-only, 0-100 range, defaults omitted percentage to 100.
EvaluationLog.result becomes CharField holding true/false or the variant name; flag payload includes variants array.
Rule dialog shows variant select plus a single 0-100 slider with live readout; variant breakdown and editor on the flag pages.
PERCENTAGE_SPLIT gates segment entry on the identity bucket (md5 user_id:flag_id), Flagsmith-style: no trait needed, missing user_id never enters, out-of-range values fail closed. Serializer normalizes to the wrapped shape and enforces 0-100.
Condition dialog hides the attribute field for % Split and shows one 0-100 slider; values are sent wrapped with the user_id convention; table renders the split as N%.
Both variant editors pair each number input with a 0-100 slider bound to the same draft: drag to move fast, type for exact values.
Mirrors Flagsmith's variant editor: the control row's percentage is always 100 minus the sum of the other variants, disabled for direct edit, and non-control rows are capped by remaining headroom instead of allowing an invalid total.
Its percentage is derived, not editable, so a disabled slider was misleading. Only the treatment/non-control rows keep a slider now.
- evaluate_flag: a rule with rollout_variant set but rollout_percentage left None (the model's documented default) crashed with TypeError instead of behaving like rollout_percentage=100. - VariantViewSet.destroy: deleting a flag's last remaining variant, or its control variant while others remain, left evaluation unable to find a control variant on the next call.
The control percentage is derived (100 minus the rest), so its row shows read-only text and no switch; the unreachable sum-100 warning and tautological validation go away with it.
Variant rows go back to the number input alone; the control tooltip and its unused copy go away with the sliders.
Each variant percentage carries a Weight label above it with an info tooltip: shares must add up to 100 and control keeps the remainder.
The control row keeps its editable name with a Control (default) badge beside it, marking that name as the fallback.
The control row renders no remove button; only added variants can be removed, so control always exists. The API already refuses control and last-variant deletion.
Edit rows mark control with muted text next to its editable name.
The control marker moves from inline to above the name input, mirroring the Weight label above the percentage.
- Flags list showed the raw BOOLEAN/MULTIVARIATE enum value instead of a translated, styled badge like the rest of the table. - The rule dialog's % Split condition and Rollout Percentage sliders looked interchangeable; their hints now say they stack rather than duplicate each other, and when to leave the rollout at 100.
PUT /api/v1/flags/{id}/variants/ used to require the submitted set to
match the flag's existing variant ids exactly. Now an item with no id
creates a new variant, an existing id omitted from the list deletes
it (rejecting the whole request first if any removed variant is
forced by a strategy rule), and everything still lands in one atomic,
whole-set-validated transaction.
The Variant Breakdown editor on an existing flag's rules page could only rename/rebalance its exact existing variant set. Add an "Add variant" button and a per-row remove button (disabled at one row, control auto-reassigned on removal), mirroring the flag-creation dialog's editor. New rows are saved without an id so the backend creates them; removed rows are simply omitted from the PUT payload.
The rules-page variant editor's new remove button rendered on every row, including control, and let it be deleted with an auto-promote fallback. That contradicts the already-established rule elsewhere on this branch: the control variant is permanent and never exposes a remove button (flags/page.tsx). Only non-control rows are removable here now, with no artificial floor on how many can be removed.
…variants
Move `_hash_bucket`'s salt from flag.id to flag.key everywhere (variant
split and the PERCENTAGE_SPLIT condition) so SDKs, which only ever see
the wire payload's flag key, can reproduce the server's bucket exactly.
Serialize payload variants in the server's split order (order_by("id"))
with is_control, and pin the shared cross-language hash vectors.
State-only: the column's max_length already fits, sqlmigrate is a no-op. Without it makemigrations --check fails and the next unrelated migration would pick it up.
… algorithm The README still listed multivariate flags as not implemented. It now covers creating one, the variant endpoints, the wire format and the local evaluation an SDK must reproduce, useVariant, and the remaining gap: no experiment analysis.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
FlagType.MULTIVARIATEhas been declared since the start, but nothing evaluated it:evaluate_flagcould only return a boolean. This makes multivariate flags real, end to end: variants with weights and a control, rules that roll a percentage of a segment out to a specific variant, aPERCENTAGE_SPLITcondition, the SDK wire format that carries all of it, and the dashboard to edit it.The SDKs are already out.
@flagward/core@0.3.0and the adapters (react 0.4.0, vue 0.3.0, solid 0.2.0, svelte 0.2.0; basb7/flagward-sdk-js#12) evaluate this payload bucket for bucket. That ordering is deliberate: an older SDK reading the new payload does not crash, it silently ignores the rollout percentage.It is one PR on purpose: the 23 commits interleave the layers, so slicing them would mean reordering finished work. The sections below follow the layers, so it can be read as four.
1. Model and evaluation:
core_flags/models.py,core_flags/services.pyVariant(name,percentage_allocation,is_control), and onStrategyRulearollout_variant+rollout_percentage.rollout_variant, or the user misses its percentage, evaluation falls through to the flag's general split, not to control. With nouser_id, the result is the control variant.int(md5(f"{user_id}:{flag_key}").hexdigest()[:8], 16) % 10000 / 100, salted by the flag key so the SDKs can compute it from the payload. Seven vectors are pinned intests/unit/test_evaluation.py, and the TypeScript SDK pins the same seven.PERCENTAGE_SPLITuses the same bucket, so raising a percentage only ever adds users.2. Admin API:
core_flags/api/VariantViewSetat/api/v1/variants/, plusPOST|PUT /api/v1/flags/{id}/variants/. ThePUTreplaces the whole set: rows without anidare created and omitted ids are deleted; a foreign id is a 400.tests/integration/test_tenant_scoping.pycovers the new endpoints.3. SDK payload and evaluation log:
sdk_api/variants(in the order the server walks them, withis_control) and the per-rulerollout_variant/rollout_percentage. An override still strips both.EvaluationLog.resultchanges from boolean to string, so it can record the variant.4. Dashboard:
frontend/src/app/dashboard/flags/PERCENTAGE_SPLITin the condition builder. New strings inenandes.Before deploying
sdk_api0004/0005 rewritesEvaluationLog: the migration adds a temporary column, backfills it withUPDATEs, then drops and renames. A directAlterFieldwould have written"t"/"f"on PostgreSQL. The runtime scales with the size of that table, and the reverse migration does not restore the booleans.core_flags0005 is state-only (sqlmigrateis a no-op). It was missing on this branch and is added here, somakemigrations --checkis clean.Checks
pytest686 passed,ruffclean,makemigrations --check: no changesvitest188 passed,biomeclean,next buildOK