exd fixtures
Deterministic generator for SDK-level test inputs. Walks a flag's rule chain, synthesizes a representative context per rule, and emits (ctx, expected variant, why) rows in a paste-ready form.
Synopsis
exd fixtures <flag> --env <env>
[--manifest <path-or-uri>]
[--coverage rules|rules+fallthrough|exhaustive]
[--seed <prefix>]
[--format human|json|rust|typescript]
[--http-backend curl|in-process]
Description
Generates the test inputs you would otherwise hand-write to verify "this rule fires for this audience". The same --seed and the same manifest always produce byte-identical output, so generated fixtures can be checked into the repo and diffs only appear when the manifest actually changed.
Fixtures vs. schema.
exd schemaemits the type of context attributes (the input shape).exd fixturesemits concrete examples with expected variants — example-based testing, not type-based.
Use cases
-
Bootstrap a test suite. Generate the initial fixture table for an SDK consumer, then commit it:
exd fixtures onboarding-banner --env prod --format rust > tests/onboarding_banner_fixtures.rs -
Catch off-by-one bucket bugs. Use
--coverage exhaustiveto add boundary-row tests (one id per bucket-range endpoint, plus boundary-miss):exd fixtures onboarding-banner --env prod --coverage exhaustive -
Regression-test a rule change. Generated fixtures encode the previous behavior. A rule rewrite that changes a variant assignment shows up as a diff in the fixture file — instant regression signal.
-
Agent-authored tests. An agent writing tests for code that consumes a flag can synthesize the fixture table from the manifest in one call, instead of inventing context dicts.
-
TypeScript / Rust codegen.
--format rustand--format typescriptemit a typed const directly; no further processing needed.
Arguments and flags
| Argument / flag | Required | Notes |
|---|---|---|
<flag> (positional) | yes | Flag key. |
--env <env> | yes | Environment slug. |
--coverage <mode> | no, default rules+fallthrough | See Coverage modes. |
--seed <prefix> | no, default "u-" | Id prefix for bucket synthesis. Used as format!("{seed}{n}") for n = 1, 2, …. Determinism: same --seed + same manifest → byte-identical output. |
--manifest <path-or-uri> | no | Defaults to .. |
--format human|json|rust|typescript | no, default human | See Output formats. |
--http-backend curl|in-process | no | URI loads only. |
Coverage modes
| Mode | Rows emitted |
|---|---|
rules | One per rule (in declared order across the resolution walk): a context that matches that rule's audience — predicate satisfied AND bucket hit, when both apply. |
rules+fallthrough (default) | All rules rows, plus (a) one catch-all row whose expected variant comes from the four-step fallthrough; plus (b) one predicate-satisfied / bucket-miss row per predicate+bucket segment — distinguishes geo-gate failures from bucket-boundary failures. |
exhaustive | All rules+fallthrough rows, plus for each bucket range [s, e] two boundary rows: one id whose bucket is exactly s, one whose bucket is exactly e+1. For catching off-by-one bucket bugs. |
Output formats
--format human (default)
$ exd fixtures onboarding-banner --env prod
fixture variant why
──────────────────────────────────────── ──────── ─────────────────────────────────────────────────────
user.id=u-1, user.country=US control bucket=1832 ∈ [0,3299], country=US satisfies seg
user.id=u-4, user.country=US treat_a bucket=5104 ∈ [3300,6599], country=US satisfies seg
user.id=u-9, user.country=US treat_b bucket=8742 ∈ [6600,9999], country=US satisfies seg
user.id=u-4, user.country=DE control predicate user.country=US fails → fallthrough
--format rust
// Generated by `exd fixtures onboarding-banner --env prod --format rust`.
// Manifest fingerprint: marketing@a1b2c3 — rerun if salts, ranges, or rule order change.
pub const ONBOARDING_BANNER_FIXTURES: &[(&[(&str, &str)], &str)] = &[
// why: bucket=1832 ∈ [0,3299], user.country=US satisfies onboarding-banner-bucket-control
(&[("user.id", "u-1"), ("user.country", "US")], "control"),
// why: bucket=5104 ∈ [3300,6599], user.country=US satisfies onboarding-banner-bucket-treat-a
(&[("user.id", "u-4"), ("user.country", "US")], "treat_a"),
// ...
];
Const name: SCREAMING_SNAKE_CASE(<flag-key>) + "_FIXTURES". All values are string literals; the SDK auto-coerces numerics and bools when typed predicates demand them.
--format typescript
// Generated by `exd fixtures onboarding-banner --env prod --format typescript`.
// Manifest fingerprint: marketing@a1b2c3 — rerun if salts, ranges, or rule order change.
export const ONBOARDING_BANNER_FIXTURES: ReadonlyArray<{
ctx: Record<string, string>;
variant: string;
why: string;
}> = [
{ ctx: { "user.id": "u-1", "user.country": "US" }, variant: "control",
why: "bucket=1832 ∈ [0,3299], user.country=US satisfies onboarding-banner-bucket-control" },
// ...
];
Const name matches the Rust form.
--format json
{
"query": "fixtures",
"result": {
"manifest_fingerprint": "marketing@a1b2c3",
"rows": [
{
"ctx": { "user.id": "u-1", "user.country": "US" },
"variant": "control",
"why": "bucket=1832 ∈ [0,3299], ...",
"synth": { "rule_index": 0, "kind": "rule_match" }
}
]
}
}
synth.kind ∈ { "rule_match" | "fallthrough" | "predicate_satisfied_bucket_miss" | "bucket_boundary" }. synth.rule_index is -1 for the catch-all fallthrough row.
When synthesis fails
Some predicates cannot be auto-synthesized (deep negation, unsupported operator, unbounded numeric domain, conflicting and constraints, unreachable bucket). The row is still emitted, with TBD placeholders and a gap explanation:
// why: predicate too complex to auto-synthesize (deep negation) — fill manually
(&[("user.id", "TBD"), ("user.country", "TBD")], "TBD"),
In JSON:
{
"ctx": { "user.id": "TBD" },
"variant": "TBD",
"synth": { "rule_index": 2, "kind": "rule_match",
"gap": { "code": "deep_negation", "message": "..." } }
}
gap.code ∈ { "deep_negation" | "unbounded_domain" | "unsupported_operator" | "bucket_unreachable" | "conflict" }.
Exit code stays 0 on TBD rows. Synthesis failure is a known limitation of the inferred-only synthesizer, not a tool error. CI consumers that want loud failure on TBD rows can grep for "TBD" in the output or check result.rows[*].synth.gap in JSON.
Exit codes
| Code | Condition |
|---|---|
0 | Success, including outputs containing TBD rows. |
1 | Unknown flag; manifest lint errors; typed-mode unknown env. |
2 | Bad CLI args; manifest URI fetch failure. |
Synthesis algorithm
Predicate synthesis is mechanical, one rule per model::Operator variant. The table below is the contract; the implementation lives in crates/exd-client/src/eval/synth.rs.
| Operator | Synthesis |
|---|---|
eq v | assign v |
neq v | assign a fresh value ≠ v |
in [a, b, c] | assign a (the first element) |
not_in [a, b, …] | assign a fresh value not in the list |
gt n | assign n + 1 |
gte n | assign n |
lt n | assign n - 1 |
lte n | assign n |
starts_with p | assign p + "x" |
ends_with p | assign "x" + p |
contains p | assign "x" + p + "y" |
not_contains p | assign a fresh string not containing p |
is_set | include the attribute with any synthesized value |
is_not_set | omit the attribute |
semver_* | use 0.0.0, 0.0.1, etc. per the comparator |
and [a, b, …] | union the sub-syntheses; conflicting demands on the same attribute → SynthGap::Conflict |
or [a, b, …] | first satisfiable branch's synthesis |
not <inner> | one-level negation: each atom is replaced by its opposite (eq ↔ neq, in ↔ not_in, etc.). Deeper nested negations → SynthGap::DeepNegation |
<segment ref> | resolve the referenced segment and synthesize its predicate, then continue. Cycles are impossible by lint diagnostic E029. |
Bucket synthesis (rule's audience is a bucket-segment): scan ids format!("{seed}{n}") for n = 1, 2, … until murmur3_x86_32(salt || "/" || id) % 10000 ∈ [start, end]. Max-scan budget: 100,000 ids. Exceeded → SynthGap::BucketUnreachable { segment, seed, scanned: 100_000 }.
Predicate+bucket synthesis (canonical conjunctive segment): synthesize the predicate first, then scan ids that also satisfy it.
Bucket-miss synthesis (for predicate_satisfied_bucket_miss rows): keep the predicate-satisfied context, scan ids until one's bucket is outside every range in the rule chain. Same budget.
See also
exd schema— the type shape of context attributes (what--ctxkeys mean).exd eval/exd explain— inspect specific contexts and the rule chain.