@headband/react 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,7 +32,21 @@ __export(index_exports, {
32
32
  SearchBox: () => SearchBox,
33
33
  SortBy: () => SortBy,
34
34
  Stats: () => Stats,
35
+ andFilters: () => andFilters,
36
+ assignExperiment: () => assignExperiment,
37
+ buildExperimentConfigPath: () => buildExperimentConfigPath,
38
+ chooseExperimentVariant: () => chooseExperimentVariant,
39
+ combineFilters: () => combineFilters,
40
+ experimentSearchMetadata: () => experimentSearchMetadata,
41
+ filterEq: () => filterEq,
42
+ filterExists: () => filterExists,
43
+ filterIn: () => filterIn,
44
+ filterNotEq: () => filterNotEq,
45
+ filterRange: () => filterRange,
35
46
  headband: () => headband,
47
+ isRunnableExperiment: () => isRunnableExperiment,
48
+ orFilters: () => orFilters,
49
+ quoteFilterValue: () => quoteFilterValue,
36
50
  useHeadbandContext: () => useHeadbandContext,
37
51
  useHighlight: () => useHighlight,
38
52
  useHits: () => useHits,
@@ -46,6 +60,70 @@ __export(index_exports, {
46
60
  });
47
61
  module.exports = __toCommonJS(index_exports);
48
62
 
63
+ // src/experiments.ts
64
+ function stableHash(input) {
65
+ let hash = 2166136261;
66
+ for (let i = 0; i < input.length; i += 1) {
67
+ hash ^= input.charCodeAt(i);
68
+ hash = Math.imul(hash, 16777619);
69
+ }
70
+ return hash >>> 0;
71
+ }
72
+ function bucket(input, max) {
73
+ if (max <= 0) return 0;
74
+ return stableHash(input) % max;
75
+ }
76
+ function assignmentSeed(config, subjectId, scope) {
77
+ return `${config.assignmentSalt || config.experimentKey}:${config.experimentKey}:${scope}:${subjectId}`;
78
+ }
79
+ function buildExperimentConfigPath(query = {}) {
80
+ const params = new URLSearchParams();
81
+ if (query.projectId) params.set("projectId", query.projectId);
82
+ if (query.indexUid) params.set("indexUid", query.indexUid);
83
+ if (query.experimentKey) params.set("experimentKey", query.experimentKey);
84
+ const search = params.toString();
85
+ return `/v1/experiments/config${search ? `?${search}` : ""}`;
86
+ }
87
+ function isRunnableExperiment(config) {
88
+ return config.status === "active" && !config.killSwitch && config.trafficPercent > 0 && config.variants.length > 0;
89
+ }
90
+ function chooseExperimentVariant(config, subjectId) {
91
+ if (!isRunnableExperiment(config)) return null;
92
+ const trafficBucket = bucket(assignmentSeed(config, subjectId, "traffic"), 1e4) / 100;
93
+ if (trafficBucket >= config.trafficPercent) return null;
94
+ const totalWeight = config.variants.reduce(
95
+ (sum, variant) => sum + Math.max(0, variant.weight),
96
+ 0
97
+ );
98
+ if (totalWeight <= 0) return null;
99
+ const variantBucket = bucket(assignmentSeed(config, subjectId, "variant"), totalWeight);
100
+ let cursor = 0;
101
+ for (const variant of config.variants) {
102
+ cursor += Math.max(0, variant.weight);
103
+ if (variantBucket < cursor) return variant;
104
+ }
105
+ return config.variants[config.variants.length - 1] ?? null;
106
+ }
107
+ function assignExperiment(config, subjectId, options = {}) {
108
+ const variant = chooseExperimentVariant(config, subjectId);
109
+ if (!variant) return null;
110
+ return {
111
+ experimentKey: config.experimentKey,
112
+ variant: variant.key,
113
+ indexUid: variant.indexUid,
114
+ sessionId: options.sessionId ?? subjectId,
115
+ config
116
+ };
117
+ }
118
+ function experimentSearchMetadata(assignment) {
119
+ if (!assignment) return void 0;
120
+ return {
121
+ experimentKey: assignment.experimentKey,
122
+ variant: assignment.variant,
123
+ ...assignment.sessionId ? { sessionId: assignment.sessionId } : {}
124
+ };
125
+ }
126
+
49
127
  // src/client.ts
50
128
  var HeadbandError = class extends Error {
51
129
  constructor(message, status, code) {
@@ -57,22 +135,20 @@ var HeadbandError = class extends Error {
57
135
  };
58
136
  function headband(host, apiKey) {
59
137
  const baseUrl = host.replace(/\/+$/, "");
60
- async function request(path, body) {
138
+ async function request(path, options = {}) {
139
+ const method = options.method ?? "POST";
61
140
  let response;
62
141
  try {
63
142
  response = await fetch(`${baseUrl}${path}`, {
64
- method: "POST",
143
+ method,
65
144
  headers: {
66
- "Content-Type": "application/json",
145
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
67
146
  Authorization: `Bearer ${apiKey}`
68
147
  },
69
- body: JSON.stringify(body)
148
+ ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {}
70
149
  });
71
150
  } catch (err) {
72
- throw new HeadbandError(
73
- err instanceof Error ? err.message : "Network request failed",
74
- 0
75
- );
151
+ throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
76
152
  }
77
153
  if (!response.ok) {
78
154
  let errorBody = {};
@@ -91,17 +167,102 @@ function headband(host, apiKey) {
91
167
  return {
92
168
  search(index, query, params) {
93
169
  return request("/v1/search", {
94
- index,
95
- q: query,
96
- ...params
170
+ body: {
171
+ index,
172
+ q: query,
173
+ ...params
174
+ }
97
175
  });
98
176
  },
99
177
  multiSearch(queries) {
100
- return request("/v1/multi-search", { queries });
178
+ return request("/v1/multi-search", { body: { queries } });
179
+ },
180
+ async getExperimentConfigs(query) {
181
+ const response = await request(
182
+ buildExperimentConfigPath(query),
183
+ { method: "GET" }
184
+ );
185
+ return { experiments: response.data?.experiments ?? [] };
186
+ },
187
+ async assignExperiment(query) {
188
+ const response = await request(
189
+ "/v1/experiments/assign",
190
+ { body: query }
191
+ );
192
+ if (!response.data) {
193
+ throw new HeadbandError("Experiment assignment response missing data", 500);
194
+ }
195
+ return response.data;
196
+ },
197
+ async recordExperimentEvent(event) {
198
+ const response = await request("/v1/experiments/events", {
199
+ body: event
200
+ });
201
+ if (!response.data) {
202
+ throw new HeadbandError("Experiment event response missing data", 500);
203
+ }
204
+ return response.data;
205
+ },
206
+ warm(target, opts) {
207
+ return request("/v1/warm", {
208
+ body: { ...target, ...opts?.level ? { level: opts.level } : {} }
209
+ });
101
210
  }
102
211
  };
103
212
  }
104
213
 
214
+ // src/filters.ts
215
+ function quoteFilterValue(value) {
216
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
217
+ return JSON.stringify(value);
218
+ }
219
+ function filterEq(attribute, value) {
220
+ return `${attribute} = ${quoteFilterValue(value)}`;
221
+ }
222
+ function filterNotEq(attribute, value) {
223
+ return `${attribute} != ${quoteFilterValue(value)}`;
224
+ }
225
+ function filterIn(attribute, values) {
226
+ return `${attribute} IN [${values.map(quoteFilterValue).join(", ")}]`;
227
+ }
228
+ function filterExists(attribute) {
229
+ return `${attribute} EXISTS`;
230
+ }
231
+ function filterRange(attribute, range) {
232
+ const parts = [];
233
+ const gte = range.gte ?? range.min;
234
+ const lte = range.lte ?? range.max;
235
+ if (range.gt !== void 0) parts.push(`${attribute} > ${range.gt}`);
236
+ if (gte !== void 0) parts.push(`${attribute} >= ${gte}`);
237
+ if (range.lt !== void 0) parts.push(`${attribute} < ${range.lt}`);
238
+ if (lte !== void 0) parts.push(`${attribute} <= ${lte}`);
239
+ return parts.length ? andFilters(...parts) : void 0;
240
+ }
241
+ function normalizeFilter(input) {
242
+ if (!input) return [];
243
+ return (Array.isArray(input) ? input : [input]).filter((part) => Boolean(part));
244
+ }
245
+ function andFilters(...filters) {
246
+ const parts = filters.flatMap(normalizeFilter);
247
+ if (parts.length === 0) return void 0;
248
+ if (parts.length === 1) return parts[0];
249
+ return parts.map((part) => `(${part})`).join(" AND ");
250
+ }
251
+ function orFilters(...filters) {
252
+ const parts = filters.flatMap(normalizeFilter);
253
+ if (parts.length === 0) return void 0;
254
+ if (parts.length === 1) return parts[0];
255
+ return parts.map((part) => `(${part})`).join(" OR ");
256
+ }
257
+ function combineFilters(baseFilter, generatedFilter) {
258
+ const base = normalizeFilter(baseFilter);
259
+ const generated = normalizeFilter(generatedFilter);
260
+ const combined = [...base, ...generated];
261
+ if (combined.length === 0) return void 0;
262
+ if (combined.length === 1) return combined[0];
263
+ return combined;
264
+ }
265
+
105
266
  // src/provider.tsx
106
267
  var import_react = require("react");
107
268
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -109,9 +270,7 @@ var HeadbandContext = (0, import_react.createContext)(null);
109
270
  function useHeadbandContext() {
110
271
  const ctx = (0, import_react.useContext)(HeadbandContext);
111
272
  if (!ctx) {
112
- throw new Error(
113
- "useHeadbandContext must be used within a <HeadbandProvider>."
114
- );
273
+ throw new Error("useHeadbandContext must be used within a <HeadbandProvider>.");
115
274
  }
116
275
  return ctx;
117
276
  }
@@ -121,18 +280,19 @@ function buildFilterString(refinements, ranges) {
121
280
  if (values.size === 0) return;
122
281
  if (values.size === 1) {
123
282
  const [v] = values;
124
- parts.push(`${attribute} = "${v}"`);
283
+ parts.push(filterEq(attribute, v));
125
284
  } else {
126
- const list = Array.from(values).map((v) => `"${v}"`).join(", ");
127
- parts.push(`${attribute} IN [${list}]`);
285
+ parts.push(filterIn(attribute, Array.from(values)));
128
286
  }
129
287
  });
130
288
  ranges.forEach((range, attribute) => {
131
289
  if (range.min !== void 0) {
132
- parts.push(`${attribute} >= ${range.min}`);
290
+ const generated = filterRange(attribute, { min: range.min });
291
+ if (generated) parts.push(generated);
133
292
  }
134
293
  if (range.max !== void 0) {
135
- parts.push(`${attribute} <= ${range.max}`);
294
+ const generated = filterRange(attribute, { max: range.max });
295
+ if (generated) parts.push(generated);
136
296
  }
137
297
  });
138
298
  return parts.length > 0 ? parts.join(" AND ") : void 0;
@@ -141,96 +301,92 @@ function HeadbandProvider({
141
301
  client,
142
302
  index,
143
303
  initialQuery = "",
304
+ experiment = null,
305
+ baseFilter,
306
+ mode,
144
307
  children
145
308
  }) {
146
309
  const [query, setQueryRaw] = (0, import_react.useState)(initialQuery);
147
310
  const [results, setResults] = (0, import_react.useState)(null);
148
311
  const [isLoading, setIsLoading] = (0, import_react.useState)(false);
149
312
  const [error, setError] = (0, import_react.useState)(null);
150
- const [refinements, setRefinements] = (0, import_react.useState)(
151
- () => /* @__PURE__ */ new Map()
152
- );
313
+ const [refinements, setRefinements] = (0, import_react.useState)(() => /* @__PURE__ */ new Map());
153
314
  const [ranges, setRanges] = (0, import_react.useState)(() => /* @__PURE__ */ new Map());
154
315
  const [sortBy, setSortBy] = (0, import_react.useState)(null);
155
316
  const [page, setPage] = (0, import_react.useState)(0);
156
317
  const [hitsPerPage, setHitsPerPage] = (0, import_react.useState)(20);
157
318
  const [facets, setFacets] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
158
- const timerRef = (0, import_react.useRef)(null);
159
319
  const requestIdRef = (0, import_react.useRef)(0);
160
- const refinementsRef = (0, import_react.useRef)(refinements);
161
- refinementsRef.current = refinements;
162
- const rangesRef = (0, import_react.useRef)(ranges);
163
- rangesRef.current = ranges;
164
- const facetsRef = (0, import_react.useRef)(facets);
165
- facetsRef.current = facets;
320
+ const generatedFilter = (0, import_react.useMemo)(
321
+ () => buildFilterString(refinements, ranges),
322
+ [refinements, ranges]
323
+ );
324
+ const filter = (0, import_react.useMemo)(
325
+ () => combineFilters(baseFilter, generatedFilter),
326
+ [baseFilter, generatedFilter]
327
+ );
328
+ const facetList = (0, import_react.useMemo)(() => facets.size > 0 ? Array.from(facets) : void 0, [facets]);
329
+ const sortList = (0, import_react.useMemo)(() => sortBy ? [sortBy] : void 0, [sortBy]);
166
330
  const executeSearch = (0, import_react.useCallback)(() => {
167
- const id = ++requestIdRef.current;
331
+ const requestId = ++requestIdRef.current;
168
332
  setIsLoading(true);
169
333
  setError(null);
170
- const filter = buildFilterString(refinementsRef.current, rangesRef.current);
171
- const facetList = facetsRef.current.size > 0 ? Array.from(facetsRef.current) : void 0;
172
- const sortList = sortBy ? [sortBy] : void 0;
173
334
  const params = {
174
335
  limit: hitsPerPage,
175
336
  offset: page * hitsPerPage,
176
337
  ...filter ? { filter } : {},
177
338
  ...facetList ? { facets: facetList } : {},
178
339
  ...sortList ? { sort: sortList } : {},
340
+ ...experiment ? experiment : {},
341
+ ...mode ? { mode } : {},
179
342
  attributesToHighlight: ["*"]
180
343
  };
181
344
  client.search(index, query, params).then((res) => {
182
- if (id === requestIdRef.current) {
183
- setResults(res);
184
- setIsLoading(false);
345
+ if (requestId !== requestIdRef.current) {
346
+ return;
185
347
  }
348
+ setResults(res);
349
+ setIsLoading(false);
186
350
  }).catch((err) => {
187
- if (id === requestIdRef.current) {
188
- setError(err instanceof Error ? err : new Error(String(err)));
189
- setIsLoading(false);
351
+ if (requestId !== requestIdRef.current) {
352
+ return;
190
353
  }
354
+ setError(err instanceof Error ? err : new Error(String(err)));
355
+ setIsLoading(false);
191
356
  });
192
- }, [client, index, query, sortBy, page, hitsPerPage]);
357
+ }, [client, index, query, filter, facetList, sortList, experiment, mode, page, hitsPerPage]);
193
358
  (0, import_react.useEffect)(() => {
194
- if (timerRef.current) {
195
- clearTimeout(timerRef.current);
196
- }
197
- timerRef.current = setTimeout(executeSearch, 200);
359
+ const timer = setTimeout(executeSearch, 200);
198
360
  return () => {
199
- if (timerRef.current) clearTimeout(timerRef.current);
361
+ clearTimeout(timer);
200
362
  };
201
- }, [executeSearch, refinements, ranges, facets]);
363
+ }, [executeSearch]);
202
364
  const setQuery = (0, import_react.useCallback)((q) => {
203
365
  setQueryRaw(q);
204
366
  setPage(0);
205
367
  }, []);
206
- const toggleRefinement = (0, import_react.useCallback)(
207
- (attribute, value2) => {
208
- setRefinements((prev) => {
209
- const next = new Map(prev);
210
- const current = new Set(next.get(attribute) ?? []);
211
- if (current.has(value2)) {
212
- current.delete(value2);
213
- } else {
214
- current.add(value2);
215
- }
216
- next.set(attribute, current);
217
- return next;
218
- });
219
- setPage(0);
220
- },
221
- []
222
- );
223
- const setRange = (0, import_react.useCallback)(
224
- (attribute, range) => {
225
- setRanges((prev) => {
226
- const next = new Map(prev);
227
- next.set(attribute, range);
228
- return next;
229
- });
230
- setPage(0);
231
- },
232
- []
233
- );
368
+ const toggleRefinement = (0, import_react.useCallback)((attribute, value2) => {
369
+ setRefinements((prev) => {
370
+ const next = new Map(prev);
371
+ const current = new Set(next.get(attribute) ?? []);
372
+ if (current.has(value2)) {
373
+ current.delete(value2);
374
+ } else {
375
+ current.add(value2);
376
+ }
377
+ next.set(attribute, current);
378
+ return next;
379
+ });
380
+ setPage(0);
381
+ }, []);
382
+ const setRange = (0, import_react.useCallback)((attribute, range) => {
383
+ setRanges((prev) => {
384
+ const next = new Map(prev);
385
+ next.set(attribute, range);
386
+ return next;
387
+ });
388
+ setPage(0);
389
+ }, []);
234
390
  const clearRange = (0, import_react.useCallback)((attribute) => {
235
391
  setRanges((prev) => {
236
392
  const next = new Map(prev);
@@ -286,7 +442,18 @@ function HeadbandProvider({
286
442
  unregisterFacet,
287
443
  refresh
288
444
  }),
289
- [setQuery, toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet, refresh]
445
+ [
446
+ setQuery,
447
+ setPage,
448
+ setSortBy,
449
+ setHitsPerPage,
450
+ toggleRefinement,
451
+ setRange,
452
+ clearRange,
453
+ registerFacet,
454
+ unregisterFacet,
455
+ refresh
456
+ ]
290
457
  );
291
458
  const value = (0, import_react.useMemo)(
292
459
  () => ({ client, index, state, actions }),
@@ -348,18 +515,19 @@ function useRefinementList({
348
515
  actions.unregisterFacet(attribute);
349
516
  };
350
517
  }, [attribute, actions]);
351
- const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
518
+ const refined = state.refinements.get(attribute);
352
519
  const items = (0, import_react4.useMemo)(() => {
520
+ const activeRefinements = refined ?? /* @__PURE__ */ new Set();
353
521
  const distribution = state.results?.facetDistribution?.[attribute] ?? {};
354
522
  const allValues = /* @__PURE__ */ new Set([
355
523
  ...Object.keys(distribution),
356
- ...refined
524
+ ...activeRefinements
357
525
  ]);
358
526
  let result = Array.from(allValues).map(
359
527
  (value) => ({
360
528
  value,
361
529
  count: distribution[value] ?? 0,
362
- isRefined: refined.has(value)
530
+ isRefined: activeRefinements.has(value)
363
531
  })
364
532
  );
365
533
  if (sortOrder === "alpha") {
@@ -659,7 +827,11 @@ function SearchBox({
659
827
  // src/components/Hits.tsx
660
828
  var import_jsx_runtime3 = require("react/jsx-runtime");
661
829
  function DefaultHit({ hit }) {
662
- const { _formatted, __data, ...rest } = hit;
830
+ const rest = Object.fromEntries(
831
+ Object.entries(hit).filter(
832
+ ([key]) => key !== "_formatted" && key !== "__data"
833
+ )
834
+ );
663
835
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-900", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("pre", { className: "overflow-x-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300", children: JSON.stringify(rest, null, 2) }) });
664
836
  }
665
837
  function DefaultEmpty() {
@@ -720,7 +892,7 @@ function RefinementList({
720
892
  sortBy
721
893
  });
722
894
  const handleChange = (0, import_react11.useCallback)(
723
- (value) => (_e) => {
895
+ (value) => () => {
724
896
  refine(value);
725
897
  },
726
898
  [refine]
@@ -960,17 +1132,15 @@ function RangeInput({
960
1132
  maxPlaceholder = "Max"
961
1133
  }) {
962
1134
  const { min, max, stats, setRange, clear } = useRange({ attribute });
963
- const [localMin, setLocalMin] = (0, import_react14.useState)(min !== void 0 ? String(min) : "");
964
- const [localMax, setLocalMax] = (0, import_react14.useState)(max !== void 0 ? String(max) : "");
965
- (0, import_react14.useEffect)(() => {
966
- setLocalMin(min !== void 0 ? String(min) : "");
967
- }, [min]);
968
- (0, import_react14.useEffect)(() => {
969
- setLocalMax(max !== void 0 ? String(max) : "");
970
- }, [max]);
1135
+ const defaultMin = min !== void 0 ? String(min) : "";
1136
+ const defaultMax = max !== void 0 ? String(max) : "";
1137
+ const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
971
1138
  const handleSubmit = (0, import_react14.useCallback)(
972
1139
  (e) => {
973
1140
  e.preventDefault();
1141
+ const formData = new FormData(e.currentTarget);
1142
+ const localMin = String(formData.get("min") ?? "");
1143
+ const localMax = String(formData.get("max") ?? "");
974
1144
  const parsedMin = localMin !== "" ? Number(localMin) : void 0;
975
1145
  const parsedMax = localMax !== "" ? Number(localMax) : void 0;
976
1146
  if (parsedMin === void 0 && parsedMax === void 0) {
@@ -982,15 +1152,7 @@ function RangeInput({
982
1152
  });
983
1153
  }
984
1154
  },
985
- [localMin, localMax, setRange, clear]
986
- );
987
- const handleMinChange = (0, import_react14.useCallback)(
988
- (e) => setLocalMin(e.target.value),
989
- []
990
- );
991
- const handleMaxChange = (0, import_react14.useCallback)(
992
- (e) => setLocalMax(e.target.value),
993
- []
1155
+ [setRange, clear]
994
1156
  );
995
1157
  const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
996
1158
  const inputClass = styled ? "w-full rounded border border-gray-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500" : "";
@@ -1025,9 +1187,9 @@ function RangeInput({
1025
1187
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1026
1188
  "input",
1027
1189
  {
1190
+ name: "min",
1028
1191
  type: "number",
1029
- value: localMin,
1030
- onChange: handleMinChange,
1192
+ defaultValue: defaultMin,
1031
1193
  placeholder: minPlaceholder,
1032
1194
  "aria-label": `Minimum ${attribute}`,
1033
1195
  className: inputClass
@@ -1044,9 +1206,9 @@ function RangeInput({
1044
1206
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1045
1207
  "input",
1046
1208
  {
1209
+ name: "max",
1047
1210
  type: "number",
1048
- value: localMax,
1049
- onChange: handleMaxChange,
1211
+ defaultValue: defaultMax,
1050
1212
  placeholder: maxPlaceholder,
1051
1213
  "aria-label": `Maximum ${attribute}`,
1052
1214
  className: inputClass
@@ -1055,7 +1217,8 @@ function RangeInput({
1055
1217
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "submit", className: buttonClass, children: "Go" })
1056
1218
  ] })
1057
1219
  ]
1058
- }
1220
+ },
1221
+ formKey
1059
1222
  );
1060
1223
  }
1061
1224
 
@@ -1116,7 +1279,21 @@ function PoweredBy({
1116
1279
  SearchBox,
1117
1280
  SortBy,
1118
1281
  Stats,
1282
+ andFilters,
1283
+ assignExperiment,
1284
+ buildExperimentConfigPath,
1285
+ chooseExperimentVariant,
1286
+ combineFilters,
1287
+ experimentSearchMetadata,
1288
+ filterEq,
1289
+ filterExists,
1290
+ filterIn,
1291
+ filterNotEq,
1292
+ filterRange,
1119
1293
  headband,
1294
+ isRunnableExperiment,
1295
+ orFilters,
1296
+ quoteFilterValue,
1120
1297
  useHeadbandContext,
1121
1298
  useHighlight,
1122
1299
  useHits,