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
24 changes: 13 additions & 11 deletions clients/haskell/hs-exp-client/src/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ foreign import ccall unsafe "expt_get_applicable_variant"
c_get_applicable_variants :: Ptr ExpClient -> CString -> CString -> CString -> CString -> IO CString

foreign import ccall unsafe "expt_get_satisfied_experiments"
c_get_satisfied_experiments :: Ptr ExpClient -> CString -> CString -> IO CString
c_get_satisfied_experiments :: Ptr ExpClient -> CString -> CString -> CString -> IO CString

foreign import ccall unsafe "expt_get_filtered_satisfied_experiments"
c_get_filtered_satisfied_experiments :: Ptr ExpClient -> CString -> CString -> IO CString
c_get_filtered_satisfied_experiments :: Ptr ExpClient -> CString -> CString -> CString -> IO CString

foreign import ccall unsafe "expt_get_running_experiments"
c_get_running_experiments :: Ptr ExpClient -> IO CString
Expand Down Expand Up @@ -105,7 +105,7 @@ getApplicableVariants client dimensions query identifier mbPrefix = do
Just prefix -> newCString prefix
Nothing -> return nullPtr
variants <- withForeignPtr client (\c -> c_get_applicable_variants c dimensions context identifier' prefix)
_ <- cleanup [context]
_ <- cleanup [context, dimensions]
if variants == nullPtr
then Left <$> getError
else do
Expand All @@ -116,30 +116,32 @@ getApplicableVariants client dimensions query identifier mbPrefix = do
-- Error s -> Left s
-- Success vec -> Right vec

getSatisfiedExperiments :: ForeignPtr ExpClient -> String -> Maybe String -> IO (Either Error Value)
getSatisfiedExperiments client query mbPrefix = do
getSatisfiedExperiments :: ForeignPtr ExpClient -> String -> String -> Maybe String -> IO (Either Error Value)
getSatisfiedExperiments client dimensions query mbPrefix = do
context <- newCString query
dimensions <- newCString dimensions
prefix <- case mbPrefix of
Just prefix -> newCString prefix
Nothing -> return nullPtr
experiments <- withForeignPtr client $ \client -> c_get_satisfied_experiments client context prefix
_ <- cleanup [context]
experiments <- withForeignPtr client $ \client -> c_get_satisfied_experiments client dimensions context prefix
_ <- cleanup [context, dimensions]
if experiments == nullPtr
then Left <$> getError
else do
fptrExperiments <- newForeignPtr c_free_string experiments
Right . toJSON <$> withForeignPtr fptrExperiments peekCString

getFilteredSatisfiedExperiments :: ForeignPtr ExpClient -> Maybe String -> Maybe String -> IO (Either Error Value)
getFilteredSatisfiedExperiments client mbFilters mbPrefix = do
getFilteredSatisfiedExperiments :: ForeignPtr ExpClient -> String -> Maybe String -> Maybe String -> IO (Either Error Value)
getFilteredSatisfiedExperiments client dimensions mbFilters mbPrefix = do
dimensions <- newCString dimensions
filters <- case mbFilters of
Just filters' -> newCString filters'
Nothing -> return nullPtr
prefix <- case mbPrefix of
Just prefix' -> newCString prefix'
Nothing -> return nullPtr
experiments <- withForeignPtr client $ \client -> c_get_filtered_satisfied_experiments client filters prefix
_ <- cleanup [filters]
experiments <- withForeignPtr client $ \client -> c_get_filtered_satisfied_experiments client dimensions filters prefix
_ <- cleanup [filters, dimensions]
if experiments == nullPtr
then Left <$> getError
else do
Expand Down
4 changes: 2 additions & 2 deletions clients/haskell/hs-exp-client/src/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ main = do
loopNTimes 0 _ = return ()
loopNTimes n client = do
runningExperiments <- getRunningExperiments client
satisfiedExperiments <- getSatisfiedExperiments client "{\"os\": \"android\", \"client\": \"1mg\"}" Nothing
filteredExperiments <- getFilteredSatisfiedExperiments client (Just "{\"os\": \"android\"}") (Just "hyperpay")
satisfiedExperiments <- getSatisfiedExperiments client "{}" "{\"os\": \"android\", \"client\": \"1mg\"}" Nothing
filteredExperiments <- getFilteredSatisfiedExperiments client "{}" (Just "{\"os\": \"android\"}") (Just "hyperpay")
variants <- getApplicableVariants client "{}" "{\"os\": \"android\", \"client\": \"1mg\"}" "1mg-android" Nothing
print "Running experiments"
print runningExperiments
Expand Down
33 changes: 32 additions & 1 deletion crates/experimentation_client/src/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use std::{
cell::RefCell,
ffi::{c_int, CString},
};
use superposition_types::DimensionInfo;
use superposition_types::{
logic::{evaluate_local_cohorts, evaluate_local_cohorts_skip_unresolved},
DimensionInfo,
};
use tokio::{runtime::Runtime, task};

thread_local! {
Expand Down Expand Up @@ -216,6 +219,7 @@ pub extern "C" fn expt_get_applicable_variant(
#[no_mangle]
pub extern "C" fn expt_get_satisfied_experiments(
client: *mut Arc<Client>,
c_dimensions: *const c_char,
c_context: *const c_char,
filter_prefix: *const c_char,
) -> *mut c_char {
Expand All @@ -227,6 +231,16 @@ pub extern "C" fn expt_get_satisfied_experiments(
return std::ptr::null_mut()
);

let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);

let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
Comment on lines +234 to +242
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing null check for c_dimensions parameter.

Unlike filter_prefix which has explicit null handling, c_dimensions is passed directly to cstring_to_rstring without a null check. If a caller passes NULL for dimensions, this will cause undefined behavior when CStr::from_ptr is called on a null pointer.

Consider adding a null check similar to the filter_prefix handling, or document that c_dimensions must never be null.

🐛 Proposed fix
+    null_check!(c_dimensions, "Dimensions cannot be a null string", return std::ptr::null_mut());
+
     let dimensions = unwrap_safe!(
         cstring_to_rstring(c_dimensions),
         return std::ptr::null_mut()
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);
let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
null_check!(c_dimensions, "Dimensions cannot be a null string", return std::ptr::null_mut());
let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);
let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
🤖 Prompt for AI Agents
In @crates/experimentation_client/src/interface.rs around lines 234 - 242, The
code calls cstring_to_rstring(c_dimensions) without checking for null, causing
undefined behavior if c_dimensions is NULL; add a null check like the existing
filter_prefix handling: if c_dimensions.is_null() { return std::ptr::null_mut();
} before calling cstring_to_rstring, then proceed to unwrap_safe! on the
resulting Rust string and to serde_json::from_str::<HashMap<String,
DimensionInfo>>(...) as before so that c_dimensions is safely handled.


let prefix_list = if filter_prefix.is_null() {
None
} else {
Expand All @@ -238,6 +252,8 @@ pub extern "C" fn expt_get_satisfied_experiments(
Some(prefix_list)
};

let context = evaluate_local_cohorts(&dimensions, &context);

let local = task::LocalSet::new();
local.block_on(&Runtime::new().unwrap(), async move {
unsafe {
Expand All @@ -258,6 +274,7 @@ pub extern "C" fn expt_get_satisfied_experiments(
#[no_mangle]
pub extern "C" fn expt_get_filtered_satisfied_experiments(
client: *mut Arc<Client>,
c_dimensions: *const c_char,
c_context: *const c_char,
filter_prefix: *const c_char,
) -> *mut c_char {
Expand All @@ -268,6 +285,17 @@ pub extern "C" fn expt_get_filtered_satisfied_experiments(
serde_json::from_str::<Map<String, Value>>(context.as_str()),
return std::ptr::null_mut()
);

let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);

let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
Comment on lines +289 to +297
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same null check issue for c_dimensions in expt_get_filtered_satisfied_experiments.

Apply the same null check fix here as well.

🐛 Proposed fix
+    null_check!(c_dimensions, "Dimensions cannot be a null string", return std::ptr::null_mut());
+
     let dimensions = unwrap_safe!(
         cstring_to_rstring(c_dimensions),
         return std::ptr::null_mut()
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);
let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
null_check!(c_dimensions, "Dimensions cannot be a null string", return std::ptr::null_mut());
let dimensions = unwrap_safe!(
cstring_to_rstring(c_dimensions),
return std::ptr::null_mut()
);
let dimensions = unwrap_safe!(
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()),
return std::ptr::null_mut()
);
🤖 Prompt for AI Agents
In @crates/experimentation_client/src/interface.rs around lines 292 - 300, In
expt_get_filtered_satisfied_experiments, add the same null-check for the
incoming c_dimensions pointer before calling
cstring_to_rstring/serde_json::from_str: if c_dimensions is null then return
std::ptr::null_mut() early (matching the prior fix), and only call
cstring_to_rstring(c_dimensions) and the subsequent unwrap_safe! on
serde_json::from_str::<HashMap<String, DimensionInfo>>(dimensions.as_str()) when
c_dimensions is non-null so you avoid attempting to convert a null C string.


let prefix_list = if filter_prefix.is_null() {
None
} else {
Expand All @@ -280,6 +308,9 @@ pub extern "C" fn expt_get_filtered_satisfied_experiments(

Some(prefix_list).filter(|list| !list.is_empty())
};

let context = evaluate_local_cohorts_skip_unresolved(&dimensions, &context);

let local = task::LocalSet::new();
local.block_on(&Runtime::new().unwrap(), async move {
unsafe {
Expand Down
29 changes: 28 additions & 1 deletion crates/superposition_core/src/experiment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ pub fn get_satisfied_experiments(
) -> Result<Experiments, String> {
let running_experiments = experiments
.iter()
.filter(|exp| superposition_types::partial_apply(&exp.context, context))
.filter(|exp| superposition_types::apply(&exp.context, context))
.cloned()
.collect();

Expand All @@ -208,6 +208,33 @@ pub fn get_satisfied_experiments(
Ok(running_experiments)
}

pub fn get_filtered_satisfied_experiments(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function needs a better name - it implies that get_statisfied_experiments doesn't filter. This function is filtering contexts by partially application, and can be merged into get_satisfied_experiments. Take an argument called partial_apply_filter: bool which does filtering via contexts

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Datron this is what it was called in the exp_client crate
This was missed in the process of creating core crate

So i added this in this PR

experiments: &Experiments,
context: &Map<String, Value>,
filter_prefixes: Option<Vec<String>>,
) -> Result<Experiments, String> {
let running_experiments = experiments
.iter()
.filter_map(|exp| {
if exp.context.is_empty() {
Some(exp.clone())
} else {
superposition_types::partial_apply(&exp.context, context)
.then(|| exp.clone())
}
})
.collect();

if let Some(prefix_list) = filter_prefixes {
return Ok(filter_experiments_by_prefix(
running_experiments,
prefix_list,
));
}

Ok(running_experiments)
}

fn filter_experiments_by_prefix(
experiments: Experiments,
filter_prefixes: Vec<String>,
Expand Down
5 changes: 3 additions & 2 deletions crates/superposition_types/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use uniffi::deps::anyhow;

use crate::{
database::models::cac::{DependencyGraph, DimensionType},
logic::evaluate_local_cohorts,
logic::evaluate_local_cohorts_skip_unresolved,
overridden::filter_config_keys_by_prefix,
Cac, Contextual, Exp, ExtendedMap,
};
Expand Down Expand Up @@ -281,7 +281,8 @@ pub struct Config {

impl Config {
pub fn filter_by_dimensions(&self, dimension_data: &Map<String, Value>) -> Self {
let modified_context = evaluate_local_cohorts(&self.dimensions, dimension_data);
let modified_context =
evaluate_local_cohorts_skip_unresolved(&self.dimensions, dimension_data);

let filtered_context =
Context::filter_by_eval(self.contexts.clone(), &modified_context);
Expand Down
66 changes: 57 additions & 9 deletions crates/superposition_types/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
DimensionInfo,
};

#[inline]
fn apply_logic(
condition: &Map<String, Value>,
context: &Map<String, Value>,
Expand Down Expand Up @@ -37,15 +38,42 @@ fn apply_logic(
true
}

/// Core context application logic - checks if all dimensions in condition are satisfied by context
/// Only exact matches are considered valid, except for "variantIds" dimension where containment is checked
/// Returns true if condition is satisfied by context, false otherwise
pub fn apply(condition: &Map<String, Value>, context: &Map<String, Value>) -> bool {
apply_logic(condition, context, false)
}

/// Filtering logic that allows partial matching of context
/// For dimensions present in context, performs the same checks as `apply`
/// For dimensions absent in context, skips the check (allows partial matching)
/// This is useful for matching contexts that may not have all dimensions defined
/// For array context values, checks for containment - added behaviour over `apply`
/// Returns false if there is a mismatch, true otherwise
pub fn partial_apply(
condition: &Map<String, Value>,
context: &Map<String, Value>,
) -> bool {
apply_logic(condition, context, true)
for (dimension, value) in condition {
let Some(context_value) = context.get(dimension) else {
continue; // Skip dimensions not in context (partial matching)
};

// For array context values, check containment
if let Value::Array(context_values) = context_value {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ayushjain17 - the input in the handler is a JSON stringified array or CSV ? You showed JSON stringified array.

In some other places we do repeated values of the same key and collect them in the handler.

Just asking if it is all consistent in how we get multi-value input in the handler?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to verify once which all spec are supported

Input spec is not changed via this change-log

Here, updating the internal logic only

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the spec for these APIs are different, they are un-typed json
we cant have that other behaviour here, it has to be JSON stringified only

repeating values with same key is only possible for types which have type CommaSeparatedQParams

if !context_values.contains(value) {
return false;
}
} else if dimension == "variantIds" {
// variantIds must always be an array - fail if scalar
return false;
} else if *context_value != *value {
// For non-array values, check equality
return false;
}
}
true
}

fn _evaluate_local_cohort_dimension(
Expand Down Expand Up @@ -141,16 +169,10 @@ fn evaluate_local_cohorts_dependency(
}
}

/// Evaluates all local cohort dimensions based on the provided query data and dimension definitions
/// First all local cohorts which are computable from the query data are evaluated, then any remaining local cohorts are set to "otherwise"
/// Computation starts from such a point, such that dependencies can be resolved in a depth-first manner
///
/// Values of regular and remote cohort dimensions in query_data are retained as is.
/// Returned value, might have a different value for local cohort dimensions based on its based on dimensions,
/// if the value provided for the local cohort was incorrect in the query data.
pub fn evaluate_local_cohorts(
fn _evaluate_local_cohorts(
dimensions: &HashMap<String, DimensionInfo>,
query_data: &Map<String, Value>,
skip_unresolved: bool,
) -> Map<String, Value> {
if dimensions.is_empty() {
return query_data.clone();
Expand All @@ -175,6 +197,10 @@ pub fn evaluate_local_cohorts(
}
}

if skip_unresolved {
return modified_context;
}

// For any local cohort dimension not yet set, set it to "otherwise"
for dimension_key in dimensions.keys() {
if let Some(dimension_info) = dimensions.get(dimension_key) {
Expand All @@ -192,6 +218,28 @@ pub fn evaluate_local_cohorts(
modified_context
}

/// Evaluates all local cohort dimensions based on the provided query data and dimension definitions
/// First all local cohorts which are computable from the query data are evaluated, then any remaining local cohorts are set to "otherwise"
/// Computation starts from such a point, such that dependencies can be resolved in a depth-first manner
///
/// Values of regular and remote cohort dimensions in query_data are retained as is.
/// Returned value, might have a different value for local cohort dimensions based on its based on dimensions,
/// if the value provided for the local cohort was incorrect in the query data.
pub fn evaluate_local_cohorts(
dimensions: &HashMap<String, DimensionInfo>,
query_data: &Map<String, Value>,
) -> Map<String, Value> {
_evaluate_local_cohorts(dimensions, query_data, false)
}

/// Same as evaluate_local_cohorts but does not set unresolved local cohorts to "otherwise"
pub fn evaluate_local_cohorts_skip_unresolved(
dimensions: &HashMap<String, DimensionInfo>,
query_data: &Map<String, Value>,
) -> Map<String, Value> {
_evaluate_local_cohorts(dimensions, query_data, true)
}

/// Identifies starting dimensions for evaluation based on query data and dimension definitions
/// For each tree in the dependency graph, picks the node closest to root from query_data for each branch of the tree.
/// If nothing is found and a local cohort is encountered, picks that local cohort as start point from that branch.
Expand Down
2 changes: 2 additions & 0 deletions headers/libexperimentation_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ char *expt_get_applicable_variant(struct Arc_Client *client,
const char *filter_prefix);

char *expt_get_satisfied_experiments(struct Arc_Client *client,
const char *c_dimensions,
const char *c_context,
const char *filter_prefix);

char *expt_get_filtered_satisfied_experiments(struct Arc_Client *client,
const char *c_dimensions,
const char *c_context,
const char *filter_prefix);

Expand Down
Loading