{"version":3,"file":"analysis.cjs","names":[],"sources":["../src/analysis.ts"],"sourcesContent":["/**\n * Core Analysis Loop\n *\n * Debugging from first principles: take the events you care about, take a\n * comparable normal population, and ask which recorded field separates them.\n * The loop is mechanical, so run it in code instead of clicking through a\n * dashboard.\n *\n * `compareCohorts()` scores every field/value pair by how much more often it\n * appears in the outlier group than in the baseline. The top result names the\n * cohort behind a regression, which is the hypothesis you then verify against\n * individual traces.\n *\n * The input is any array of flat records: wide events from\n * `getRequestLogger()`, `TestSpan.attributes` from `autotel/testing`, or rows\n * returned by a backend query.\n *\n * @example Find the cohort behind a latency regression\n * ```typescript\n * import { compareCohorts } from 'autotel/analysis'\n *\n * const slow = events.filter((event) => event['duration_ms'] >= 800)\n * const normal = events.filter((event) => event['duration_ms'] < 800)\n *\n * const [top] = compareCohorts({ outlier: slow, baseline: normal })\n * // { field: 'payment.provider', value: 'bank-beta', difference: 0.94, ... }\n * ```\n */\n\n/** A flat telemetry record. Span attributes and wide events both satisfy it. */\nexport type AnalysisEvent = Record<string, unknown>;\n\n/** How strongly one field/value pair separates the outlier group from the baseline. */\nexport interface CohortDifference {\n  /** Attribute name, such as `payment.provider`. */\n  field: string;\n  /** The value, rendered as a string for display and grouping. */\n  value: string;\n  /** Share of outlier events carrying this value, 0-1. */\n  outlierFraction: number;\n  /** Share of baseline events carrying this value, 0-1. */\n  baselineFraction: number;\n  /** `outlierFraction - baselineFraction`. Positive means over-represented in the outliers. */\n  difference: number;\n  outlierCount: number;\n  baselineCount: number;\n}\n\nexport interface CompareCohortsOptions {\n  /** The events you are investigating: the errors, the slow requests, the failed checkouts. */\n  outlier: readonly AnalysisEvent[];\n  /** A comparable normal population from the same time range. */\n  baseline: readonly AnalysisEvent[];\n  /** Restrict the scan to these fields. Defaults to every field seen in either group. */\n  fields?: readonly string[];\n  /** Fields to skip, such as identifiers you already know are unique. */\n  ignoreFields?: readonly string[];\n  /**\n   * Skip a field once it holds more distinct values than this across both\n   * groups. Default 50.\n   */\n  maxValuesPerField?: number;\n  /**\n   * Skip a field whose distinct values outnumber this share of the combined\n   * events. A request id or a raw duration takes a near-unique value per\n   * event, so its values never repeat and it cannot describe a cohort. The\n   * ratio catches those fields in small populations, where an absolute cap\n   * does not. Bucket numeric fields at instrumentation time if you want them\n   * ranked. Default 0.5.\n   */\n  maxUniqueRatio?: number;\n  /** Drop pairs whose absolute difference falls below this. Default 0.1. */\n  minDifference?: number;\n  /** Maximum results to return, strongest first. Default 20. */\n  limit?: number;\n}\n\nconst DEFAULT_MAX_VALUES_PER_FIELD = 50;\nconst DEFAULT_MAX_UNIQUE_RATIO = 0.5;\nconst DEFAULT_MIN_DIFFERENCE = 0.1;\nconst DEFAULT_LIMIT = 20;\n\n/**\n * Render a value as a grouping key.\n *\n * Objects and arrays return undefined: a nested structure does not name a\n * cohort, and stringifying it produces noise rather than a testable split.\n */\nfunction valueKey(value: unknown): string | undefined {\n  if (value === null || value === undefined) {\n    return undefined;\n  }\n  const kind = typeof value;\n  if (\n    kind === 'string' ||\n    kind === 'number' ||\n    kind === 'boolean' ||\n    kind === 'bigint'\n  ) {\n    return String(value);\n  }\n  return undefined;\n}\n\n/** Occurrences of one value, split by the group it appeared in. */\ninterface ValueCounts {\n  outlier: number;\n  baseline: number;\n}\n\n/** field -> value -> counts */\ntype FieldTally = Map<string, Map<string, ValueCounts>>;\n\n/**\n * Fold one group's events into the shared tally.\n *\n * Both groups accumulate into the same structure, so the caller never builds a\n * combined array or revisits an event once per candidate field.\n */\nfunction accumulate(\n  tally: FieldTally,\n  events: readonly AnalysisEvent[],\n  group: keyof ValueCounts,\n  includes: (field: string) => boolean,\n): void {\n  for (const event of events) {\n    for (const [field, raw] of Object.entries(event)) {\n      if (!includes(field)) {\n        continue;\n      }\n      const value = valueKey(raw);\n      if (value === undefined) {\n        continue;\n      }\n      let values = tally.get(field);\n      if (values === undefined) {\n        values = new Map<string, ValueCounts>();\n        tally.set(field, values);\n      }\n      const counts = values.get(value) ?? { outlier: 0, baseline: 0 };\n      counts[group]++;\n      values.set(value, counts);\n    }\n  }\n}\n\n/**\n * Rank the field/value pairs that separate an outlier group from a baseline.\n *\n * Returns an empty array when either group is empty, because a fraction over\n * zero events carries no information.\n *\n * @param options - The two populations and the scan limits\n * @returns Differences sorted by absolute strength, strongest first\n */\nexport function compareCohorts(\n  options: CompareCohortsOptions,\n): CohortDifference[] {\n  const {\n    outlier,\n    baseline,\n    fields,\n    ignoreFields,\n    maxValuesPerField = DEFAULT_MAX_VALUES_PER_FIELD,\n    maxUniqueRatio = DEFAULT_MAX_UNIQUE_RATIO,\n    minDifference = DEFAULT_MIN_DIFFERENCE,\n    limit = DEFAULT_LIMIT,\n  } = options;\n\n  if (outlier.length === 0 || baseline.length === 0) {\n    return [];\n  }\n\n  const ignored = new Set(ignoreFields);\n  const selected = fields === undefined ? undefined : new Set(fields);\n  const includes = (field: string) =>\n    !ignored.has(field) && (selected === undefined || selected.has(field));\n\n  const tally: FieldTally = new Map();\n  accumulate(tally, outlier, 'outlier', includes);\n  accumulate(tally, baseline, 'baseline', includes);\n\n  const results: CohortDifference[] = [];\n  const total = outlier.length + baseline.length;\n\n  for (const [field, values] of tally) {\n    if (\n      values.size > maxValuesPerField ||\n      values.size > total * maxUniqueRatio\n    ) {\n      continue;\n    }\n\n    for (const [value, counts] of values) {\n      const outlierFraction = counts.outlier / outlier.length;\n      const baselineFraction = counts.baseline / baseline.length;\n      const difference = outlierFraction - baselineFraction;\n\n      if (Math.abs(difference) < minDifference) {\n        continue;\n      }\n\n      results.push({\n        field,\n        value,\n        outlierFraction,\n        baselineFraction,\n        difference,\n        outlierCount: counts.outlier,\n        baselineCount: counts.baseline,\n      });\n    }\n  }\n\n  // Strongest first. On a tie prefer the over-represented value, because a\n  // cohort you can open traces from beats one defined by its absence. Fall\n  // back to field and value so repeated runs return the same order.\n  results.sort(\n    (a, b) =>\n      Math.abs(b.difference) - Math.abs(a.difference) ||\n      b.difference - a.difference ||\n      a.field.localeCompare(b.field) ||\n      a.value.localeCompare(b.value),\n  );\n\n  return results.slice(0, limit);\n}\n"],"mappings":";;;AA6EA,MAAM,+BAA+B;AACrC,MAAM,2BAA2B;AACjC,MAAM,yBAAyB;AAC/B,MAAM,gBAAgB;;;;;;;AAQtB,SAAS,SAAS,OAAoC;CACpD,IAAI,UAAU,QAAQ,UAAU,QAC9B;CAEF,MAAM,OAAO,OAAO;CACpB,IACE,SAAS,YACT,SAAS,YACT,SAAS,aACT,SAAS,UAET,OAAO,OAAO,KAAK;AAGvB;;;;;;;AAiBA,SAAS,WACP,OACA,QACA,OACA,UACM;CACN,KAAK,MAAM,SAAS,QAClB,KAAK,MAAM,CAAC,OAAO,QAAQ,OAAO,QAAQ,KAAK,GAAG;EAChD,IAAI,CAAC,SAAS,KAAK,GACjB;EAEF,MAAM,QAAQ,SAAS,GAAG;EAC1B,IAAI,UAAU,QACZ;EAEF,IAAI,SAAS,MAAM,IAAI,KAAK;EAC5B,IAAI,WAAW,QAAW;GACxB,yBAAS,IAAI,IAAyB;GACtC,MAAM,IAAI,OAAO,MAAM;EACzB;EACA,MAAM,SAAS,OAAO,IAAI,KAAK,KAAK;GAAE,SAAS;GAAG,UAAU;EAAE;EAC9D,OAAO,MAAM;EACb,OAAO,IAAI,OAAO,MAAM;CAC1B;AAEJ;;;;;;;;;;AAWA,SAAgB,eACd,SACoB;CACpB,MAAM,EACJ,SACA,UACA,QACA,cACA,oBAAoB,8BACpB,iBAAiB,0BACjB,gBAAgB,wBAChB,QAAQ,kBACN;CAEJ,IAAI,QAAQ,WAAW,KAAK,SAAS,WAAW,GAC9C,OAAO,CAAC;CAGV,MAAM,UAAU,IAAI,IAAI,YAAY;CACpC,MAAM,WAAW,WAAW,SAAY,SAAY,IAAI,IAAI,MAAM;CAClE,MAAM,YAAY,UAChB,CAAC,QAAQ,IAAI,KAAK,MAAM,aAAa,UAAa,SAAS,IAAI,KAAK;CAEtE,MAAM,wBAAoB,IAAI,IAAI;CAClC,WAAW,OAAO,SAAS,WAAW,QAAQ;CAC9C,WAAW,OAAO,UAAU,YAAY,QAAQ;CAEhD,MAAM,UAA8B,CAAC;CACrC,MAAM,QAAQ,QAAQ,SAAS,SAAS;CAExC,KAAK,MAAM,CAAC,OAAO,WAAW,OAAO;EACnC,IACE,OAAO,OAAO,qBACd,OAAO,OAAO,QAAQ,gBAEtB;EAGF,KAAK,MAAM,CAAC,OAAO,WAAW,QAAQ;GACpC,MAAM,kBAAkB,OAAO,UAAU,QAAQ;GACjD,MAAM,mBAAmB,OAAO,WAAW,SAAS;GACpD,MAAM,aAAa,kBAAkB;GAErC,IAAI,KAAK,IAAI,UAAU,IAAI,eACzB;GAGF,QAAQ,KAAK;IACX;IACA;IACA;IACA;IACA;IACA,cAAc,OAAO;IACrB,eAAe,OAAO;GACxB,CAAC;EACH;CACF;CAKA,QAAQ,MACL,GAAG,MACF,KAAK,IAAI,EAAE,UAAU,IAAI,KAAK,IAAI,EAAE,UAAU,KAC9C,EAAE,aAAa,EAAE,cACjB,EAAE,MAAM,cAAc,EAAE,KAAK,KAC7B,EAAE,MAAM,cAAc,EAAE,KAAK,CACjC;CAEA,OAAO,QAAQ,MAAM,GAAG,KAAK;AAC/B"}