Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 201 additions & 15 deletions crates/cac_client/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use superposition_types::{logic::evaluate_local_cohorts, Config, Overrides};

use crate::{utils::core::MapError, Context, MergeStrategy};

type KeyTransitions = Vec<Value>;
type ReasoningHook<'a> = &'a mut dyn FnMut(Context, &Overrides, KeyTransitions);

pub fn merge(doc: &mut Value, patch: &Value) {
if !patch.is_object() {
*doc = patch.clone();
Expand All @@ -20,6 +23,43 @@ pub fn merge(doc: &mut Value, patch: &Value) {
}
}

fn merged_value(
previous: Option<&Value>,
patch: &Value,
merge_strategy: &MergeStrategy,
) -> Value {
match merge_strategy {
MergeStrategy::REPLACE => patch.clone(),
MergeStrategy::MERGE => {
let mut next = previous.cloned().unwrap_or(Value::Null);
merge(&mut next, patch);
next
}
}
}

fn build_key_transitions(
current_overrides: &Value,
override_patch: &Overrides,
merge_strategy: &MergeStrategy,
) -> KeyTransitions {
let current_map = current_overrides.as_object();

override_patch
.iter()
.map(|(key, patch)| {
let previous = current_map.and_then(|map| map.get(key)).cloned();
let next = merged_value(previous.as_ref(), patch, merge_strategy);

json!({
"key": key,
"previous": previous,
"next": next,
})
})
.collect()
}

fn replace_top_level(
doc: &mut Map<String, Value>,
patch: &Value,
Expand All @@ -44,34 +84,52 @@ fn get_overrides(
contexts: &[Context],
overrides: &HashMap<String, Overrides>,
merge_strategy: &MergeStrategy,
mut on_override_select: Option<&mut dyn FnMut(Context)>,
mut on_override_select: Option<ReasoningHook<'_>>,
) -> serde_json::Result<Value> {
let mut required_overrides: Value = json!({});
let mut on_override_select = |context: Context| {
if let Some(ref mut func) = on_override_select {
func(context)
}
};
let mut on_override_select =
|context: Context,
override_patch: &Overrides,
key_transitions: KeyTransitions| {
if let Some(ref mut func) = on_override_select {
func(context, override_patch, key_transitions)
}
};

for context in contexts {
let valid_context = superposition_types::apply(&context.condition, query_data);

if valid_context {
let override_key = context.override_with_keys.get_key();
if let Some(overriden_value) = overrides.get(override_key) {
let key_transitions = build_key_transitions(
&required_overrides,
overriden_value,
merge_strategy,
);
Comment on lines +105 to +109
match merge_strategy {
MergeStrategy::REPLACE => replace_top_level(
required_overrides.as_object_mut().unwrap(),
&Value::Object(overriden_value.clone().into()),
|| on_override_select(context.clone()),
|| {
on_override_select(
context.clone(),
overriden_value,
key_transitions.clone(),
)
},
override_key,
),
MergeStrategy::MERGE => {
merge(
&mut required_overrides,
&Value::Object(overriden_value.clone().into()),
);
on_override_select(context.clone())
on_override_select(
context.clone(),
overriden_value,
key_transitions,
)
}
}
}
Expand Down Expand Up @@ -106,7 +164,7 @@ pub fn eval_cac(
merge_strategy: MergeStrategy,
) -> Result<Map<String, Value>, String> {
let mut default_config = (*config.default_configs).clone();
let on_override_select: Option<&mut dyn FnMut(Context)> = None;
let on_override_select: Option<ReasoningHook<'_>> = None;
let modified_query_data = evaluate_local_cohorts(&config.dimensions, query_data);
let overrides: Map<String, Value> = get_overrides(
&modified_query_data,
Expand All @@ -128,7 +186,7 @@ pub fn eval_cac_with_reasoning(
merge_strategy: MergeStrategy,
) -> Result<Map<String, Value>, String> {
let mut default_config = (*config.default_configs).clone();
let mut reasoning: Vec<Value> = vec![];
let mut reasoning: Map<String, Value> = Map::new();

let modified_query_data = evaluate_local_cohorts(&config.dimensions, query_data);

Expand All @@ -137,11 +195,34 @@ pub fn eval_cac_with_reasoning(
&config.contexts,
&config.overrides,
&merge_strategy,
Some(&mut |context| {
reasoning.push(json!({
"context": context.condition,
"override": context.override_with_keys
}))
Some(&mut |context, _override_patch, key_transitions| {
let override_id = context.override_with_keys.get_key().clone();

for transition in key_transitions {
let Some(key) = transition
.get("key")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
continue;
};

let history = reasoning
.entry(key)
.or_insert_with(|| Value::Array(vec![]));

if let Value::Array(entries) = history {
entries.push(json!({
"context_id": context.id.clone(),
"condition": context.condition.clone(),
"priority": context.priority,
"weight": context.weight,
"override_id": override_id.clone(),
"previous": transition.get("previous").cloned().unwrap_or(Value::Null),
"next": transition.get("next").cloned().unwrap_or(Value::Null),
}));
}
}
}),
)
.and_then(serde_json::from_value)
Expand All @@ -156,3 +237,108 @@ pub fn eval_cac_with_reasoning(
overriden_config.insert("metadata".into(), json!(reasoning));
Ok(overriden_config)
}

#[cfg(test)]
mod tests {
use super::*;
use superposition_types::{Context, ExtendedMap, OverrideWithKeys};

fn test_context(
id: &str,
condition: Map<String, Value>,
override_id: &str,
) -> Context {
Context {
id: id.to_string(),
condition: serde_json::from_value(Value::Object(condition)).unwrap(),
priority: 1,
weight: 1,
override_with_keys: OverrideWithKeys::new(override_id.to_string()),
}
}

fn base_config() -> Config {
let mut default_configs = Map::new();
default_configs.insert("featureA".to_string(), json!(false));
default_configs.insert("settings".to_string(), json!({ "mode": "default" }));

let mut first_override = Map::new();
first_override.insert("featureA".to_string(), json!(true));
first_override.insert("settings".to_string(), json!({ "mode": "first" }));

let mut second_override = Map::new();
second_override.insert("settings".to_string(), json!({ "mode": "second" }));

let mut first_condition = Map::new();
first_condition.insert("clientId".to_string(), json!("android"));

let mut second_condition = Map::new();
second_condition.insert("country".to_string(), json!("IN"));

Config {
contexts: vec![
test_context("ctx-1", first_condition, "override-1"),
test_context("ctx-2", second_condition, "override-2"),
],
overrides: HashMap::from([
(
"override-1".to_string(),
serde_json::from_value(Value::Object(first_override)).unwrap(),
),
(
"override-2".to_string(),
serde_json::from_value(Value::Object(second_override)).unwrap(),
),
]),
default_configs: ExtendedMap::from(default_configs),
dimensions: HashMap::new(),
}
}

#[test]
fn eval_cac_with_reasoning_groups_metadata_by_config_key() {
let config = base_config();
let query = Map::from_iter([
("clientId".to_string(), json!("android")),
("country".to_string(), json!("IN")),
]);

let resolved = eval_cac_with_reasoning(&config, &query, MergeStrategy::MERGE)
.expect("reasoning config should resolve");

Comment on lines +298 to +308
let metadata = resolved
.get("metadata")
.and_then(Value::as_object)
.expect("metadata should be grouped by config key");

let settings_history = metadata
.get("settings")
.and_then(Value::as_array)
.expect("settings history should be recorded");
assert_eq!(settings_history.len(), 2);
assert_eq!(settings_history[0]["previous"], Value::Null);
assert_eq!(settings_history[1]["previous"], json!({ "mode": "first" }));
assert_eq!(settings_history[1]["next"], json!({ "mode": "second" }));

let feature_history = metadata
.get("featureA")
.and_then(Value::as_array)
.expect("featureA history should be recorded");
assert_eq!(feature_history.len(), 1);
assert_eq!(feature_history[0]["next"], json!(true));
}

#[test]
fn eval_cac_without_reasoning_omits_metadata() {
let config = base_config();
let query = Map::from_iter([
("clientId".to_string(), json!("android")),
("country".to_string(), json!("IN")),
]);

let resolved = eval_cac(&config, &query, MergeStrategy::MERGE)
.expect("config should resolve");

assert!(resolved.get("metadata").is_none());
}
}
36 changes: 34 additions & 2 deletions crates/frontend/src/pages/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ pub fn Home() -> impl IntoView {
.unwrap_or_default();
logging::log!("resolved config {:#?}", config);
// unstrike those that we want to show the user
// if metadata field is found, unstrike only that override
// if metadata field is found, unstrike only the applied overrides
match config.remove("metadata") {
Some(Value::Array(metadata)) => {
if metadata.is_empty() {
Expand All @@ -298,9 +298,41 @@ pub fn Home() -> impl IntoView {
});
}
}
Some(Value::Object(metadata)) => {
let mut override_ids: Vec<String> = Vec::new();
for history in metadata.values() {
let Some(entries) = history.as_array() else {
continue;
};

for entry in entries {
let Some(override_id) = entry
.get("override_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
continue;
};

if !override_ids.contains(&override_id) {
override_ids.push(override_id);
}
}
}

if override_ids.is_empty() {
logging::log!("unstrike default config");
unstrike(&String::new(), &config);
}

for override_id in override_ids {
logging::log!("unstrike {:#?}", override_id);
unstrike(&override_id, &config);
}
}
_ => {
logging::log!(
"no metadata recieved, default config is the config to be used"
"no metadata received, default config is the config to be used"
);
}
}
Expand Down
Loading