autotel
Version:
Write Once, Observe Anywhere
87 lines (85 loc) • 3.29 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
//#region src/analysis.ts
const DEFAULT_MAX_VALUES_PER_FIELD = 50;
const DEFAULT_MAX_UNIQUE_RATIO = .5;
const DEFAULT_MIN_DIFFERENCE = .1;
const DEFAULT_LIMIT = 20;
/**
* Render a value as a grouping key.
*
* Objects and arrays return undefined: a nested structure does not name a
* cohort, and stringifying it produces noise rather than a testable split.
*/
function valueKey(value) {
if (value === null || value === void 0) return;
const kind = typeof value;
if (kind === "string" || kind === "number" || kind === "boolean" || kind === "bigint") return String(value);
}
/**
* Fold one group's events into the shared tally.
*
* Both groups accumulate into the same structure, so the caller never builds a
* combined array or revisits an event once per candidate field.
*/
function accumulate(tally, events, group, includes) {
for (const event of events) for (const [field, raw] of Object.entries(event)) {
if (!includes(field)) continue;
const value = valueKey(raw);
if (value === void 0) continue;
let values = tally.get(field);
if (values === void 0) {
values = /* @__PURE__ */ new Map();
tally.set(field, values);
}
const counts = values.get(value) ?? {
outlier: 0,
baseline: 0
};
counts[group]++;
values.set(value, counts);
}
}
/**
* Rank the field/value pairs that separate an outlier group from a baseline.
*
* Returns an empty array when either group is empty, because a fraction over
* zero events carries no information.
*
* @param options - The two populations and the scan limits
* @returns Differences sorted by absolute strength, strongest first
*/
function compareCohorts(options) {
const { outlier, baseline, fields, ignoreFields, maxValuesPerField = DEFAULT_MAX_VALUES_PER_FIELD, maxUniqueRatio = DEFAULT_MAX_UNIQUE_RATIO, minDifference = DEFAULT_MIN_DIFFERENCE, limit = DEFAULT_LIMIT } = options;
if (outlier.length === 0 || baseline.length === 0) return [];
const ignored = new Set(ignoreFields);
const selected = fields === void 0 ? void 0 : new Set(fields);
const includes = (field) => !ignored.has(field) && (selected === void 0 || selected.has(field));
const tally = /* @__PURE__ */ new Map();
accumulate(tally, outlier, "outlier", includes);
accumulate(tally, baseline, "baseline", includes);
const results = [];
const total = outlier.length + baseline.length;
for (const [field, values] of tally) {
if (values.size > maxValuesPerField || values.size > total * maxUniqueRatio) continue;
for (const [value, counts] of values) {
const outlierFraction = counts.outlier / outlier.length;
const baselineFraction = counts.baseline / baseline.length;
const difference = outlierFraction - baselineFraction;
if (Math.abs(difference) < minDifference) continue;
results.push({
field,
value,
outlierFraction,
baselineFraction,
difference,
outlierCount: counts.outlier,
baselineCount: counts.baseline
});
}
}
results.sort((a, b) => Math.abs(b.difference) - Math.abs(a.difference) || b.difference - a.difference || a.field.localeCompare(b.field) || a.value.localeCompare(b.value));
return results.slice(0, limit);
}
//#endregion
exports.compareCohorts = compareCohorts;
//# sourceMappingURL=analysis.cjs.map