Skip to content

Commit 09faeef

Browse files
aepfliCopilot
andcommitted
feat(fractional): support nested JSON Logic expressions as bucket variant names
The operator already recurses into array elements via evaluator.evaluate(), so nested expressions (if, var, fractional) in bucket variant positions worked at runtime. The only blocker was the JSON Schema enforcing "type": "string" for fractionalWeightArg[0]. Change fractionalWeightArg first element to oneOf[string, anyRule] so the schema accepts JSON Logic objects as variant names in strict mode. Also: - pub mod fractional (was private) to allow direct access in tests - Add integration tests for nested if, nested fractional, and var as bucket variant names (closes #30) - Add statistical distribution uniformity test (mirrors Java PR #1740) - Add boundary-key test (mirrors Java PR #1740 edgeCasesDoNotThrow) Signed-off-by: Simon Schrottner <simon.schrottner@aepfli.at> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
1 parent 824d1ee commit 09faeef

File tree

3 files changed

+208
-3
lines changed

3 files changed

+208
-3
lines changed

schemas/targeting.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,8 +461,15 @@
461461
"maxItems": 2,
462462
"items": [
463463
{
464-
"description": "If this bucket is randomly selected, this string is used to as a key to retrieve the associated value from the \"variants\" object.",
465-
"type": "string"
464+
"description": "If this bucket is randomly selected, this value (or the string result of evaluating this JSON Logic expression) is used as a key to retrieve the associated value from the \"variants\" object.",
465+
"oneOf": [
466+
{
467+
"type": "string"
468+
},
469+
{
470+
"$ref": "#/definitions/anyRule"
471+
}
472+
]
466473
},
467474
{
468475
"description": "Weighted distribution for this variant key.",

src/operators/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
//! - `sem_ver.rs`: Semantic version comparison
2222
2323
mod common;
24-
mod fractional;
24+
pub mod fractional;
2525
mod sem_ver;
2626

2727
pub use fractional::FractionalOperator;

tests/integration_tests.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,3 +1463,201 @@ fn test_update_state_flag_set_metadata_replaces_on_second_call() {
14631463
assert_eq!(m2.get("env"), Some(&json!("production")));
14641464
assert_eq!(m2.get("owner"), Some(&json!("team-a")));
14651465
}
1466+
1467+
// ============================================================================
1468+
// Nested fractional argument evaluation (flagd#1676)
1469+
// ============================================================================
1470+
1471+
/// Helper: create a strict evaluator and load config, panicking on failure.
1472+
fn strict_eval_with(config: &str) -> FlagEvaluator {
1473+
let mut eval = FlagEvaluator::new(ValidationMode::Strict);
1474+
let resp = eval.update_state(config).unwrap();
1475+
assert!(resp.success, "update_state failed: {:?}", resp.error);
1476+
eval
1477+
}
1478+
1479+
#[test]
1480+
fn test_nested_if_in_bucket_variant_name() {
1481+
use serde_json::json;
1482+
// Variant name is a {"if": ...} expression — same email maps to the same bucket,
1483+
// but the resolved variant depends on the locale context field.
1484+
let config = json!({
1485+
"flags": {
1486+
"color-flag": {
1487+
"state": "ENABLED",
1488+
"variants": {"red": "#FF0000", "grey": "#808080", "blue": "#0000FF"},
1489+
"defaultVariant": "grey",
1490+
"targeting": {
1491+
"fractional": [
1492+
{"var": "email"},
1493+
[{"if": [{"in": [{"var": "locale"}, ["us", "ca"]]}, "red", "grey"]}, 50],
1494+
["blue", 50]
1495+
]
1496+
}
1497+
}
1498+
}
1499+
});
1500+
let eval = strict_eval_with(&config.to_string());
1501+
1502+
// jon@company.com hashes into the first bucket (verified by gherkin v2 test)
1503+
let ctx_us = json!({"email": "jon@company.com", "locale": "us"});
1504+
let ctx_de = json!({"email": "jon@company.com", "locale": "de"});
1505+
1506+
let res_us = eval.evaluate_flag("color-flag", ctx_us);
1507+
let res_de = eval.evaluate_flag("color-flag", ctx_de);
1508+
1509+
// Same email → same bucket, but different locale → different resolved variant
1510+
assert_eq!(
1511+
res_us.variant.as_deref(),
1512+
Some("red"),
1513+
"US locale should resolve to 'red'"
1514+
);
1515+
assert_eq!(
1516+
res_de.variant.as_deref(),
1517+
Some("grey"),
1518+
"DE locale should resolve to 'grey'"
1519+
);
1520+
}
1521+
1522+
#[test]
1523+
fn test_nested_fractional_inside_fractional() {
1524+
use serde_json::json;
1525+
// Nested fractional: outer splits on email 50/50, inner splits on tier 50/50.
1526+
let config = json!({
1527+
"flags": {
1528+
"experiment": {
1529+
"state": "ENABLED",
1530+
"variants": {"red": 1, "blue": 2, "green": 3, "yellow": 4},
1531+
"defaultVariant": "red",
1532+
"targeting": {
1533+
"fractional": [
1534+
{"var": "email"},
1535+
[{"fractional": [{"var": "tier"}, ["red", 50], ["blue", 50]]}, 50],
1536+
[{"fractional": [{"var": "tier"}, ["green", 50], ["yellow", 50]]}, 50]
1537+
]
1538+
}
1539+
}
1540+
}
1541+
});
1542+
let eval = strict_eval_with(&config.to_string());
1543+
1544+
// All combinations should resolve to one of the four valid variants
1545+
for email in ["a@x.com", "b@x.com", "c@x.com", "d@x.com", "e@x.com"] {
1546+
for tier in ["free", "pro", "enterprise"] {
1547+
let ctx = json!({"email": email, "tier": tier});
1548+
let res = eval.evaluate_flag("experiment", ctx);
1549+
assert!(
1550+
["red", "blue", "green", "yellow"].contains(&res.variant.as_deref().unwrap_or("")),
1551+
"email={email} tier={tier} → unexpected variant {:?}",
1552+
res.variant
1553+
);
1554+
}
1555+
}
1556+
}
1557+
1558+
#[test]
1559+
fn test_nested_var_in_bucket_variant_name() {
1560+
use serde_json::json;
1561+
// Variant name is a {"var": "preferred_color"} — each user carries their own preference.
1562+
let config = json!({
1563+
"flags": {
1564+
"theme-flag": {
1565+
"state": "ENABLED",
1566+
"variants": {"dark": "dark-theme", "light": "light-theme"},
1567+
"defaultVariant": "light",
1568+
"targeting": {
1569+
"fractional": [
1570+
{"var": "email"},
1571+
[{"var": "preferred_theme"}, 50],
1572+
["light", 50]
1573+
]
1574+
}
1575+
}
1576+
}
1577+
});
1578+
let eval = strict_eval_with(&config.to_string());
1579+
1580+
// jon@company.com hashes into first bucket; preferred_theme drives the variant
1581+
let ctx_dark = json!({"email": "jon@company.com", "preferred_theme": "dark"});
1582+
let ctx_light = json!({"email": "jon@company.com", "preferred_theme": "light"});
1583+
1584+
let res_dark = eval.evaluate_flag("theme-flag", ctx_dark);
1585+
let res_light = eval.evaluate_flag("theme-flag", ctx_light);
1586+
1587+
assert_eq!(res_dark.variant.as_deref(), Some("dark"));
1588+
assert_eq!(res_light.variant.as_deref(), Some("light"));
1589+
}
1590+
1591+
// ============================================================================
1592+
// Statistical distribution test (mirrors Java PR #1740 statistics() test)
1593+
// ============================================================================
1594+
1595+
#[test]
1596+
fn test_fractional_distribution_uniformity() {
1597+
use flagd_evaluator::operators::fractional::fractional;
1598+
use serde_json::json;
1599+
1600+
// 16 equal-weight buckets totalling i32::MAX (matches Java test exactly)
1601+
let total_weight: u64 = i32::MAX as u64;
1602+
let buckets_count: u64 = 16;
1603+
let weight = total_weight / buckets_count;
1604+
let remainder = total_weight - weight * (buckets_count - 1);
1605+
1606+
let mut bucket_defs: Vec<serde_json::Value> = Vec::new();
1607+
for i in 0..buckets_count - 1 {
1608+
bucket_defs.push(json!(format!("{}", i)));
1609+
bucket_defs.push(serde_json::Value::Number(weight.into()));
1610+
}
1611+
bucket_defs.push(json!(format!("{}", buckets_count - 1)));
1612+
bucket_defs.push(serde_json::Value::Number(remainder.into()));
1613+
1614+
let mut hits = vec![0u64; buckets_count as usize];
1615+
1616+
// 100k sequential keys — dense sample, fast, and tight enough for 10% tolerance.
1617+
for i in 0u64..100_000 {
1618+
let key = format!("{}", i);
1619+
let bucket_str = fractional(&key, &bucket_defs).unwrap();
1620+
let bucket_idx: usize = bucket_str.parse().unwrap();
1621+
hits[bucket_idx] += 1;
1622+
}
1623+
1624+
let min = *hits.iter().min().unwrap();
1625+
let max = *hits.iter().max().unwrap();
1626+
let delta = max - min;
1627+
1628+
let sample_size: u64 = 100_000;
1629+
let expected_per_bucket = sample_size / buckets_count;
1630+
let tolerance = expected_per_bucket / 10; // 10% tolerance
1631+
1632+
assert!(
1633+
delta < tolerance,
1634+
"Distribution imbalance too large: max={max} min={min} delta={delta} (tolerance={tolerance}). hits={hits:?}"
1635+
);
1636+
}
1637+
1638+
#[test]
1639+
fn test_fractional_boundary_hashes_do_not_panic() {
1640+
use flagd_evaluator::operators::fractional::fractional;
1641+
use serde_json::json;
1642+
1643+
let buckets = vec![
1644+
json!("a"),
1645+
json!(4),
1646+
json!("b"),
1647+
json!(4),
1648+
json!("c"),
1649+
json!(4),
1650+
json!("d"),
1651+
json!(4),
1652+
];
1653+
1654+
// Keys chosen to produce boundary-adjacent hash values
1655+
for key in ["", "0", "a", "\0", "ffffffff"] {
1656+
let result = fractional(key, &buckets);
1657+
assert!(result.is_ok(), "key={key:?} should not error: {:?}", result);
1658+
assert!(
1659+
["a", "b", "c", "d"].contains(&result.unwrap().as_str()),
1660+
"key={key:?} must map to a valid bucket"
1661+
);
1662+
}
1663+
}

0 commit comments

Comments
 (0)