2727import javax .annotation .Nonnull ;
2828import javax .annotation .Nullable ;
2929
30+ import org .slf4j .Logger ;
31+ import org .slf4j .LoggerFactory ;
32+
3033/**
31- * HoldoutConfig manages collections of Holdout objects, distinguishing between global holdouts
32- * (which apply to all rules) and local holdouts (which target specific rule IDs).
34+ * HoldoutConfig manages collections of Holdout objects partitioned by datafile section.
35+ *
36+ * <p>Two top-level datafile sections drive holdout scoping (Gen 3+):
37+ * <ul>
38+ * <li>{@code holdouts} — every entry is a global holdout (applied to every flag).
39+ * Any {@code includedRules} field on these entries is IGNORED
40+ * and stripped at parse time; section membership alone determines
41+ * scope.</li>
42+ * <li>{@code localHoldouts} — every entry is a local holdout (rule-scoped via
43+ * {@code includedRules}). Entries missing or with empty
44+ * {@code includedRules} are invalid and skipped with an error log.</li>
45+ * </ul>
46+ *
47+ * <p>Backward compatibility: older datafiles that only emit the {@code holdouts} section
48+ * continue to work unchanged — every entry is treated as global, matching pre-localHoldouts
49+ * behavior. The {@code localHoldouts} key is simply absent and parsed as an empty list.
3350 */
3451public class HoldoutConfig {
52+ private static final Logger logger = LoggerFactory .getLogger (HoldoutConfig .class );
53+
3554 private List <Holdout > allHoldouts ;
3655 private Map <String , Holdout > holdoutIdMap ;
3756
38- /** Global holdouts: holdouts where includedRules == null . Evaluated at flag level. */
57+ /** Global holdouts: entries from the datafile 'holdouts' section . Evaluated at flag level. */
3958 private List <Holdout > globalHoldouts ;
4059
4160 /** Rule-level map: ruleId -> list of local holdouts targeting that rule. */
4261 private Map <String , List <Holdout >> ruleHoldoutsMap ;
4362
4463 /**
45- * Initializes a new HoldoutConfig with an empty list of holdouts.
64+ * Initializes a new HoldoutConfig with no holdouts.
4665 */
4766 public HoldoutConfig () {
48- this (Collections .emptyList ());
67+ this (Collections .< Holdout > emptyList (), Collections .< Holdout > emptyList ());
4968 }
5069
5170 /**
52- * Initializes a new HoldoutConfig with the specified holdouts.
71+ * Backward-compatible constructor: treats every entry as if it came from the global
72+ * 'holdouts' section. Any {@code includedRules} field on these entries is preserved
73+ * (legacy classification is by entity-level {@code includedRules}, used only by callers
74+ * who pre-date the section split).
5375 *
5476 * @param allHoldouts The list of holdouts to manage
77+ * @deprecated Prefer {@link #HoldoutConfig(List, List)} so global vs. local scope is
78+ * driven by datafile section membership.
5579 */
80+ @ Deprecated
5681 public HoldoutConfig (@ Nonnull List <Holdout > allHoldouts ) {
5782 this .allHoldouts = new ArrayList <>(allHoldouts );
5883 this .holdoutIdMap = new HashMap <>();
5984 this .globalHoldouts = new ArrayList <>();
6085 this .ruleHoldoutsMap = new HashMap <>();
61- updateHoldoutMapping ();
86+ updateLegacyHoldoutMapping ();
87+ }
88+
89+ /**
90+ * Initializes a new HoldoutConfig from the two top-level datafile sections.
91+ *
92+ * <p>Entries in {@code globalHoldoutsFromSection} are treated as global regardless of
93+ * any {@code includedRules} field they may carry; that field is stripped so section
94+ * membership is the sole signal for scope.
95+ *
96+ * <p>Entries in {@code localHoldoutsFromSection} must carry a non-empty
97+ * {@code includedRules} list. Invalid entries (null or empty {@code includedRules})
98+ * are logged at ERROR and excluded from evaluation — they do NOT fall back to
99+ * global application (the partition between sections is hard).
100+ *
101+ * @param globalHoldoutsFromSection Entries from the datafile 'holdouts' section
102+ * @param localHoldoutsFromSection Entries from the datafile 'localHoldouts' section
103+ */
104+ public HoldoutConfig (@ Nonnull List <Holdout > globalHoldoutsFromSection ,
105+ @ Nonnull List <Holdout > localHoldoutsFromSection ) {
106+ this .allHoldouts = new ArrayList <>();
107+ this .holdoutIdMap = new HashMap <>();
108+ this .globalHoldouts = new ArrayList <>();
109+ this .ruleHoldoutsMap = new HashMap <>();
110+ updateHoldoutMapping (globalHoldoutsFromSection , localHoldoutsFromSection );
62111 }
63112
64113 /**
65- * Updates internal mappings:
66- * - holdoutIdMap: id -> Holdout
67- * - globalHoldouts: holdouts where includedRules == null
68- * - ruleHoldoutsMap: ruleId -> list of holdouts that include that rule
114+ * Section-aware mapping: enforces that scope comes from the datafile section, not the
115+ * {@code includedRules} field. Stale {@code includedRules} values on global-section
116+ * entries are stripped; invalid local-section entries are logged and skipped.
69117 */
70- private void updateHoldoutMapping () {
71- holdoutIdMap .clear ();
72- globalHoldouts .clear ();
73- ruleHoldoutsMap .clear ();
118+ private void updateHoldoutMapping (@ Nonnull List <Holdout > globalHoldoutsFromSection ,
119+ @ Nonnull List <Holdout > localHoldoutsFromSection ) {
120+ // Process global holdouts: section membership is the sole signal for scope.
121+ // Strip any stale 'includedRules' so the entity is unambiguously global (isGlobal -> true),
122+ // even if the datafile incorrectly includes one.
123+ for (Holdout holdout : globalHoldoutsFromSection ) {
124+ Holdout sanitized = holdout .isGlobal () ? holdout : stripIncludedRules (holdout );
74125
126+ allHoldouts .add (sanitized );
127+ holdoutIdMap .put (sanitized .getId (), sanitized );
128+ globalHoldouts .add (sanitized );
129+ }
130+
131+ // Process local holdouts: every entry must carry an 'includedRules' field.
132+ // Entries with null/missing includedRules are invalid per spec — log an error and
133+ // exclude them from evaluation (do NOT fall back to global application).
134+ // An empty includedRules list is valid but inert: the entity is tracked in the id
135+ // map but is not registered under any rule (matches Python reference semantics).
136+ for (Holdout holdout : localHoldoutsFromSection ) {
137+ List <String > includedRules = holdout .getIncludedRules ();
138+ if (includedRules == null ) {
139+ logger .error (
140+ "Local holdout \" {}\" is missing required 'includedRules' field and will be excluded from evaluation." ,
141+ holdout .getKey () != null ? holdout .getKey () : holdout .getId ());
142+ continue ;
143+ }
144+
145+ allHoldouts .add (holdout );
146+ holdoutIdMap .put (holdout .getId (), holdout );
147+ for (String ruleId : includedRules ) {
148+ if (!ruleHoldoutsMap .containsKey (ruleId )) {
149+ ruleHoldoutsMap .put (ruleId , new ArrayList <Holdout >());
150+ }
151+ ruleHoldoutsMap .get (ruleId ).add (holdout );
152+ }
153+ }
154+ }
155+
156+ /**
157+ * Legacy mapping used by the deprecated single-list constructor. Classifies each entry
158+ * by its entity-level {@code includedRules} (null -> global, non-null -> local).
159+ * Preserved unchanged for callers that have not migrated to section-aware construction.
160+ */
161+ private void updateLegacyHoldoutMapping () {
75162 for (Holdout holdout : allHoldouts ) {
76163 holdoutIdMap .put (holdout .getId (), holdout );
77164
78165 if (holdout .isGlobal ()) {
79- // includedRules == null: global holdout — applies to all rules
80166 globalHoldouts .add (holdout );
81167 } else {
82- // includedRules != null: local holdout — add to each targeted rule
83168 List <String > includedRules = holdout .getIncludedRules ();
84169 for (String ruleId : includedRules ) {
85170 if (!ruleHoldoutsMap .containsKey (ruleId )) {
86- ruleHoldoutsMap .put (ruleId , new ArrayList <>());
171+ ruleHoldoutsMap .put (ruleId , new ArrayList <Holdout >());
87172 }
88173 ruleHoldoutsMap .get (ruleId ).add (holdout );
89174 }
@@ -92,8 +177,28 @@ private void updateHoldoutMapping() {
92177 }
93178
94179 /**
95- * Returns all global holdouts (holdouts where includedRules == null).
180+ * Returns a copy of the given holdout with {@code includedRules} forced to null, so the
181+ * entity is unambiguously classified as global. Used only when a stale {@code includedRules}
182+ * appears on an entry coming from the global 'holdouts' section.
183+ */
184+ private static Holdout stripIncludedRules (Holdout holdout ) {
185+ return new Holdout (
186+ holdout .getId (),
187+ holdout .getKey (),
188+ holdout .getStatus (),
189+ holdout .getAudienceIds (),
190+ holdout .getAudienceConditions (),
191+ holdout .getVariations (),
192+ holdout .getTrafficAllocation (),
193+ null
194+ );
195+ }
196+
197+ /**
198+ * Returns all global holdouts (entries from the datafile 'holdouts' section).
96199 * These are evaluated at the flag level, before any rules are evaluated.
200+ * Section membership in 'holdouts' is the sole signal for global scope — any
201+ * 'includedRules' field on these entries is ignored.
97202 *
98203 * @return An unmodifiable list of global holdouts
99204 */
@@ -102,16 +207,17 @@ public List<Holdout> getGlobalHoldouts() {
102207 }
103208
104209 /**
105- * Returns local holdouts targeting a specific rule ID.
106- * These are evaluated per-rule, after the forced decision check and before regular rule evaluation.
210+ * Returns local holdouts targeting a specific rule ID. Local holdouts come from the
211+ * datafile 'localHoldouts' section and are scoped per-rule via 'includedRules'.
212+ * Evaluated per-rule, after the forced decision check and before regular rule evaluation.
107213 *
108214 * @param ruleId The rule identifier to look up
109215 * @return An unmodifiable list of local holdouts targeting that rule, or empty list if none
110216 */
111217 @ Nonnull
112218 public List <Holdout > getHoldoutsForRule (@ Nonnull String ruleId ) {
113219 List <Holdout > holdouts = ruleHoldoutsMap .get (ruleId );
114- return holdouts != null ? Collections .unmodifiableList (holdouts ) : Collections .emptyList ();
220+ return holdouts != null ? Collections .unmodifiableList (holdouts ) : Collections .< Holdout > emptyList ();
115221 }
116222
117223 /**
@@ -140,7 +246,7 @@ public Holdout getHoldout(@Nonnull String id) {
140246 }
141247
142248 /**
143- * Returns all holdouts managed by this config.
249+ * Returns all holdouts managed by this config (both global and local sections, in that order) .
144250 *
145251 * @return An unmodifiable list of all holdouts
146252 */
0 commit comments