Skip to content

Commit 5f608ac

Browse files
gtheaclaude
andcommitted
Add targeting-engine module: generic rules evaluation engine
Extracts a zero-dependency targeting rules engine into a new Maven module (io.split:targeting-engine). Core algorithm, bucketing, and all matchers are moved here, providing a foundation to be shared with the Android SDK. - New module `targeting-engine/` added to root pom.xml - 25 matcher/utility classes moved via git mv from client/ (history preserved): MurmurHash3, all value matchers (AllKeys, Between, Boolean, EqualTo, GreaterThanOrEqualTo, LessThanOrEqualTo), all semver matchers, all string matchers (StartsWithAnyOf, EndsWithAnyOf, ContainsAnyOf, RegularExpression), all set matchers (EqualToSet, PartOfSet, ContainsAllOfSet, ContainsAnyOfSet), Semver, Transformers, DependencyMatcher, PrerequisitesMatcher, WhitelistMatcher - New interfaces and implementations written from scratch: TargetingEngine, TargetingEngineImpl, EvaluationContext, EvaluationResult, EvaluationLabels, Bucketer, Matcher, CombiningMatcher, AttributeMatcher, UserDefinedSegmentMatcher, RuleBasedSegmentMatcher, Logger, VersionedExceptionWrapper, all model classes (TargetingRule, Condition, ConditionType, Partition, Prerequisite, DataType) - Packages updated: io.split.engine.matchers → io.split.rules.matchers - Removed Guava and SLF4J dependencies from moved classes - 37 TDD tests pass (BucketerTest, TargetingEngineImplTest, AllKeysMatcherTest, BetweenMatcherTest, BooleanMatcherTest, SemverTest) Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 914493a commit 5f608ac

52 files changed

Lines changed: 1233 additions & 104 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
<maven.compiler.target>1.8</maven.compiler.target>
6565
</properties>
6666
<modules>
67+
<module>targeting-engine</module>
6768
<module>pluggable-storage</module>
6869
<module>redis-wrapper</module>
6970
<module>testing</module>

targeting-engine/pom.xml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?xml version="1.0"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>io.split.client</groupId>
7+
<artifactId>java-client-parent</artifactId>
8+
<version>4.18.3</version>
9+
</parent>
10+
11+
<version>4.18.3</version>
12+
<artifactId>targeting-engine</artifactId>
13+
<packaging>jar</packaging>
14+
<name>Targeting Engine</name>
15+
<description>A generic, zero-dependency targeting rules engine extracted from the Split Java SDK</description>
16+
17+
<dependencies>
18+
<dependency>
19+
<groupId>junit</groupId>
20+
<artifactId>junit</artifactId>
21+
<version>4.13.1</version>
22+
<scope>test</scope>
23+
</dependency>
24+
<dependency>
25+
<groupId>org.mockito</groupId>
26+
<artifactId>mockito-core</artifactId>
27+
<version>5.14.2</version>
28+
<scope>test</scope>
29+
</dependency>
30+
</dependencies>
31+
32+
<build>
33+
<plugins>
34+
<plugin>
35+
<groupId>org.apache.maven.plugins</groupId>
36+
<artifactId>maven-compiler-plugin</artifactId>
37+
<version>3.3</version>
38+
<configuration>
39+
<source>${maven.compiler.source}</source>
40+
<target>${maven.compiler.target}</target>
41+
</configuration>
42+
</plugin>
43+
<plugin>
44+
<groupId>org.apache.maven.plugins</groupId>
45+
<artifactId>maven-surefire-plugin</artifactId>
46+
<version>3.2.5</version>
47+
</plugin>
48+
</plugins>
49+
</build>
50+
</project>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package io.split.rules.bucketing;
2+
3+
import io.split.rules.model.Partition;
4+
5+
import java.util.List;
6+
7+
/**
8+
* Hashes keys into buckets and selects treatments from partition lists.
9+
*/
10+
public final class Bucketer {
11+
private static final int ALGO_LEGACY = 1;
12+
private static final int ALGO_MURMUR = 2;
13+
private static final String CONTROL = "control";
14+
15+
/**
16+
* Returns the treatment for the given key, seed, partitions, and algorithm.
17+
* Returns "control" if no partition matches.
18+
*/
19+
public static String getTreatment(String key, int seed, List<Partition> partitions, int algo) {
20+
if (partitions.isEmpty()) {
21+
return CONTROL;
22+
}
23+
if (hundredPercentOneTreatment(partitions)) {
24+
return partitions.get(0).treatment;
25+
}
26+
return selectTreatment(bucket(hash(key, seed, algo)), partitions);
27+
}
28+
29+
/**
30+
* Returns a bucket between 1 and 100, inclusive.
31+
*/
32+
public static int getBucket(String key, int seed, int algo) {
33+
return bucket(hash(key, seed, algo));
34+
}
35+
36+
static long hash(String key, int seed, int algo) {
37+
switch (algo) {
38+
case ALGO_MURMUR:
39+
return murmurHash(key, seed);
40+
case ALGO_LEGACY:
41+
default:
42+
return legacyHash(key, seed);
43+
}
44+
}
45+
46+
static long murmurHash(String key, int seed) {
47+
return MurmurHash3.murmurhash3_x86_32(key, 0, key.length(), seed);
48+
}
49+
50+
static int legacyHash(String key, int seed) {
51+
int h = 0;
52+
for (int i = 0; i < key.length(); i++) {
53+
h = 31 * h + key.charAt(i);
54+
}
55+
return h ^ seed;
56+
}
57+
58+
static int bucket(long hash) {
59+
return (int) (Math.abs(hash % 100) + 1);
60+
}
61+
62+
private static String selectTreatment(int bucket, List<Partition> partitions) {
63+
int covered = 0;
64+
for (Partition partition : partitions) {
65+
covered += partition.size;
66+
if (covered >= bucket) {
67+
return partition.treatment;
68+
}
69+
}
70+
return CONTROL;
71+
}
72+
73+
private static boolean hundredPercentOneTreatment(List<Partition> partitions) {
74+
return partitions.size() == 1 && partitions.get(0).size == 100;
75+
}
76+
77+
private Bucketer() {}
78+
}

client/src/main/java/io/split/client/utils/MurmurHash3.java renamed to targeting-engine/src/main/java/io/split/rules/bucketing/MurmurHash3.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.split.client.utils;
1+
package io.split.rules.bucketing;
22

33
/**
44
* The MurmurHash3 algorithm was created by Austin Appleby and placed in the public domain.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.split.rules.engine;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* Provides recursive evaluation and segment membership checks to matchers.
7+
* Each SDK implements this interface to bridge to its own storage and evaluator.
8+
*/
9+
public interface EvaluationContext {
10+
11+
/**
12+
* Evaluates a targeting rule by name. Used by DependencyMatcher and PrerequisitesMatcher.
13+
*/
14+
EvaluationResult evaluate(String matchingKey, String bucketingKey, String ruleName, Map<String, Object> attributes);
15+
16+
/**
17+
* Checks if the given key is a member of a standard segment.
18+
*/
19+
boolean isInSegment(String segmentName, String key);
20+
21+
/**
22+
* Checks if the given key is a member of a rule-based segment.
23+
*/
24+
boolean isInRuleBasedSegment(String segmentName, String key, String bucketingKey, Map<String, Object> attributes);
25+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.split.rules.engine;
2+
3+
public final class EvaluationLabels {
4+
public static final String NOT_IN_SPLIT = "not in split";
5+
public static final String DEFAULT_RULE = "default rule";
6+
public static final String KILLED = "killed";
7+
public static final String DEFINITION_NOT_FOUND = "definition not found";
8+
public static final String EXCEPTION = "exception";
9+
public static final String UNSUPPORTED_MATCHER = "targeting rule type unsupported by sdk";
10+
public static final String PREREQUISITES_NOT_MET = "prerequisites not met";
11+
public static final String NOT_READY = "not ready";
12+
13+
private EvaluationLabels() {}
14+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.split.rules.engine;
2+
3+
public final class EvaluationResult {
4+
public final String treatment;
5+
public final String label;
6+
public final Long version;
7+
public final String config;
8+
public final boolean impressionsDisabled;
9+
10+
public EvaluationResult(String treatment, String label) {
11+
this(treatment, label, null, null, false);
12+
}
13+
14+
public EvaluationResult(String treatment, String label, Long version) {
15+
this(treatment, label, version, null, false);
16+
}
17+
18+
public EvaluationResult(String treatment, String label, Long version, String config, boolean impressionsDisabled) {
19+
this.treatment = treatment;
20+
this.label = label;
21+
this.version = version;
22+
this.config = config;
23+
this.impressionsDisabled = impressionsDisabled;
24+
}
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.split.rules.engine;
2+
3+
import io.split.rules.model.TargetingRule;
4+
5+
import java.util.Map;
6+
7+
/**
8+
* Evaluates a targeting rule against a key and attributes.
9+
* This is the core contract of the targeting engine.
10+
*/
11+
public interface TargetingEngine {
12+
EvaluationResult evaluate(String matchingKey, String bucketingKey,
13+
TargetingRule rule, Map<String, Object> attributes,
14+
EvaluationContext context);
15+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.split.rules.engine;
2+
3+
import io.split.rules.bucketing.Bucketer;
4+
import io.split.rules.exceptions.VersionedExceptionWrapper;
5+
import io.split.rules.matchers.PrerequisitesMatcher;
6+
import io.split.rules.model.Condition;
7+
import io.split.rules.model.ConditionType;
8+
import io.split.rules.model.TargetingRule;
9+
10+
import java.util.Map;
11+
12+
public final class TargetingEngineImpl implements TargetingEngine {
13+
14+
@Override
15+
public EvaluationResult evaluate(String matchingKey, String bucketingKey,
16+
TargetingRule rule, Map<String, Object> attributes,
17+
EvaluationContext context) {
18+
try {
19+
return evaluateInternal(matchingKey, bucketingKey, rule, attributes, context);
20+
} catch (VersionedExceptionWrapper e) {
21+
throw new RuntimeException(e);
22+
}
23+
}
24+
25+
private EvaluationResult evaluateInternal(String matchingKey, String bucketingKey,
26+
TargetingRule rule, Map<String, Object> attributes,
27+
EvaluationContext context) throws VersionedExceptionWrapper {
28+
try {
29+
String config = getConfig(rule, rule.defaultTreatment());
30+
31+
// 1. Killed rule → return default treatment
32+
if (rule.killed()) {
33+
return new EvaluationResult(rule.defaultTreatment(), EvaluationLabels.KILLED,
34+
rule.changeNumber(), config, rule.impressionsDisabled());
35+
}
36+
37+
// 2. Bucketing key resolution
38+
String bk = bucketingKey != null ? bucketingKey : matchingKey;
39+
40+
// 3. Prerequisites check
41+
PrerequisitesMatcher prereqMatcher = new PrerequisitesMatcher(rule.prerequisites());
42+
if (!prereqMatcher.match(matchingKey, bk, attributes, context)) {
43+
return new EvaluationResult(rule.defaultTreatment(), EvaluationLabels.PREREQUISITES_NOT_MET,
44+
rule.changeNumber(), config, rule.impressionsDisabled());
45+
}
46+
47+
// 4. Iterate conditions
48+
boolean inRollout = false;
49+
for (Condition condition : rule.conditions()) {
50+
51+
// 4a. Traffic allocation check (once, before first ROLLOUT condition)
52+
if (!inRollout && condition.conditionType() == ConditionType.ROLLOUT) {
53+
if (rule.trafficAllocation() < 100) {
54+
int bucket = Bucketer.getBucket(bk, rule.trafficAllocationSeed(), rule.algo());
55+
if (bucket > rule.trafficAllocation()) {
56+
config = getConfig(rule, rule.defaultTreatment());
57+
return new EvaluationResult(rule.defaultTreatment(), EvaluationLabels.NOT_IN_SPLIT,
58+
rule.changeNumber(), config, rule.impressionsDisabled());
59+
}
60+
}
61+
inRollout = true;
62+
}
63+
64+
// 4b. Condition match → select treatment
65+
if (condition.matcher().match(matchingKey, bucketingKey, attributes, context)) {
66+
String treatment = Bucketer.getTreatment(bk, rule.seed(), condition.partitions(), rule.algo());
67+
config = getConfig(rule, treatment);
68+
return new EvaluationResult(treatment, condition.label(),
69+
rule.changeNumber(), config, rule.impressionsDisabled());
70+
}
71+
}
72+
73+
// 5. No condition matched → default rule
74+
config = getConfig(rule, rule.defaultTreatment());
75+
return new EvaluationResult(rule.defaultTreatment(), EvaluationLabels.DEFAULT_RULE,
76+
rule.changeNumber(), config, rule.impressionsDisabled());
77+
78+
} catch (Exception e) {
79+
throw new VersionedExceptionWrapper(e, rule.changeNumber());
80+
}
81+
}
82+
83+
private String getConfig(TargetingRule rule, String treatment) {
84+
return rule.configurations() != null ? rule.configurations().get(treatment) : null;
85+
}
86+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package io.split.rules.exceptions;
2+
3+
public class VersionedExceptionWrapper extends Exception {
4+
private final Exception _wrappedException;
5+
private final Long _version;
6+
7+
public VersionedExceptionWrapper(Exception wrappedException, Long version) {
8+
_wrappedException = wrappedException;
9+
_version = version;
10+
}
11+
12+
public Exception wrappedException() {
13+
return _wrappedException;
14+
}
15+
16+
public Long version() {
17+
return _version;
18+
}
19+
}

0 commit comments

Comments
 (0)