@uptimizr/db 2.0.0 → 2.1.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.
Files changed (173) hide show
  1. package/AGENTS.md +228 -3
  2. package/README.md +13 -0
  3. package/dist/cli/seed.js +9 -7
  4. package/dist/cli/seed.js.map +1 -1
  5. package/dist/duckdb/migrations.d.ts.map +1 -1
  6. package/dist/duckdb/migrations.js +169 -0
  7. package/dist/duckdb/migrations.js.map +1 -1
  8. package/dist/duckdb/panelSpecs.d.ts +29 -0
  9. package/dist/duckdb/panelSpecs.d.ts.map +1 -0
  10. package/dist/duckdb/panelSpecs.js +104 -0
  11. package/dist/duckdb/panelSpecs.js.map +1 -0
  12. package/dist/duckdb/projectMetadata.d.ts +43 -0
  13. package/dist/duckdb/projectMetadata.d.ts.map +1 -0
  14. package/dist/duckdb/projectMetadata.js +222 -0
  15. package/dist/duckdb/projectMetadata.js.map +1 -0
  16. package/dist/duckdb/subscriptions.d.ts +45 -0
  17. package/dist/duckdb/subscriptions.d.ts.map +1 -0
  18. package/dist/duckdb/subscriptions.js +202 -0
  19. package/dist/duckdb/subscriptions.js.map +1 -0
  20. package/dist/index.d.ts +18 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +48 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/insights/anomalies.d.ts +213 -0
  25. package/dist/insights/anomalies.d.ts.map +1 -0
  26. package/dist/insights/anomalies.js +340 -0
  27. package/dist/insights/anomalies.js.map +1 -0
  28. package/dist/insights/baseline.d.ts +51 -0
  29. package/dist/insights/baseline.d.ts.map +1 -0
  30. package/dist/insights/baseline.js +48 -0
  31. package/dist/insights/baseline.js.map +1 -0
  32. package/dist/insights/buckets.d.ts +114 -0
  33. package/dist/insights/buckets.d.ts.map +1 -0
  34. package/dist/insights/buckets.js +220 -0
  35. package/dist/insights/buckets.js.map +1 -0
  36. package/dist/insights/changepoint.d.ts +130 -0
  37. package/dist/insights/changepoint.d.ts.map +1 -0
  38. package/dist/insights/changepoint.js +209 -0
  39. package/dist/insights/changepoint.js.map +1 -0
  40. package/dist/insights/evaluate.d.ts +63 -0
  41. package/dist/insights/evaluate.d.ts.map +1 -0
  42. package/dist/insights/evaluate.js +158 -0
  43. package/dist/insights/evaluate.js.map +1 -0
  44. package/dist/insights/health.d.ts +219 -0
  45. package/dist/insights/health.d.ts.map +1 -0
  46. package/dist/insights/health.js +349 -0
  47. package/dist/insights/health.js.map +1 -0
  48. package/dist/insights/index.d.ts +45 -0
  49. package/dist/insights/index.d.ts.map +1 -0
  50. package/dist/insights/index.js +40 -0
  51. package/dist/insights/index.js.map +1 -0
  52. package/dist/insights/measures.d.ts +222 -0
  53. package/dist/insights/measures.d.ts.map +1 -0
  54. package/dist/insights/measures.js +431 -0
  55. package/dist/insights/measures.js.map +1 -0
  56. package/dist/insights/movers.d.ts +123 -0
  57. package/dist/insights/movers.d.ts.map +1 -0
  58. package/dist/insights/movers.js +166 -0
  59. package/dist/insights/movers.js.map +1 -0
  60. package/dist/insights/significance.d.ts +221 -0
  61. package/dist/insights/significance.d.ts.map +1 -0
  62. package/dist/insights/significance.js +531 -0
  63. package/dist/insights/significance.js.map +1 -0
  64. package/dist/insights/stats.d.ts +104 -0
  65. package/dist/insights/stats.d.ts.map +1 -0
  66. package/dist/insights/stats.js +181 -0
  67. package/dist/insights/stats.js.map +1 -0
  68. package/dist/insights/windows.d.ts +95 -0
  69. package/dist/insights/windows.d.ts.map +1 -0
  70. package/dist/insights/windows.js +97 -0
  71. package/dist/insights/windows.js.map +1 -0
  72. package/dist/metadata.d.ts +178 -1
  73. package/dist/metadata.d.ts.map +1 -1
  74. package/dist/metadata.js +80 -0
  75. package/dist/metadata.js.map +1 -1
  76. package/dist/narrative/build.d.ts +78 -0
  77. package/dist/narrative/build.d.ts.map +1 -0
  78. package/dist/narrative/build.js +437 -0
  79. package/dist/narrative/build.js.map +1 -0
  80. package/dist/narrative/index.d.ts +14 -0
  81. package/dist/narrative/index.d.ts.map +1 -0
  82. package/dist/narrative/index.js +13 -0
  83. package/dist/narrative/index.js.map +1 -0
  84. package/dist/narrative/text.d.ts +25 -0
  85. package/dist/narrative/text.d.ts.map +1 -0
  86. package/dist/narrative/text.js +41 -0
  87. package/dist/narrative/text.js.map +1 -0
  88. package/dist/parity/cases.d.ts +3 -0
  89. package/dist/parity/cases.d.ts.map +1 -1
  90. package/dist/parity/cases.js +387 -7
  91. package/dist/parity/cases.js.map +1 -1
  92. package/dist/parity/compare.d.ts +19 -0
  93. package/dist/parity/compare.d.ts.map +1 -1
  94. package/dist/parity/compare.js +19 -0
  95. package/dist/parity/compare.js.map +1 -1
  96. package/dist/parity/fixtures.d.ts.map +1 -1
  97. package/dist/parity/fixtures.js +25 -0
  98. package/dist/parity/fixtures.js.map +1 -1
  99. package/dist/query/aggregations.d.ts +46 -1
  100. package/dist/query/aggregations.d.ts.map +1 -1
  101. package/dist/query/aggregations.js +86 -0
  102. package/dist/query/aggregations.js.map +1 -1
  103. package/dist/query/clickhouseDialect.d.ts.map +1 -1
  104. package/dist/query/clickhouseDialect.js +5 -1
  105. package/dist/query/clickhouseDialect.js.map +1 -1
  106. package/dist/query/customEventVocabulary.d.ts +46 -0
  107. package/dist/query/customEventVocabulary.d.ts.map +1 -0
  108. package/dist/query/customEventVocabulary.js +127 -0
  109. package/dist/query/customEventVocabulary.js.map +1 -0
  110. package/dist/query/dsl/builders.d.ts +38 -0
  111. package/dist/query/dsl/builders.d.ts.map +1 -0
  112. package/dist/query/dsl/builders.js +35 -0
  113. package/dist/query/dsl/builders.js.map +1 -0
  114. package/dist/query/dsl/compare.d.ts +167 -0
  115. package/dist/query/dsl/compare.d.ts.map +1 -0
  116. package/dist/query/dsl/compare.js +358 -0
  117. package/dist/query/dsl/compare.js.map +1 -0
  118. package/dist/query/dsl/compile.d.ts +89 -0
  119. package/dist/query/dsl/compile.d.ts.map +1 -0
  120. package/dist/query/dsl/compile.js +174 -0
  121. package/dist/query/dsl/compile.js.map +1 -0
  122. package/dist/query/dsl/explain.d.ts +115 -0
  123. package/dist/query/dsl/explain.d.ts.map +1 -0
  124. package/dist/query/dsl/explain.js +143 -0
  125. package/dist/query/dsl/explain.js.map +1 -0
  126. package/dist/query/dsl/generic.d.ts +127 -0
  127. package/dist/query/dsl/generic.d.ts.map +1 -0
  128. package/dist/query/dsl/generic.js +323 -0
  129. package/dist/query/dsl/generic.js.map +1 -0
  130. package/dist/query/dsl/index.d.ts +30 -0
  131. package/dist/query/dsl/index.d.ts.map +1 -0
  132. package/dist/query/dsl/index.js +23 -0
  133. package/dist/query/dsl/index.js.map +1 -0
  134. package/dist/query/dsl/order.d.ts +41 -0
  135. package/dist/query/dsl/order.d.ts.map +1 -0
  136. package/dist/query/dsl/order.js +55 -0
  137. package/dist/query/dsl/order.js.map +1 -0
  138. package/dist/query/dsl/significance.d.ts +130 -0
  139. package/dist/query/dsl/significance.d.ts.map +1 -0
  140. package/dist/query/dsl/significance.js +224 -0
  141. package/dist/query/dsl/significance.js.map +1 -0
  142. package/dist/query/index.d.ts +2 -0
  143. package/dist/query/index.d.ts.map +1 -1
  144. package/dist/query/index.js +8 -0
  145. package/dist/query/index.js.map +1 -1
  146. package/dist/query/summary/index.d.ts +4 -2
  147. package/dist/query/summary/index.d.ts.map +1 -1
  148. package/dist/query/summary/index.js +2 -1
  149. package/dist/query/summary/index.js.map +1 -1
  150. package/dist/query/summary/labels.d.ts +101 -0
  151. package/dist/query/summary/labels.d.ts.map +1 -0
  152. package/dist/query/summary/labels.js +261 -0
  153. package/dist/query/summary/labels.js.map +1 -0
  154. package/dist/query/summary/reading.d.ts.map +1 -1
  155. package/dist/query/summary/reading.js +21 -2
  156. package/dist/query/summary/reading.js.map +1 -1
  157. package/dist/query/summary/schema.d.ts +250 -20
  158. package/dist/query/summary/schema.d.ts.map +1 -1
  159. package/dist/query/summary/schema.js +135 -123
  160. package/dist/query/summary/schema.js.map +1 -1
  161. package/dist/query/summary/summarize.d.ts.map +1 -1
  162. package/dist/query/summary/summarize.js +91 -5
  163. package/dist/query/summary/summarize.js.map +1 -1
  164. package/dist/query/summary/types.d.ts +77 -2
  165. package/dist/query/summary/types.d.ts.map +1 -1
  166. package/dist/query/types.d.ts +47 -1
  167. package/dist/query/types.d.ts.map +1 -1
  168. package/dist/subscriptions.d.ts +208 -0
  169. package/dist/subscriptions.d.ts.map +1 -0
  170. package/dist/subscriptions.js +153 -0
  171. package/dist/subscriptions.js.map +1 -0
  172. package/llms.txt +9 -0
  173. package/package.json +8 -8
@@ -0,0 +1,220 @@
1
+ /**
2
+ * **The one generic bucket query** behind both insight primitives
3
+ * (ADR 0051 §4, design sketch §D).
4
+ *
5
+ * `buildMetricBuckets` renders a {@link BucketMeasure} — the declarative
6
+ * description of how a comparable metric's primary column is reproduced per
7
+ * time bucket, from `measures.ts` — into a single grouped scan of `events`:
8
+ *
9
+ * ```sql
10
+ * SELECT <time bucket> AS bucket, <aggregate> AS value, <denominator> AS sample_size
11
+ * FROM events
12
+ * WHERE project_id = ? AND event_type IN (…) AND …
13
+ * GROUP BY bucket ORDER BY bucket ASC
14
+ * ```
15
+ *
16
+ * It is authored against the {@link Dialect} interface like every aggregation in
17
+ * `query/aggregations.ts`, and parity-tested on all four engines through
18
+ * `src/parity/cases.ts`. It deliberately does **not** live in
19
+ * `query/aggregations.ts`: the `build*` exports of that module are the registry's
20
+ * closed list of *metrics*, each of which must have its own registry entry and
21
+ * endpoint. This is not a metric — it is the shared input of two, and has no
22
+ * endpoint, no row schema and no tool of its own. Keeping it here keeps the
23
+ * registry's coverage invariant meaningful.
24
+ *
25
+ * No statistic is computed in SQL. The query returns raw per-bucket values and
26
+ * everything else — mean, median, MAD, quantiles, slope, robust z — is pure
27
+ * TypeScript in `stats.ts`, so no two engines can disagree about an insight.
28
+ */
29
+ import { ParamBag, rangeClause, sceneClause } from "../query/dialect.js";
30
+ import { BUCKET_SECONDS, BUCKET_SPLIT_COLUMNS, resolveBucketMeasure, } from "./measures.js";
31
+ /** The SQL expression for a promoted value column, per aggregate. */
32
+ function valueColumnExpr(column, aggregate, d) {
33
+ switch (column) {
34
+ case "fps":
35
+ return "fps";
36
+ case "visible_ms":
37
+ return "visible_ms";
38
+ case "long_frames":
39
+ // Promoted, `NOT NULL DEFAULT 0`, and a 0 here is a real observation — a
40
+ // sampled window in which no frame ran long. Unlike `js_heap_bytes` it is
41
+ // never nulled out: excluding the good windows would report every project
42
+ // as permanently janky.
43
+ return "long_frames";
44
+ case "js_heap_bytes":
45
+ // A heap reading of 0 means "the browser did not report one" (the column
46
+ // is `NOT NULL DEFAULT 0`), so averages and percentiles must exclude it —
47
+ // exactly what `buildResourceSummary` / `buildResourcePercentiles` do. A
48
+ // `max` keeps the raw column: the largest of a set that includes 0 is
49
+ // unaffected by it, and nulling it out would only cost a branch.
50
+ return aggregate === "max" ? "js_heap_bytes" : "nullIf(js_heap_bytes, 0)";
51
+ case "ar_placement_scale":
52
+ // Not promoted; read from the payload exactly as `buildArPlacementSurfaces`
53
+ // reads it, so the bucket series and the metric agree.
54
+ return d.jsonFloat("payload", "scale");
55
+ }
56
+ }
57
+ /** The `SELECT` expression that produces a bucket's value. */
58
+ function aggregateExpr(aggregate, d) {
59
+ switch (aggregate.kind) {
60
+ case "count":
61
+ return "count(*)";
62
+ case "sessions":
63
+ return "count(DISTINCT session_id)";
64
+ case "sum":
65
+ return `sum(${valueColumnExpr(aggregate.column, "sum", d)})`;
66
+ case "avg":
67
+ return `avg(${valueColumnExpr(aggregate.column, "avg", d)})`;
68
+ case "max":
69
+ return `max(${valueColumnExpr(aggregate.column, "max", d)})`;
70
+ case "quantile":
71
+ return d.quantile(valueColumnExpr(aggregate.column, "quantile", d), aggregate.q);
72
+ }
73
+ }
74
+ /**
75
+ * The `SELECT` expression for the bucket's denominator.
76
+ *
77
+ * For a session-valued measure the denominator *is* the distinct-session count
78
+ * (`minSample: 30` on `view_coverage_histogram` means thirty sessions, not
79
+ * thirty camera samples); for every other measure it is the number of events
80
+ * that contributed.
81
+ */
82
+ function sampleSizeExpr(aggregate) {
83
+ return aggregate.kind === "sessions" ? "count(DISTINCT session_id)" : "count(*)";
84
+ }
85
+ /** `AND event_type IN (…)`, or `""` when the measure counts every channel. */
86
+ function eventTypeClause(bag, measure) {
87
+ const types = measure.eventTypes;
88
+ if (types.length === 0)
89
+ return "";
90
+ if (types.length === 1) {
91
+ return ` AND event_type = ${bag.add("bmType0", "string", types[0])}`;
92
+ }
93
+ const placeholders = types.map((type, index) => bag.add(`bmType${index}`, "string", type));
94
+ return ` AND event_type IN (${placeholders.join(", ")})`;
95
+ }
96
+ /**
97
+ * One extra predicate, rendered from the closed vocabulary in `measures.ts`.
98
+ *
99
+ * Column names come from a compile-time union and values are always bound as
100
+ * parameters, so nothing a caller can influence reaches the SQL text.
101
+ */
102
+ function predicateClause(bag, d, predicate, index) {
103
+ switch (predicate.kind) {
104
+ case "eq":
105
+ return ` AND ${predicate.column} = ${bag.add(`bmP${index}`, "string", predicate.value)}`;
106
+ case "ne":
107
+ return ` AND ${predicate.column} <> ${bag.add(`bmP${index}`, "string", predicate.value)}`;
108
+ case "in": {
109
+ const placeholders = predicate.values.map((value, i) => bag.add(`bmP${index}_${i}`, "string", value));
110
+ return ` AND ${predicate.column} IN (${placeholders.join(", ")})`;
111
+ }
112
+ case "geometry":
113
+ return ` AND ${d.arrayLength(predicate.column)} = ${predicate.arity}`;
114
+ }
115
+ }
116
+ /**
117
+ * Build the per-bucket series of a metric's comparable primary column.
118
+ *
119
+ * Throws when the metric has no portable bucket form — the route validates
120
+ * against `BUCKETABLE_METRIC_IDS` at the edge and answers `400`, so reaching
121
+ * this is a programming error rather than bad input.
122
+ */
123
+ export function buildMetricBuckets(projectId, opts, d) {
124
+ const measure = resolveBucketMeasure(opts.metric, opts.series);
125
+ if (measure == null) {
126
+ throw new Error(opts.series == null
127
+ ? `metric '${opts.metric}' has no portable bucket series`
128
+ : `metric '${opts.metric}' declares no '${opts.series}' series`);
129
+ }
130
+ const bag = new ParamBag(d);
131
+ const pid = bag.add("projectId", "string", projectId);
132
+ const seconds = BUCKET_SECONDS[opts.bucket ?? "day"];
133
+ const interval = bag.add("bmInterval", "u32", seconds);
134
+ const types = eventTypeClause(bag, measure);
135
+ const range = rangeClause(bag, opts);
136
+ const scene = sceneClause(bag, opts);
137
+ const predicates = (measure.where ?? [])
138
+ .map((predicate, index) => predicateClause(bag, d, predicate, index))
139
+ .join("");
140
+ // --- anomalies (#306): the optional grouped split ------------------------
141
+ // The column comes from the measure's own declaration via a compile-time
142
+ // union, so the only way a name reaches the SQL text is by being in
143
+ // `BUCKET_SPLIT_COLUMNS`. The grouping key is projected, grouped and ordered
144
+ // by so the rows arrive in a stable order on every engine.
145
+ const splitColumn = opts.groupBy == null ? null : BUCKET_SPLIT_COLUMNS[opts.groupBy];
146
+ const splitSelect = splitColumn == null ? "" : `,\n ${splitColumn} AS dimension_value`;
147
+ const splitGroup = splitColumn == null ? "" : `, ${splitColumn}`;
148
+ const splitOrder = splitColumn == null ? "" : `, ${splitColumn} ASC`;
149
+ return {
150
+ query: `
151
+ SELECT
152
+ ${d.timeBucketMs("ts", interval)} AS bucket,
153
+ ${aggregateExpr(measure.aggregate, d)} AS value,
154
+ ${sampleSizeExpr(measure.aggregate)} AS sample_size${splitSelect}
155
+ FROM events
156
+ WHERE project_id = ${pid}${types}${range}${scene}${predicates}
157
+ GROUP BY bucket${splitGroup}
158
+ ORDER BY bucket ASC${splitOrder}
159
+ `,
160
+ query_params: bag.values,
161
+ };
162
+ }
163
+ /**
164
+ * Total order over bucket rows: by bucket, then by the split value on a grouped
165
+ * read (#306).
166
+ *
167
+ * Compared by code point rather than `localeCompare`, so the order is a property
168
+ * of the data instead of the ICU build the collector happens to run on — the same
169
+ * reason every statistic in this directory is computed in TypeScript.
170
+ */
171
+ export function byBucketThenDimension(a, b) {
172
+ if (a.bucket !== b.bucket)
173
+ return a.bucket - b.bucket;
174
+ const left = a.dimension_value ?? "";
175
+ const right = b.dimension_value ?? "";
176
+ return left < right ? -1 : left > right ? 1 : 0;
177
+ }
178
+ /**
179
+ * Normalise driver output into a {@link MetricBucketRow}.
180
+ *
181
+ * `buildMetricBuckets` produces no registry metric, so it carries no
182
+ * `QuerySpec.metric` tag and the store-edge coercion (ADR 0051 §2) has no row
183
+ * schema to coerce it against. ClickHouse string-encodes 64-bit counts over
184
+ * HTTP, so the numbers are parsed here instead — the one place every store's
185
+ * bucket read passes through.
186
+ */
187
+ export function toMetricBucketRows(rows) {
188
+ const out = [];
189
+ for (const row of rows) {
190
+ const bucket = toNumber(row.bucket);
191
+ if (bucket == null)
192
+ continue;
193
+ out.push({
194
+ bucket,
195
+ value: toNumber(row.value),
196
+ sample_size: toNumber(row.sample_size) ?? 0,
197
+ // Present only on a grouped read (#306); a null group key is the store's
198
+ // "unknown", which every other aggregation also renders as `''`.
199
+ ...(Object.prototype.hasOwnProperty.call(row, "dimension_value")
200
+ ? { dimension_value: row.dimension_value == null ? "" : String(row.dimension_value) }
201
+ : {}),
202
+ });
203
+ }
204
+ // Stable on both shapes: bucket first, then the split value, so a grouped
205
+ // series is grouped in TypeScript in the order the engines returned it.
206
+ return out.sort(byBucketThenDimension);
207
+ }
208
+ /** A finite number from a driver cell, or `null`. */
209
+ function toNumber(value) {
210
+ if (typeof value === "number")
211
+ return Number.isFinite(value) ? value : null;
212
+ if (typeof value === "bigint")
213
+ return Number(value);
214
+ if (typeof value === "string") {
215
+ const parsed = Number(value.trim());
216
+ return value.trim() !== "" && Number.isFinite(parsed) ? parsed : null;
217
+ }
218
+ return null;
219
+ }
220
+ //# sourceMappingURL=buckets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buckets.js","sourceRoot":"","sources":["../../src/insights/buckets.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * **Rolling robust statistics and change-point detection** (ADR 0051 §4,
3
+ * design sketch §D row `anomalies`).
4
+ *
5
+ * Two generic, series-shaped algorithms that `anomalies.ts` composes, kept in
6
+ * their own module for the same reason `stats.ts` exists: they are pure
7
+ * functions of `readonly number[]` with no notion of a metric, a bucket, a store
8
+ * or a dialect, and they are unit-tested against hand-built series rather than
9
+ * against a query result.
10
+ *
11
+ * ## Why these two, and not a model
12
+ *
13
+ * A self-hosted collector has one project's history and no training budget, so
14
+ * an anomaly detector here has to work on a few dozen buckets, explain itself in
15
+ * one number, and never need tuning per metric. Two classical methods do exactly
16
+ * that, and they answer *different* questions — which is why both are here:
17
+ *
18
+ * - {@link rollingRobustStats} answers **"is this bucket unlike the ones just
19
+ * before it?"** — a single surprising day. It is a *point* detector.
20
+ * - {@link cusumChangePoints} answers **"did the level move and stay moved?"** —
21
+ * a regression that is only 1.5 MADs deep and so never trips a point detector,
22
+ * but that persists for a fortnight. It is a *sequential* detector, and it is
23
+ * the one that catches the shape a release regression actually has.
24
+ *
25
+ * Both are robust by construction: the centre is a median and the scale is a
26
+ * median absolute deviation, so one catastrophic bucket in the trailing window
27
+ * cannot widen the tolerance until nothing is ever abnormal again.
28
+ */
29
+ /**
30
+ * The trailing centre and spread for one index of a series.
31
+ *
32
+ * `null` in both fields means the index had too little history to be judged —
33
+ * never `0`, which would claim a perfectly flat past.
34
+ */
35
+ export interface RollingRobustPoint {
36
+ /** Median of the trailing window; `null` when it held too few values. */
37
+ median: number | null;
38
+ /** Median absolute deviation of the same window; `null` alongside a null median. */
39
+ mad: number | null;
40
+ /** How many values the trailing window actually carried. */
41
+ count: number;
42
+ }
43
+ /**
44
+ * The trailing median and MAD for every index of `values`.
45
+ *
46
+ * The window for index `i` is `values[i - window .. i - 1]` — **the point itself
47
+ * is excluded**, which is the whole point. Including it drags the median toward
48
+ * the very value being judged and inflates the MAD by the deviation being
49
+ * measured, so a big enough spike partly hides itself. Excluding it makes the
50
+ * comparison "against what came before", which is what a reader means by
51
+ * "unusual".
52
+ *
53
+ * Indices with fewer than `minCount` trailing values report `null`: the first
54
+ * days of a project are not evidence about the first days of a project, and a
55
+ * detector that flags them produces a page of findings on every new install.
56
+ *
57
+ * Non-finite entries are skipped (they never enter a window and are never
58
+ * scored), so a sparse series behaves like the shorter series it really is.
59
+ */
60
+ export declare function rollingRobustStats(values: readonly number[], window: number, minCount: number): RollingRobustPoint[];
61
+ /**
62
+ * Slack ("allowance") of the tabular CUSUM, in MAD units.
63
+ *
64
+ * The accumulator only grows once a deviation exceeds this, so ordinary
65
+ * bucket-to-bucket noise cancels instead of drifting upward forever. Half a MAD
66
+ * is the textbook choice — it is the shift size the chart is tuned to detect
67
+ * fastest — and keeping it a constant rather than a parameter is deliberate:
68
+ * `sensitivity` already moves the decision threshold, and two dials that both
69
+ * mean "how eager" would be impossible for a caller to reason about.
70
+ */
71
+ export declare const CUSUM_SLACK = 0.5;
72
+ /**
73
+ * The decision threshold, as a multiple of `sensitivity`.
74
+ *
75
+ * The alarm fires when the accumulated excess passes
76
+ * `sensitivity × CUSUM_DECISION_FACTOR` MADs. At the default `sensitivity` of 3
77
+ * that is 6 MADs of *accumulated* excess — reachable by four consecutive buckets
78
+ * two MADs off, or by a dozen buckets barely off, but not by noise. It keeps the
79
+ * one dial monotone: a higher `sensitivity` means fewer findings for the point
80
+ * detector and for the change-point detector alike.
81
+ */
82
+ export declare const CUSUM_DECISION_FACTOR = 2;
83
+ /** A detected level shift in a series. */
84
+ export interface ChangePoint {
85
+ /** Index at which the shift began — the last point before the drift started. */
86
+ index: number;
87
+ /** Direction of the shift. */
88
+ direction: "up" | "down";
89
+ /** Median of the segment before the change-point. */
90
+ before: number | null;
91
+ /** Median of the segment from the change-point to the next one (or the end). */
92
+ after: number | null;
93
+ /** Accumulated excess, in MADs, at the bucket that raised the alarm. */
94
+ score: number;
95
+ }
96
+ /**
97
+ * Two-sided tabular CUSUM over `values`, segmented so several level changes in
98
+ * one series are found rather than one.
99
+ *
100
+ * ```
101
+ * eᵢ = clip((xᵢ − reference) / spread, ±sensitivity)
102
+ * S⁺ᵢ = max(0, S⁺ᵢ₋₁ + eᵢ − k) S⁻ᵢ = max(0, S⁻ᵢ₋₁ − eᵢ − k)
103
+ * ```
104
+ *
105
+ * Three decisions make this a *change-point* detector rather than a chart:
106
+ *
107
+ * 1. **The reference is the level the segment started at** — the median of its
108
+ * first `referenceLength` values — not the median of the whole series. With a
109
+ * whole-series centre, a series that steps up halfway sits *above* its own
110
+ * median for the entire second half and *below* it for the entire first, so
111
+ * the accumulator starts drifting at index 0 and the alarm names the wrong
112
+ * bucket. Measuring against where the series began is what makes "it moved"
113
+ * a statement about a moment.
114
+ * 2. **An alarm is reported where the accumulator last left zero**, not where it
115
+ * crossed the threshold. A CUSUM crosses several buckets after the level
116
+ * actually moved — that lag is how it earns its sensitivity — and reporting
117
+ * the crossing would put the finding on the wrong day.
118
+ * 3. **After an alarm the series is re-segmented from the change-point**, with
119
+ * the reference re-estimated over the new regime. Without that, a shifted
120
+ * series keeps deviating from the old reference forever and alarms again
121
+ * every few buckets; with it, a level that moves and moves back reports
122
+ * exactly two change-points.
123
+ *
124
+ * `spread` is supplied by the caller (`anomalies.ts` passes the same floored MAD
125
+ * the point detector uses, so both halves of the primitive are measured in one
126
+ * unit). A non-positive or missing `spread` means there is nothing to detect and
127
+ * the result is empty.
128
+ */
129
+ export declare function cusumChangePoints(values: readonly number[], spread: number | null, sensitivity: number, referenceLength: number): ChangePoint[];
130
+ //# sourceMappingURL=changepoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changepoint.d.ts","sourceRoot":"","sources":["../../src/insights/changepoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,oFAAoF;IACpF,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,kBAAkB,EAAE,CAyBtB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,MAAM,CAAC;AAE/B;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAoBvC,0CAA0C;AAC1C,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,qDAAqD;IACrD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM,GACtB,WAAW,EAAE,CAyCf"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * **Rolling robust statistics and change-point detection** (ADR 0051 §4,
3
+ * design sketch §D row `anomalies`).
4
+ *
5
+ * Two generic, series-shaped algorithms that `anomalies.ts` composes, kept in
6
+ * their own module for the same reason `stats.ts` exists: they are pure
7
+ * functions of `readonly number[]` with no notion of a metric, a bucket, a store
8
+ * or a dialect, and they are unit-tested against hand-built series rather than
9
+ * against a query result.
10
+ *
11
+ * ## Why these two, and not a model
12
+ *
13
+ * A self-hosted collector has one project's history and no training budget, so
14
+ * an anomaly detector here has to work on a few dozen buckets, explain itself in
15
+ * one number, and never need tuning per metric. Two classical methods do exactly
16
+ * that, and they answer *different* questions — which is why both are here:
17
+ *
18
+ * - {@link rollingRobustStats} answers **"is this bucket unlike the ones just
19
+ * before it?"** — a single surprising day. It is a *point* detector.
20
+ * - {@link cusumChangePoints} answers **"did the level move and stay moved?"** —
21
+ * a regression that is only 1.5 MADs deep and so never trips a point detector,
22
+ * but that persists for a fortnight. It is a *sequential* detector, and it is
23
+ * the one that catches the shape a release regression actually has.
24
+ *
25
+ * Both are robust by construction: the centre is a median and the scale is a
26
+ * median absolute deviation, so one catastrophic bucket in the trailing window
27
+ * cannot widen the tolerance until nothing is ever abnormal again.
28
+ */
29
+ import { median, medianAbsoluteDeviation } from "./stats.js";
30
+ /**
31
+ * The trailing median and MAD for every index of `values`.
32
+ *
33
+ * The window for index `i` is `values[i - window .. i - 1]` — **the point itself
34
+ * is excluded**, which is the whole point. Including it drags the median toward
35
+ * the very value being judged and inflates the MAD by the deviation being
36
+ * measured, so a big enough spike partly hides itself. Excluding it makes the
37
+ * comparison "against what came before", which is what a reader means by
38
+ * "unusual".
39
+ *
40
+ * Indices with fewer than `minCount` trailing values report `null`: the first
41
+ * days of a project are not evidence about the first days of a project, and a
42
+ * detector that flags them produces a page of findings on every new install.
43
+ *
44
+ * Non-finite entries are skipped (they never enter a window and are never
45
+ * scored), so a sparse series behaves like the shorter series it really is.
46
+ */
47
+ export function rollingRobustStats(values, window, minCount) {
48
+ const out = [];
49
+ const width = Math.max(1, Math.floor(window));
50
+ const floor = Math.max(1, Math.floor(minCount));
51
+ // The trailing values in a plain array: the windows are tens to a few hundred
52
+ // entries and a median needs them sorted anyway, so an incremental structure
53
+ // would add ordering bugs to buy nothing measurable.
54
+ const trailing = [];
55
+ for (const value of values) {
56
+ if (trailing.length < floor) {
57
+ out.push({ median: null, mad: null, count: trailing.length });
58
+ }
59
+ else {
60
+ const centre = median(trailing);
61
+ out.push({
62
+ median: centre,
63
+ mad: medianAbsoluteDeviation(trailing),
64
+ count: trailing.length,
65
+ });
66
+ }
67
+ if (Number.isFinite(value)) {
68
+ trailing.push(value);
69
+ if (trailing.length > width)
70
+ trailing.shift();
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Slack ("allowance") of the tabular CUSUM, in MAD units.
77
+ *
78
+ * The accumulator only grows once a deviation exceeds this, so ordinary
79
+ * bucket-to-bucket noise cancels instead of drifting upward forever. Half a MAD
80
+ * is the textbook choice — it is the shift size the chart is tuned to detect
81
+ * fastest — and keeping it a constant rather than a parameter is deliberate:
82
+ * `sensitivity` already moves the decision threshold, and two dials that both
83
+ * mean "how eager" would be impossible for a caller to reason about.
84
+ */
85
+ export const CUSUM_SLACK = 0.5;
86
+ /**
87
+ * The decision threshold, as a multiple of `sensitivity`.
88
+ *
89
+ * The alarm fires when the accumulated excess passes
90
+ * `sensitivity × CUSUM_DECISION_FACTOR` MADs. At the default `sensitivity` of 3
91
+ * that is 6 MADs of *accumulated* excess — reachable by four consecutive buckets
92
+ * two MADs off, or by a dozen buckets barely off, but not by noise. It keeps the
93
+ * one dial monotone: a higher `sensitivity` means fewer findings for the point
94
+ * detector and for the change-point detector alike.
95
+ */
96
+ export const CUSUM_DECISION_FACTOR = 2;
97
+ /**
98
+ * Why each deviation is clamped to ±`sensitivity` before it is accumulated.
99
+ *
100
+ * A textbook CUSUM alarms on a single large outlier: one bucket ten MADs out
101
+ * contributes 9.5 on its own and trips any reasonable threshold, so every spike
102
+ * would also be reported as a level shift and `kind` would stop meaning
103
+ * anything. Winsorising at exactly the point detector's own threshold fixes
104
+ * that by construction: a bucket the point detector already calls a `spike`
105
+ * contributes at most `sensitivity − k`, so **no single bucket can raise a
106
+ * change-point alarm** at the default settings — it takes three saturated
107
+ * buckets in a row, or a longer run of smaller ones. That is precisely the
108
+ * difference between "a bad Tuesday" and "it has been worse since Tuesday",
109
+ * which is the distinction `kind` exists to make.
110
+ */
111
+ function winsorise(deviation, limit) {
112
+ return Math.max(-limit, Math.min(limit, deviation));
113
+ }
114
+ /**
115
+ * Two-sided tabular CUSUM over `values`, segmented so several level changes in
116
+ * one series are found rather than one.
117
+ *
118
+ * ```
119
+ * eᵢ = clip((xᵢ − reference) / spread, ±sensitivity)
120
+ * S⁺ᵢ = max(0, S⁺ᵢ₋₁ + eᵢ − k) S⁻ᵢ = max(0, S⁻ᵢ₋₁ − eᵢ − k)
121
+ * ```
122
+ *
123
+ * Three decisions make this a *change-point* detector rather than a chart:
124
+ *
125
+ * 1. **The reference is the level the segment started at** — the median of its
126
+ * first `referenceLength` values — not the median of the whole series. With a
127
+ * whole-series centre, a series that steps up halfway sits *above* its own
128
+ * median for the entire second half and *below* it for the entire first, so
129
+ * the accumulator starts drifting at index 0 and the alarm names the wrong
130
+ * bucket. Measuring against where the series began is what makes "it moved"
131
+ * a statement about a moment.
132
+ * 2. **An alarm is reported where the accumulator last left zero**, not where it
133
+ * crossed the threshold. A CUSUM crosses several buckets after the level
134
+ * actually moved — that lag is how it earns its sensitivity — and reporting
135
+ * the crossing would put the finding on the wrong day.
136
+ * 3. **After an alarm the series is re-segmented from the change-point**, with
137
+ * the reference re-estimated over the new regime. Without that, a shifted
138
+ * series keeps deviating from the old reference forever and alarms again
139
+ * every few buckets; with it, a level that moves and moves back reports
140
+ * exactly two change-points.
141
+ *
142
+ * `spread` is supplied by the caller (`anomalies.ts` passes the same floored MAD
143
+ * the point detector uses, so both halves of the primitive are measured in one
144
+ * unit). A non-positive or missing `spread` means there is nothing to detect and
145
+ * the result is empty.
146
+ */
147
+ export function cusumChangePoints(values, spread, sensitivity, referenceLength) {
148
+ const out = [];
149
+ if (spread == null || !(spread > 0) || values.length < 2)
150
+ return out;
151
+ const threshold = Math.max(0, sensitivity) * CUSUM_DECISION_FACTOR;
152
+ if (!(threshold > 0))
153
+ return out;
154
+ const reference = Math.max(2, Math.floor(referenceLength));
155
+ let from = 0;
156
+ while (from < values.length) {
157
+ const segment = values.slice(from);
158
+ const alarm = firstAlarm(segment, median(segment.slice(0, Math.min(reference, segment.length))), spread, threshold, sensitivity);
159
+ if (alarm == null)
160
+ break;
161
+ const at = from + alarm.index;
162
+ out.push({
163
+ index: at,
164
+ direction: alarm.direction,
165
+ before: null,
166
+ after: null,
167
+ score: alarm.score,
168
+ });
169
+ // Strictly forward, so a pathological series cannot loop here.
170
+ from = at + 1;
171
+ }
172
+ // Levels are filled in once the segment boundaries are known: `before` is the
173
+ // regime that ended at the change-point and `after` the one that began there,
174
+ // each closed by its neighbour rather than running to the end of the series.
175
+ for (const [position, change] of out.entries()) {
176
+ const previous = position === 0 ? 0 : out[position - 1].index;
177
+ const next = position === out.length - 1 ? values.length : out[position + 1].index;
178
+ change.before = median(values.slice(previous, change.index));
179
+ change.after = median(values.slice(change.index, next));
180
+ }
181
+ return out;
182
+ }
183
+ /** Where one segment first departs from its own opening level, if it does. */
184
+ function firstAlarm(values, reference, spread, threshold, sensitivity) {
185
+ if (reference == null)
186
+ return null;
187
+ let up = 0;
188
+ let down = 0;
189
+ let upStart = 0;
190
+ let downStart = 0;
191
+ for (let index = 0; index < values.length; index += 1) {
192
+ const value = values[index];
193
+ if (!Number.isFinite(value))
194
+ continue;
195
+ const deviation = winsorise((value - reference) / spread, sensitivity);
196
+ if (up === 0)
197
+ upStart = index;
198
+ if (down === 0)
199
+ downStart = index;
200
+ up = Math.max(0, up + deviation - CUSUM_SLACK);
201
+ down = Math.max(0, down - deviation - CUSUM_SLACK);
202
+ if (up > threshold)
203
+ return { index: upStart, direction: "up", score: up };
204
+ if (down > threshold)
205
+ return { index: downStart, direction: "down", score: down };
206
+ }
207
+ return null;
208
+ }
209
+ //# sourceMappingURL=changepoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changepoint.js","sourceRoot":"","sources":["../../src/insights/changepoint.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The in-memory evaluator for a {@link BucketMeasure} (ADR 0051 §4).
3
+ *
4
+ * `buildMetricBuckets` renders a measure to SQL for the four persistent stores.
5
+ * The collector also ships an in-memory store — the one the playground and the
6
+ * end-to-end harness boot without a database — which has no SQL to run. Rather
7
+ * than let the insight endpoints go dark there (an empty series reads as "no
8
+ * data", which is a different and misleading claim), the same declarative
9
+ * measure is evaluated directly over events here.
10
+ *
11
+ * Pure, and deliberately the *same shape* as the SQL: one pass that groups by
12
+ * bucket, then one aggregate per bucket, using the same closed predicate
13
+ * vocabulary. `src/__tests__/insights.test.ts` runs both paths over the same
14
+ * rows so the two cannot drift.
15
+ */
16
+ import { type BucketGrain, type BucketSplitDimension, type BucketVariant } from "./measures.js";
17
+ import { type MetricBucketRow } from "./buckets.js";
18
+ /**
19
+ * The promoted event columns a measure can read — the in-memory mirror of the
20
+ * `events` table's column set. A store maps its own event representation into
21
+ * this once, and the evaluator never sees the store's shape.
22
+ */
23
+ export interface BucketEventLike {
24
+ /** Event time, epoch milliseconds. */
25
+ ts: number;
26
+ event_type: string;
27
+ scene_id: string;
28
+ session_id: string;
29
+ mesh?: string;
30
+ name?: string;
31
+ source?: string;
32
+ fps?: number;
33
+ visible_ms?: number;
34
+ js_heap_bytes?: number;
35
+ long_frames?: number;
36
+ position?: readonly number[];
37
+ direction?: readonly number[];
38
+ hit_point?: readonly number[];
39
+ screen?: readonly number[];
40
+ /** `ar_placement.scale`, the one measure value that lives in the payload. */
41
+ ar_placement_scale?: number;
42
+ }
43
+ /** Scope an evaluation the way the SQL's `WHERE` clause does. */
44
+ export interface EvaluateBucketOptions {
45
+ metric: string;
46
+ /** A named auxiliary series of the metric rather than its headline one (#307). */
47
+ series?: BucketVariant;
48
+ bucket?: BucketGrain;
49
+ since?: number;
50
+ until?: number;
51
+ scene?: string;
52
+ /** Split the series by one promoted dimension, as `buildMetricBuckets` does. */
53
+ groupBy?: BucketSplitDimension;
54
+ }
55
+ /**
56
+ * Evaluate a metric's bucket series over events held in memory.
57
+ *
58
+ * Returns the same rows `buildMetricBuckets` would: ascending by bucket, with
59
+ * buckets that matched nothing simply absent. Throws for a metric with no
60
+ * portable bucket form, exactly as the builder does.
61
+ */
62
+ export declare function evaluateBucketMeasure(events: Iterable<BucketEventLike>, opts: EvaluateBucketOptions): MetricBucketRow[];
63
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["../../src/insights/evaluate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAGL,KAAK,WAAW,EAGhB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAG3E;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,sCAAsC;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,6EAA6E;IAC7E,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,iEAAiE;AACjE,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,gFAAgF;IAChF,OAAO,CAAC,EAAE,oBAAoB,CAAC;CAChC;AA2DD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,QAAQ,CAAC,eAAe,CAAC,EACjC,IAAI,EAAE,qBAAqB,GAC1B,eAAe,EAAE,CAwDnB"}