From c698ee1a575b73dacb031af0a271559c8d425918 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 20:09:08 -0500 Subject: [PATCH 1/3] getAssignmentDetails methods in BaseEppoClient --- src/main/java/cloud/eppo/BaseEppoClient.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 08533a6..1bc20b8 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -11,6 +11,7 @@ import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoModule; import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; @@ -199,7 +200,7 @@ protected AssignmentDetails getTypedAssignmentWithDetails( T resultValue = details.evaluationSuccessful() - ? details.getVariationValue().unwrap(expectedType) + ? EppoValue.unwrap(details.getVariationValue(), expectedType) : defaultValue; return new AssignmentDetails<>(resultValue, null, details); } @@ -272,7 +273,7 @@ protected EvaluationDetails evaluateAndMaybeLog( config.getEnvironmentName(), config.getConfigFetchedAt(), config.getConfigPublishedAt()); - EvaluationDetails evaluationDetails = evaluationResult.getEvaluationDetails(); + EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); EppoValue assignedValue = evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; @@ -288,7 +289,7 @@ protected EvaluationDetails evaluateAndMaybeLog( // Update evaluation details with error code but keep the matched allocation and variation // info String variationKey = - evaluationResult.getVariation() != null ? evaluationResult.getVariation().getKey() : null; + detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; String errorDescription = String.format( "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", @@ -306,7 +307,7 @@ protected EvaluationDetails evaluateAndMaybeLog( } // Log assignment if applicable - if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { + if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { try { String allocationKey = evaluationResult.getAllocationKey(); String experimentKey = @@ -370,9 +371,8 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v case JSON: typeMatch = value.isString() - // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse (via - // unwrapping) - && value.unwrap(VariationType.JSON) != null; + // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse + && EppoValue.unwrap(value, VariationType.JSON) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); From 986e82c465fbba2351ba12cfa81a530b2d077bf7 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 20:17:36 -0500 Subject: [PATCH 2/3] details methods for bandits --- src/main/java/cloud/eppo/BaseEppoClient.java | 179 ++++++++++---- .../cloud/eppo/BaseEppoClientBanditTest.java | 226 +++++++++++++++++- 2 files changed, 349 insertions(+), 56 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 1bc20b8..adb0662 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -11,7 +11,6 @@ import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; import cloud.eppo.ufc.dto.*; -import cloud.eppo.ufc.dto.adapters.EppoModule; import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.Map; @@ -25,6 +24,7 @@ public class BaseEppoClient { private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); + protected final ConfigurationRequestor requestor; private final IConfigurationStore configurationStore; private final AssignmentLogger assignmentLogger; @@ -592,68 +592,151 @@ public BanditResult getBanditAction( DiscriminableAttributes subjectAttributes, Actions actions, String defaultValue) { - BanditResult result = new BanditResult(defaultValue, null); + try { + AssignmentDetails details = + getBanditActionDetails(flagKey, subjectKey, subjectAttributes, actions, defaultValue); + return new BanditResult(details.getVariation(), details.getAction()); + } catch (Exception e) { + return throwIfNotGraceful(e, new BanditResult(defaultValue, null)); + } + } + + /** + * Returns bandit action assignment with detailed evaluation information including flag evaluation + * details and bandit action selection. + */ + public AssignmentDetails getBanditActionDetails( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { final Configuration config = getConfiguration(); try { - String assignedVariation = - getStringAssignment( + // Get detailed flag assignment + AssignmentDetails flagDetails = + getStringAssignmentDetails( flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); - // Update result to reflect that we've been assigned a variation - result = new BanditResult(assignedVariation, null); + String assignedVariation = flagDetails.getVariation(); + String assignedAction = null; + // If we got a variation, check for bandit String banditKey = config.banditKeyForVariation(flagKey, assignedVariation); - if (banditKey != null && !actions.isEmpty()) { - BanditParameters banditParameters = config.getBanditParameters(banditKey); - BanditEvaluationResult banditResult = - BanditEvaluator.evaluateBandit( - flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); - - // Update result to reflect that we've been assigned an action - result = new BanditResult(assignedVariation, banditResult.getActionKey()); - - if (banditLogger != null) { - try { - BanditAssignment banditAssignment = - new BanditAssignment( - flagKey, - banditKey, - subjectKey, - banditResult.getActionKey(), - banditResult.getActionWeight(), - banditResult.getOptimalityGap(), - banditParameters.getModelVersion(), - subjectAttributes.getNumericAttributes(), - subjectAttributes.getCategoricalAttributes(), - banditResult.getActionAttributes().getNumericAttributes(), - banditResult.getActionAttributes().getCategoricalAttributes(), - buildLogMetaData(config.isConfigObfuscated())); - - // Log, only if there is no cache hit. - boolean logBanditAssignment = true; - AssignmentCacheEntry cacheEntry = - AssignmentCacheEntry.fromBanditAssignment(banditAssignment); - if (banditAssignmentCache != null) { - if (banditAssignmentCache.hasEntry(cacheEntry)) { - logBanditAssignment = false; - } - } - if (logBanditAssignment) { - banditLogger.logBanditAssignment(banditAssignment); + // If variation is a bandit but no actions supplied, return variation with null action + // This matches Python/JS SDK behavior: "if no actions are given, return the variation with no + // action" + if (banditKey != null && actions.isEmpty()) { + EvaluationDetails noActionsDetails = + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .flagEvaluationCode(FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT) + .flagEvaluationDescription("No actions supplied for bandit evaluation") + .banditKey(banditKey) + .banditAction(null) + .build(); + return new AssignmentDetails<>(assignedVariation, null, noActionsDetails); + } + if (banditKey != null) { + try { + BanditParameters banditParameters = config.getBanditParameters(banditKey); + if (banditParameters == null) { + throw new RuntimeException("Bandit parameters not found for bandit key: " + banditKey); + } + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + assignedAction = banditResult.getActionKey(); + + // Log bandit assignment if needed + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData(config.isConfigObfuscated())); + + boolean logBanditAssignment = true; + AssignmentCacheEntry cacheEntry = + AssignmentCacheEntry.fromBanditAssignment(banditAssignment); if (banditAssignmentCache != null) { - banditAssignmentCache.put(cacheEntry); + if (banditAssignmentCache.hasEntry(cacheEntry)) { + logBanditAssignment = false; + } } + + if (logBanditAssignment) { + banditLogger.logBanditAssignment(banditAssignment); + if (banditAssignmentCache != null) { + banditAssignmentCache.put(cacheEntry); + } + } + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); } - } catch (Exception e) { - log.warn("Error logging bandit assignment: {}", e.getMessage(), e); } + + // Update evaluation details to include bandit information + EvaluationDetails updatedDetails = + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .banditKey(banditKey) + .banditAction(assignedAction) + .build(); + + return new AssignmentDetails<>(assignedVariation, assignedAction, updatedDetails); + } catch (Exception banditError) { + // Bandit evaluation failed - respect graceful mode setting + log.warn( + "Bandit evaluation failed for flag {}: {}", + flagKey, + banditError.getMessage(), + banditError); + + // If graceful mode is off, throw the exception + if (!isGracefulMode) { + throw new RuntimeException(banditError); + } + + // In graceful mode, return flag details with BANDIT_ERROR code + EvaluationDetails banditErrorDetails = + EvaluationDetails.builder(flagDetails.getEvaluationDetails()) + .flagEvaluationCode(FlagEvaluationCode.BANDIT_ERROR) + .flagEvaluationDescription( + "Bandit evaluation failed: " + banditError.getMessage()) + .banditKey(banditKey) + .banditAction(null) + .build(); + return new AssignmentDetails<>(assignedVariation, null, banditErrorDetails); } } - return result; + + // No bandit - return flag details as-is + return flagDetails; } catch (Exception e) { - return throwIfNotGraceful(e, result); + AssignmentDetails errorDetails = + new AssignmentDetails<>( + defaultValue, + null, + EvaluationDetails.buildDefault( + config.getEnvironmentName(), + config.getConfigFetchedAt(), + config.getConfigPublishedAt(), + FlagEvaluationCode.ASSIGNMENT_ERROR, + e.getMessage(), + EppoValue.valueOf(defaultValue))); + return throwIfNotGraceful(e, errorDetails); } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 84587ed..a45a3ae 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -131,7 +131,6 @@ public static Stream getBanditTestData() { return BanditTestCase.getBanditTestData(); } - @SuppressWarnings("ExtractMethodRecommender") @Test public void testBanditLogsAction() { String flagKey = "banner_bandit_flag"; @@ -228,13 +227,13 @@ public void testBanditLogCached() { ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); assertEquals("training", assignmentLogCaptor.getValue().getAllocation()); - assertEquals(assignmentLogCaptor.getValue().getVariation(), "banner_bandit"); + assertEquals("banner_bandit", assignmentLogCaptor.getValue().getVariation()); ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditAssignment.class); verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); - assertEquals(banditLogCaptor.getValue().getBandit(), "banner_bandit"); - assertEquals(banditLogCaptor.getValue().getAction(), "adidas"); + assertEquals("banner_bandit", banditLogCaptor.getValue().getBandit()); + assertEquals("adidas", banditLogCaptor.getValue().getAction()); BanditResult duplicateBanditResult = eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); @@ -291,8 +290,8 @@ public void testBanditLogCacheExpires() throws InterruptedException { ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditAssignment.class); verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); - assertEquals(banditLogCaptor.getValue().getBandit(), "banner_bandit"); - assertEquals(banditLogCaptor.getValue().getAction(), "adidas"); + assertEquals("banner_bandit", banditLogCaptor.getValue().getBandit()); + assertEquals("adidas", banditLogCaptor.getValue().getAction()); // 2. Get the bandit action again right away to ensure it was cached eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); @@ -373,11 +372,11 @@ public void testNoBanditLogsWhenNoActions() { BanditResult banditResult = eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); - // Verify assignment + // Verify assignment - returns bandit variation with null action assertEquals("banner_bandit", banditResult.getVariation()); assertNull(banditResult.getAction()); - // The variation assignment should have been logged + // The variation assignment should have been logged (happens during flag evaluation) ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); @@ -481,4 +480,215 @@ public void testWithInitialConfiguration() { throw new RuntimeException(e); } } + + @Test + public void testBanditActionDetailsSuccessful() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertEquals("adidas", assignmentDetails.getAction()); + + // Verify evaluation details are populated correctly + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + assertNotNull(details.getEnvironmentName()); + + // Verify bandit-specific fields + assertEquals("banner_bandit", details.getBanditKey()); + assertEquals("adidas", details.getBanditAction()); + + // Verify config metadata + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + assertTrue( + details.getConfigFetchedAt().after(details.getConfigPublishedAt()), + "Config fetched at should be after config published at"); + + // Verify matched allocation + assertNotNull(details.getMatchedAllocation()); + assertEquals("training", details.getMatchedAllocation().getKey()); + } + + @Test + public void testBanditActionDetailsNoActionsSupplied() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = new BanditActions(); // Empty actions + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment - should get bandit variation with null action + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertNull(assignmentDetails.getAction()); + + // Verify evaluation details + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals( + FlagEvaluationCode.NO_ACTIONS_SUPPLIED_FOR_BANDIT, details.getFlagEvaluationCode()); + assertEquals( + "No actions supplied for bandit evaluation", details.getFlagEvaluationDescription()); + + // banditKey should be set (we know which bandit would have been used) + // but banditAction should be null (no action selected) + assertEquals("banner_bandit", details.getBanditKey()); + assertNull(details.getBanditAction()); + + // Verify variation key is set + assertNotNull(details.getVariationKey()); + assertNotNull(details.getVariationValue()); + + // Verify config metadata is present + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + } + + @Test + public void testBanditActionDetailsNonBanditVariation() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "anthony"; // This subject gets "control" variation which is not a bandit + Attributes subjectAttributes = new Attributes(); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "default"); + + // Verify assignment - should get non-bandit variation with no action + assertEquals("control", assignmentDetails.getVariation()); + assertNull(assignmentDetails.getAction()); + + // Verify evaluation details + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + + // Should not have BANDIT_ERROR since this is a valid non-bandit variation + assertNotEquals(FlagEvaluationCode.BANDIT_ERROR, details.getFlagEvaluationCode()); + + // Verify no bandit key or action since this variation is not a bandit + assertNull(details.getBanditKey()); + assertNull(details.getBanditAction()); + + // Verify config metadata is present + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + } + + @Test + public void testBanditActionDetailsWithBanditLogError() { + // Even if bandit logging fails, we should still get details back + doThrow(new RuntimeException("Mock Bandit Logging Error")) + .when(mockBanditLogger) + .logBanditAssignment(any()); + + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment still succeeds + assertEquals("banner_bandit", assignmentDetails.getVariation()); + assertEquals("adidas", assignmentDetails.getAction()); + + // Verify evaluation details are populated correctly + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + assertEquals(FlagEvaluationCode.MATCH, details.getFlagEvaluationCode()); + + // Verify bandit information is present + assertEquals("banner_bandit", details.getBanditKey()); + assertEquals("adidas", details.getBanditAction()); + + // Verify config metadata + assertNotNull(details.getEnvironmentName()); + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + + // Verify logging was attempted + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); + } + + @Test + public void testBanditActionDetailsMetadataFlow() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = getBrandActions(); + + Date beforeFetch = new Date(); + AssignmentDetails assignmentDetails = + eppoClient.getBanditActionDetails( + flagKey, subjectKey, subjectAttributes, actions, "control"); + Date afterFetch = new Date(); + + // Verify evaluation details metadata + EvaluationDetails details = assignmentDetails.getEvaluationDetails(); + assertNotNull(details); + + // Verify environment name + assertNotNull(details.getEnvironmentName()); + assertFalse(details.getEnvironmentName().isEmpty()); + + // Verify timestamps are populated + assertNotNull(details.getConfigPublishedAt()); + assertNotNull(details.getConfigFetchedAt()); + + // configPublishedAt should be from the past (from the test JSON) + assertTrue( + details.getConfigPublishedAt().before(beforeFetch), + "Config published at should be before test started"); + + // configFetchedAt should be between test start and now + assertTrue( + details.getConfigFetchedAt().after(details.getConfigPublishedAt()), + "Config fetched at should be after config published at"); + assertTrue( + details.getConfigFetchedAt().before(afterFetch), + "Config fetched at should be before test completed"); + + // Verify variation information + assertNotNull(details.getVariationKey()); + assertNotNull(details.getVariationValue()); + + // Verify matched allocation details + assertNotNull(details.getMatchedAllocation()); + assertNotNull(details.getMatchedAllocation().getKey()); + } } From 19442822037935d194476d4a1ccbdea21ff9d973 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 10 Dec 2025 21:47:37 -0500 Subject: [PATCH 3/3] fix up rebasing --- src/main/java/cloud/eppo/BaseEppoClient.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index adb0662..9ce51d4 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -200,7 +200,7 @@ protected AssignmentDetails getTypedAssignmentWithDetails( T resultValue = details.evaluationSuccessful() - ? EppoValue.unwrap(details.getVariationValue(), expectedType) + ? details.getVariationValue().unwrap(expectedType) : defaultValue; return new AssignmentDetails<>(resultValue, null, details); } @@ -273,7 +273,7 @@ protected EvaluationDetails evaluateAndMaybeLog( config.getEnvironmentName(), config.getConfigFetchedAt(), config.getConfigPublishedAt()); - EvaluationDetails evaluationDetails = detailedResult.getEvaluationDetails(); + EvaluationDetails evaluationDetails = evaluationResult.getEvaluationDetails(); EppoValue assignedValue = evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; @@ -289,7 +289,7 @@ protected EvaluationDetails evaluateAndMaybeLog( // Update evaluation details with error code but keep the matched allocation and variation // info String variationKey = - detailedResult.getVariation() != null ? detailedResult.getVariation().getKey() : null; + evaluationResult.getVariation() != null ? evaluationResult.getVariation().getKey() : null; String errorDescription = String.format( "Variation (%s) is configured for type %s, but is set to incompatible value (%s)", @@ -307,7 +307,7 @@ protected EvaluationDetails evaluateAndMaybeLog( } // Log assignment if applicable - if (assignedValue != null && assignmentLogger != null && detailedResult.doLog()) { + if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { try { String allocationKey = evaluationResult.getAllocationKey(); String experimentKey = @@ -371,8 +371,9 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v case JSON: typeMatch = value.isString() - // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse - && EppoValue.unwrap(value, VariationType.JSON) != null; + // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse (via + // unwrapping) + && value.unwrap(VariationType.JSON) != null; break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType);