Skip to content

feat(flags): multivariate flags with per-rule rollout and percentage splits - #66

Merged
basb7 merged 25 commits into
mainfrom
feat/segment-rollout-percentage
Sep 28, 2026
Merged

basb7 merged 25 commits into
mainfrom
feat/segment-rollout-percentage

Conversation

@basb7

@basb7 basb7 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

FlagType.MULTIVARIATE has been declared since the start, but nothing evaluated it: evaluate_flag could 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, a PERCENTAGE_SPLIT condition, the SDK wire format that carries all of it, and the dashboard to edit it.

The SDKs are already out. @flagward/core@0.3.0 and 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.py

  • Variant (name, percentage_allocation, is_control), and on StrategyRule a rollout_variant + rollout_percentage.
  • Evaluation, in order: an override wins; disabled → off; for multivariate, only the first matching rule counts. If it has no rollout_variant, or the user misses its percentage, evaluation falls through to the flag's general split, not to control. With no user_id, the result is the control variant.
  • The bucket is 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 in tests/unit/test_evaluation.py, and the TypeScript SDK pins the same seven.
  • PERCENTAGE_SPLIT uses the same bucket, so raising a percentage only ever adds users.

2. Admin API: core_flags/api/

  • VariantViewSet at /api/v1/variants/, plus POST|PUT /api/v1/flags/{id}/variants/. The PUT replaces the whole set: rows without an id are created and omitted ids are deleted; a foreign id is a 400.
  • Validation: exactly one control once there are two or more variants, and weights that add up. The control and the last variant cannot be deleted, and a variant a rule still points to is protected.
  • Everything is tenant-scoped; tests/integration/test_tenant_scoping.py covers the new endpoints.

3. SDK payload and evaluation log: sdk_api/

  • The payload now carries variants (in the order the server walks them, with is_control) and the per-rule rollout_variant / rollout_percentage. An override still strips both.
  • EvaluationLog.result changes from boolean to string, so it can record the variant.

4. Dashboard: frontend/src/app/dashboard/flags/

  • A variant editor on the flag form and on the rules page: add and remove rows, a weight per row, and the control row derived and not deletable.
  • A single rollout slider per rule, and PERCENTAGE_SPLIT in the condition builder. New strings in en and es.

Before deploying

  • sdk_api 0004/0005 rewrites EvaluationLog: the migration adds a temporary column, backfills it with UPDATEs, then drops and renames. A direct AlterField would 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_flags 0005 is state-only (sqlmigrate is a no-op). It was missing on this branch and is added here, so makemigrations --check is clean.

Checks

  • Backend: pytest 686 passed, ruff clean, makemigrations --check: no changes
  • Frontend: vitest 188 passed, biome clean, next build OK

basb7 and others added 25 commits September 17, 2026 22:37
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.
@basb7
basb7 merged commit b84390c into main Sep 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant