@uptimizr/metrics 0.1.0 → 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/registry.js CHANGED
@@ -35,6 +35,7 @@
35
35
  * the Zod querystring that actually serves it.
36
36
  */
37
37
  import { z } from "zod";
38
+ import { NARRATIVE_LIMITS, sessionNarrativeEntrySchema } from "./narrative.js";
38
39
  /**
39
40
  * Every exported `build*` aggregation name in `@uptimizr/db`'s
40
41
  * `query/aggregations.ts`. The registry must cover them all.
@@ -64,6 +65,7 @@ export const AGGREGATION_BUILDER_NAMES = [
64
65
  "buildCapabilityChanges",
65
66
  "buildClickGazeRay",
66
67
  "buildCompileStalls",
68
+ "buildCustomEventVocabulary",
67
69
  "buildDeadClicks",
68
70
  "buildDistinctScenes",
69
71
  "buildErrorHeatmap",
@@ -134,6 +136,65 @@ export const DIMENSION_COLUMNS = {
134
136
  "device.browser": "session_start.payload.device.browser (derived from the UA, ADR 0042)",
135
137
  "device.os": "session_start.payload.device.os (derived from the UA, ADR 0042)",
136
138
  };
139
+ /**
140
+ * The column a {@link DimensionId} appears as in a metric's `row` when that
141
+ * metric is keyed by it.
142
+ *
143
+ * Several dimensions are *filterable* on a metric without being part of its
144
+ * grain (`top_meshes` can be scoped to a `session` but returns one row per
145
+ * mesh), which is what {@link MetricDefinition.grainDimensions} exists to
146
+ * express — this map is how that declaration is **checked**: every grain
147
+ * dimension must project one of the names listed here, and
148
+ * `src/__tests__/registry.test.ts` fails the build when one does not.
149
+ *
150
+ * Alternatives are listed where the projection name has varied: a scene rollup
151
+ * spells `scene` as `scene_id` and a transition as `from_scene`, and the `name`
152
+ * column surfaces as `kind` (an interaction or gesture kind), `action` (an input
153
+ * action) or `phase` (a compile stall). The **first** entry is the canonical
154
+ * spelling the generic group-by tier projects when the metric's own row does not
155
+ * already name one. `cameraMode` has none: no aggregation projects the camera
156
+ * type as a column, so it is a filter on the delegated tier and a generic-only
157
+ * group-by column (`camera_mode`) on the other.
158
+ */
159
+ export const DIMENSION_ROW_COLUMNS = {
160
+ scene: ["scene_id", "scene"],
161
+ session: ["session_id"],
162
+ mesh: ["mesh"],
163
+ name: ["name", "kind", "action", "phase"],
164
+ source: ["source"],
165
+ event_type: ["event_type"],
166
+ cameraMode: [],
167
+ "device.engine": ["engine"],
168
+ "device.renderer": ["renderer"],
169
+ "device.isMobile": ["is_mobile"],
170
+ "device.browser": ["browser"],
171
+ "device.os": ["os"],
172
+ };
173
+ /**
174
+ * The dimensions the **generic group-by tier** can render (design sketch §C.2).
175
+ *
176
+ * Restricted to what the store promotes to a column (`scene_id`, `session_id`,
177
+ * `mesh`, `name`, `source`, `event_type`) plus the session attributes every
178
+ * engine can read out of one `session_start` sub-select with the same SQL
179
+ * (`cameraMode`, the four `device.*` strings). `device.isMobile` is deliberately
180
+ * absent: it is the one session attribute that is a boolean rather than a label,
181
+ * so grouping by it would produce `"true"` / `"false"` text on some engines and
182
+ * `1` / `0` on others — the exact cross-engine disagreement this tier exists to
183
+ * avoid. Filter by it instead, or group by `device.os`.
184
+ */
185
+ export const GENERIC_DIMENSIONS = [
186
+ "scene",
187
+ "session",
188
+ "mesh",
189
+ "name",
190
+ "source",
191
+ "event_type",
192
+ "cameraMode",
193
+ "device.engine",
194
+ "device.renderer",
195
+ "device.browser",
196
+ "device.os",
197
+ ];
137
198
  /**
138
199
  * The single mapping from a request parameter to the option field it drives.
139
200
  * Consumers (docs generator, OpenAPI, the DSL) read filter semantics here rather
@@ -208,7 +269,8 @@ export const FILTER_TARGETS = {
208
269
  bucket: {
209
270
  option: "BuilderOptions",
210
271
  field: "bucket",
211
- description: "Histogram bin width in FPS.",
272
+ description: "Bucket width. On `fps_histogram` a histogram bin width in FPS; on the insight " +
273
+ "primitives the time grain of the series, `day` or `hour`.",
212
274
  },
213
275
  bucketMs: {
214
276
  option: "BuilderOptions",
@@ -310,6 +372,62 @@ export const FILTER_TARGETS = {
310
372
  field: "conversion",
311
373
  description: "JSON funnel-step predicate for the success event. Omit to report views only.",
312
374
  },
375
+ minDwellMs: {
376
+ option: "SessionNarrativeOptions",
377
+ field: "minDwellMs",
378
+ description: "Session narrative only: a mesh must hold attention for at least this many milliseconds " +
379
+ "before it earns a `dwell` entry. Raise it to keep only the meshes that were really looked at.",
380
+ },
381
+ maxEntries: {
382
+ option: "SessionNarrativeOptions",
383
+ field: "maxEntries",
384
+ description: "Session narrative only: the maximum number of entries, oldest first. The closing `summary` " +
385
+ "entry always survives and reports whether anything was dropped.",
386
+ },
387
+ metric: {
388
+ option: "InsightOptions",
389
+ field: "metric",
390
+ description: "The registry metric the insight is computed over. Must be a `comparable` metric that has a " +
391
+ "portable bucket series; a metric without one is rejected with the list of ids that do.",
392
+ },
393
+ metrics: {
394
+ option: "InsightOptions",
395
+ field: "metrics",
396
+ description: "Comma-separated allowlist of registry metric ids to scan instead of the curated default " +
397
+ "set. Bounded by the per-request metric cap the registry `limits` declare.",
398
+ },
399
+ window: {
400
+ option: "InsightOptions",
401
+ field: "windowDays",
402
+ description: "Length of the baseline window in days, counted back from `until`. Ignored when `since` is " +
403
+ "given explicitly.",
404
+ },
405
+ refSince: {
406
+ option: "InsightOptions",
407
+ field: "refSince",
408
+ description: "Inclusive lower bound of the reference window a change is measured against, epoch " +
409
+ "milliseconds. Defaults to the equal-length window immediately before the current range.",
410
+ },
411
+ refUntil: {
412
+ option: "InsightOptions",
413
+ field: "refUntil",
414
+ description: "Exclusive upper bound of the reference window, epoch milliseconds. Defaults to `since`.",
415
+ },
416
+ // --- anomalies (#306) ---
417
+ sensitivity: {
418
+ option: "InsightOptions",
419
+ field: "sensitivity",
420
+ description: "How far out of line a bucket must be before it is reported, in median-absolute-deviations " +
421
+ "of the trailing window. Higher means fewer, more extreme findings. 1-10, default 3.",
422
+ },
423
+ // --- significance / scene health (#307) ---
424
+ weights: {
425
+ option: "InsightOptions",
426
+ field: "weights",
427
+ description: "JSON object overriding the declared per-factor weights of a composite score, e.g. " +
428
+ '`{"error_rate":0.5}`. Factors the object does not name keep their declared weight; an ' +
429
+ "unknown factor id is rejected rather than ignored.",
430
+ },
313
431
  format: {
314
432
  option: "ResultEnvelope",
315
433
  field: "(response layer)",
@@ -318,6 +436,15 @@ export const FILTER_TARGETS = {
318
436
  "or a trend, with shares, a sample size, a templated reading and the caveats).",
319
437
  },
320
438
  };
439
+ /**
440
+ * The capability `metric`'s endpoint requires, with the `query` default applied.
441
+ * Consumers partition the catalog with this rather than reading the optional
442
+ * field directly, so "no capability declared" cannot be mistaken for "no
443
+ * capability required".
444
+ */
445
+ export function metricCapability(metric) {
446
+ return metric.endpoint?.capability ?? "query";
447
+ }
321
448
  // --- Row-schema building blocks ------------------------------------------
322
449
  //
323
450
  // Numeric columns are **strict** `z.number()`: they describe what the collector
@@ -421,6 +548,7 @@ export const METRIC_REGISTRY = {
421
548
  endpoint: { method: "GET", path: "/api/v1/sessions" },
422
549
  grain: "session",
423
550
  dimensions: ["session"],
551
+ grainDimensions: ["session"],
424
552
  filters: ["since", "until", "bins", "limit", "cameraMode", "format"],
425
553
  row: z.object({
426
554
  session_id: text,
@@ -467,6 +595,7 @@ export const METRIC_REGISTRY = {
467
595
  endpoint: { method: "GET", path: "/api/v1/sessions/:id/meta", pathParams: ["session"] },
468
596
  grain: "session",
469
597
  dimensions: ["session"],
598
+ grainDimensions: [],
470
599
  filters: [],
471
600
  row: z.object({
472
601
  sessionId: text,
@@ -500,6 +629,91 @@ export const METRIC_REGISTRY = {
500
629
  related: ["list_sessions", "perf_by_device"],
501
630
  category: "sessions",
502
631
  },
632
+ session_narrative: {
633
+ id: "session_narrative",
634
+ title: "Session narrative",
635
+ description: "An ordered, compacted account of what one session did — scene changes, the meshes it " +
636
+ "dwelled on, its interactions, performance dips, errors and how it ended — timestamps " +
637
+ "relative to its first event, plus a closing totals entry. A compaction of the raw " +
638
+ "per-session stream, gated on `query:raw` and raw-session retention (ADR 0003).",
639
+ endpoint: {
640
+ method: "GET",
641
+ path: "/api/v1/sessions/:id/narrative",
642
+ pathParams: ["session"],
643
+ capability: "query:raw",
644
+ },
645
+ grain: "row",
646
+ dimensions: ["session"],
647
+ // A narrative entry is not keyed by the session: the session is the *scope*,
648
+ // named in the path, and no row column projects it (cf. `session_meta`).
649
+ grainDimensions: [],
650
+ filters: ["minDwellMs", "fpsThreshold", "maxEntries", "format"],
651
+ row: sessionNarrativeEntrySchema,
652
+ columns: {
653
+ tMs: {
654
+ description: "Milliseconds since the session's first event. Relative by design — a narrative never carries a wall-clock time.",
655
+ unit: "ms",
656
+ },
657
+ kind: {
658
+ description: "What the entry is about: scene / dwell / interaction / perf_dip / error / diagnostic / capability / xr / end / summary.",
659
+ unit: "label",
660
+ label: true,
661
+ },
662
+ summary: {
663
+ description: "One templated line of prose, composed from the entry's own fields — never free text copied out of an event payload.",
664
+ },
665
+ refs: {
666
+ description: "The named things the entry points at: `mesh`, `scene` and `name` (a custom-event or input-action name). Nothing else is ever referenced.",
667
+ },
668
+ durationMs: {
669
+ description: "How long the entry spans, for the kinds that cover a stretch of time.",
670
+ unit: "ms",
671
+ },
672
+ count: {
673
+ description: "How many source events the entry collapses (dwell samples, dip frames, …).",
674
+ unit: "count",
675
+ },
676
+ totals: {
677
+ description: "Session totals (events, duration, scenes, meshes, interactions, dips, errors). Present on the closing `summary` entry only.",
678
+ },
679
+ truncated: {
680
+ description: "Whether entries were dropped to honour `maxEntries`. Present on the closing `summary` entry only.",
681
+ },
682
+ },
683
+ limits: {
684
+ maxRows: NARRATIVE_LIMITS.maxMaxEntries,
685
+ maxSummaryRows: NARRATIVE_LIMITS.defaultMaxEntries,
686
+ },
687
+ interpretation: "Read it top to bottom as a story: where the session went, what held its attention, what it " +
688
+ "touched, and what went wrong. The closing `summary` entry gives the totals and says whether " +
689
+ "anything was dropped — if `truncated` is true, raise `minDwellMs` or `maxEntries` rather " +
690
+ "than trusting the tail.",
691
+ caveats: [
692
+ "Refused with 403 unless the collector has `ENABLE_RAW_SESSION_RETENTION` enabled AND the key holds `query:raw` (ADR 0003).",
693
+ "Returns 404 when the session id is unknown to the project, or when retention was enabled only after it was recorded.",
694
+ "A projection, not the raw stream: no visitor hash, no URL or page metadata, no positions or rays, and no `device` detail. Custom-event property **keys** are listed; their values never are.",
695
+ "Dwell comes from the sampled `mesh_visibility` / `hover_dwell` channels (ADR 0012), so it ranks attention rather than measuring it exactly.",
696
+ "Not an aggregation — there is no `build*` builder, and it takes no time range.",
697
+ ],
698
+ sourceChannels: [
699
+ "session_start",
700
+ "session_end",
701
+ "scene_change",
702
+ "mesh_visibility",
703
+ "hover_dwell",
704
+ "mesh_interaction",
705
+ "pointer_click",
706
+ "input_action",
707
+ "custom",
708
+ "frame_perf",
709
+ "runtime_error",
710
+ "graphics_diagnostic",
711
+ "capability_change",
712
+ "xr_boundary_proximity",
713
+ ],
714
+ related: ["session_meta", "list_sessions", "session_trajectory"],
715
+ category: "sessions",
716
+ },
503
717
  scene_representation: {
504
718
  id: "scene_representation",
505
719
  title: "Scene representation",
@@ -513,6 +727,7 @@ export const METRIC_REGISTRY = {
513
727
  },
514
728
  grain: "scene",
515
729
  dimensions: ["scene"],
730
+ grainDimensions: [],
516
731
  filters: [],
517
732
  row: z.object({
518
733
  projectId: text,
@@ -578,6 +793,7 @@ export const METRIC_REGISTRY = {
578
793
  endpoint: { method: "GET", path: "/api/v1/scenes" },
579
794
  grain: "scene",
580
795
  dimensions: ["scene"],
796
+ grainDimensions: ["scene"],
581
797
  filters: ["since", "until", "limit", "format"],
582
798
  row: z.object({ scene_id: text, events: int, last_seen: ts }),
583
799
  columns: {
@@ -611,8 +827,9 @@ export const METRIC_REGISTRY = {
611
827
  endpoint: { method: "GET", path: "/api/v1/timeseries" },
612
828
  grain: "bucket",
613
829
  dimensions: ["scene", "event_type"],
830
+ grainDimensions: [],
614
831
  filters: ["since", "until", "interval", "scene", "type", "format"],
615
- row: z.object({ bucket: int, events: int, avg_fps: num }),
832
+ row: z.object({ bucket: int, events: int, avg_fps: numOrNull }),
616
833
  columns: {
617
834
  bucket: {
618
835
  description: "Bucket start as epoch milliseconds.",
@@ -622,13 +839,15 @@ export const METRIC_REGISTRY = {
622
839
  },
623
840
  events: { description: "Events in the bucket.", unit: "count", measure: true },
624
841
  avg_fps: {
625
- description: "Mean FPS of the `frame_perf` samples in the bucket; `0` when there were none.",
842
+ description: "Mean FPS of the `frame_perf` samples in the bucket; `null` when the bucket had " +
843
+ "none — an average over no samples is absent, never zero.",
626
844
  unit: "fps",
627
845
  },
628
846
  },
629
847
  limits: { maxRows: 1000, maxSummaryRows: 8 },
630
- interpretation: "Read it as a trend, not a level. A bucket with no `frame_perf` samples reports `avg_fps` 0, " +
631
- "so treat a zero as 'no perf data', never as a stall.",
848
+ interpretation: "Read it as a trend, not a level. A bucket with no `frame_perf` samples reports `avg_fps` " +
849
+ "`null` traffic the engine never sampled perf for, not a stall. A `0` means the samples " +
850
+ "really did average zero.",
632
851
  caveats: [
633
852
  "Buckets are fixed width; a range shorter than one `interval` collapses to a single bucket.",
634
853
  "Volume scales with the capture fidelity dial (ADR 0012) — a rise can be a sampling change rather than traffic.",
@@ -648,7 +867,26 @@ export const METRIC_REGISTRY = {
648
867
  builder: "buildEventTypeCounts",
649
868
  endpoint: { method: "GET", path: "/api/v1/event-counts" },
650
869
  grain: "row",
651
- dimensions: ["event_type", "scene"],
870
+ dimensions: [
871
+ "event_type",
872
+ "scene",
873
+ "session",
874
+ "source",
875
+ "mesh",
876
+ "name",
877
+ "cameraMode",
878
+ "device.os",
879
+ "device.browser",
880
+ "device.engine",
881
+ "device.renderer",
882
+ ],
883
+ grainDimensions: ["event_type"],
884
+ genericGroupBy: {
885
+ // The whole stream: this metric's subject *is* every event, so no
886
+ // `eventTypes` narrowing and no scope predicate.
887
+ eventTypes: [],
888
+ measures: [{ column: "count", kind: "count" }],
889
+ },
652
890
  filters: ["since", "until", "scene", "format"],
653
891
  row: z.object({ event_type: text, count: int }),
654
892
  columns: {
@@ -681,6 +919,7 @@ export const METRIC_REGISTRY = {
681
919
  builder: "buildEventsDaily",
682
920
  grain: "bucket",
683
921
  dimensions: ["event_type"],
922
+ grainDimensions: ["event_type"],
684
923
  filters: [],
685
924
  row: z.object({ day: day, event_type: text, events: int }),
686
925
  columns: {
@@ -719,6 +958,7 @@ export const METRIC_REGISTRY = {
719
958
  endpoint: { method: "GET", path: "/api/v1/heatmaps/pointer" },
720
959
  grain: "bin",
721
960
  dimensions: ["scene", "session", "source", "cameraMode"],
961
+ grainDimensions: [],
722
962
  filters: [
723
963
  "since",
724
964
  "until",
@@ -760,6 +1000,7 @@ export const METRIC_REGISTRY = {
760
1000
  endpoint: { method: "GET", path: "/api/v1/heatmaps/mesh-uv" },
761
1001
  grain: "bin",
762
1002
  dimensions: ["scene", "session", "source", "mesh"],
1003
+ grainDimensions: [],
763
1004
  filters: ["since", "until", "bins", "limit", "scene", "session", "source", "mesh", "format"],
764
1005
  row: heatmapBinRow,
765
1006
  columns: {
@@ -790,6 +1031,7 @@ export const METRIC_REGISTRY = {
790
1031
  endpoint: { method: "GET", path: "/api/v1/heatmaps/world" },
791
1032
  grain: "voxel",
792
1033
  dimensions: ["scene", "source", "cameraMode"],
1034
+ grainDimensions: [],
793
1035
  filters: [
794
1036
  "since",
795
1037
  "until",
@@ -827,6 +1069,7 @@ export const METRIC_REGISTRY = {
827
1069
  endpoint: { method: "GET", path: "/api/v1/heatmaps/world/stats" },
828
1070
  grain: "project",
829
1071
  dimensions: ["scene", "source", "cameraMode"],
1072
+ grainDimensions: [],
830
1073
  filters: ["since", "until", "cellSize", "scene", "source", "cameraMode", "region", "format"],
831
1074
  row: spatialStatsRow,
832
1075
  columns: {
@@ -858,6 +1101,7 @@ export const METRIC_REGISTRY = {
858
1101
  endpoint: { method: "GET", path: "/api/v1/heatmaps/gaze" },
859
1102
  grain: "voxel",
860
1103
  dimensions: ["scene", "session", "cameraMode"],
1104
+ grainDimensions: [],
861
1105
  filters: [
862
1106
  "since",
863
1107
  "until",
@@ -900,6 +1144,7 @@ export const METRIC_REGISTRY = {
900
1144
  endpoint: { method: "GET", path: "/api/v1/heatmaps/gaze/stats" },
901
1145
  grain: "project",
902
1146
  dimensions: ["scene", "session", "cameraMode"],
1147
+ grainDimensions: [],
903
1148
  filters: ["since", "until", "cellSize", "scene", "session", "cameraMode", "region", "format"],
904
1149
  row: spatialStatsRow,
905
1150
  columns: {
@@ -931,6 +1176,7 @@ export const METRIC_REGISTRY = {
931
1176
  endpoint: { method: "GET", path: "/api/v1/heatmaps/camera" },
932
1177
  grain: "bin",
933
1178
  dimensions: ["scene", "session", "cameraMode"],
1179
+ grainDimensions: [],
934
1180
  filters: ["since", "until", "bins", "limit", "scene", "session", "cameraMode", "format"],
935
1181
  row: z.object({ azimuth_bin: int, elevation_bin: int, count: int }),
936
1182
  columns: {
@@ -968,6 +1214,7 @@ export const METRIC_REGISTRY = {
968
1214
  endpoint: { method: "GET", path: "/api/v1/coverage/view-histogram" },
969
1215
  grain: "bucket",
970
1216
  dimensions: ["scene", "session", "cameraMode"],
1217
+ grainDimensions: [],
971
1218
  filters: ["since", "until", "bins", "limit", "scene", "session", "cameraMode", "format"],
972
1219
  row: z.object({ bucket: int, sessions: int }),
973
1220
  columns: {
@@ -1007,6 +1254,7 @@ export const METRIC_REGISTRY = {
1007
1254
  endpoint: { method: "GET", path: "/api/v1/heatmaps/position" },
1008
1255
  grain: "bin",
1009
1256
  dimensions: ["scene", "session", "cameraMode"],
1257
+ grainDimensions: [],
1010
1258
  filters: [
1011
1259
  "since",
1012
1260
  "until",
@@ -1052,6 +1300,7 @@ export const METRIC_REGISTRY = {
1052
1300
  },
1053
1301
  grain: "row",
1054
1302
  dimensions: ["session", "scene"],
1303
+ grainDimensions: [],
1055
1304
  filters: ["since", "until", "limit", "scene", "format"],
1056
1305
  row: z.object({ ts: int, x: num, y: num, z: num }),
1057
1306
  columns: {
@@ -1086,6 +1335,7 @@ export const METRIC_REGISTRY = {
1086
1335
  endpoint: { method: "GET", path: "/api/v1/paths" },
1087
1336
  grain: "row",
1088
1337
  dimensions: ["session", "scene", "cameraMode"],
1338
+ grainDimensions: ["session"],
1089
1339
  filters: ["since", "until", "cellSize", "limit", "scene", "cameraMode", "format"],
1090
1340
  row: z.object({ session_id: text, ts: int, gx: int, gz: int }),
1091
1341
  columns: {
@@ -1120,6 +1370,7 @@ export const METRIC_REGISTRY = {
1120
1370
  endpoint: { method: "GET", path: "/api/v1/coverage" },
1121
1371
  grain: "voxel",
1122
1372
  dimensions: ["scene", "session"],
1373
+ grainDimensions: [],
1123
1374
  filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
1124
1375
  row: voxelCountRow,
1125
1376
  columns: {
@@ -1152,6 +1403,7 @@ export const METRIC_REGISTRY = {
1152
1403
  endpoint: { method: "GET", path: "/api/v1/camera/distance" },
1153
1404
  grain: "bucket",
1154
1405
  dimensions: ["scene", "session"],
1406
+ grainDimensions: [],
1155
1407
  filters: [
1156
1408
  "since",
1157
1409
  "until",
@@ -1196,6 +1448,7 @@ export const METRIC_REGISTRY = {
1196
1448
  endpoint: { method: "GET", path: "/api/v1/heatmaps/click-rays" },
1197
1449
  grain: "voxel",
1198
1450
  dimensions: ["scene", "session", "source", "mesh"],
1451
+ grainDimensions: ["mesh"],
1199
1452
  filters: ["since", "until", "cellSize", "limit", "scene", "source", "session", "format"],
1200
1453
  row: z.object({
1201
1454
  cam_vx: int,
@@ -1254,6 +1507,7 @@ export const METRIC_REGISTRY = {
1254
1507
  endpoint: { method: "GET", path: "/api/v1/heatmaps/flow" },
1255
1508
  grain: "bin",
1256
1509
  dimensions: ["scene", "session", "mesh", "cameraMode"],
1510
+ grainDimensions: ["mesh"],
1257
1511
  filters: [
1258
1512
  "since",
1259
1513
  "until",
@@ -1334,7 +1588,26 @@ export const METRIC_REGISTRY = {
1334
1588
  builder: "buildTopMeshes",
1335
1589
  endpoint: { method: "GET", path: "/api/v1/meshes/top" },
1336
1590
  grain: "mesh",
1337
- dimensions: ["mesh", "session"],
1591
+ dimensions: [
1592
+ "mesh",
1593
+ "session",
1594
+ "scene",
1595
+ "source",
1596
+ "event_type",
1597
+ "cameraMode",
1598
+ "device.os",
1599
+ "device.browser",
1600
+ "device.engine",
1601
+ "device.renderer",
1602
+ ],
1603
+ grainDimensions: ["mesh"],
1604
+ genericGroupBy: {
1605
+ // Every mesh-referencing event, gaze included — the same population the
1606
+ // canned builder counts, which is why `hasMesh` is not optional here.
1607
+ eventTypes: [],
1608
+ scope: ["hasMesh"],
1609
+ measures: [{ column: "count", kind: "count" }],
1610
+ },
1338
1611
  filters: ["since", "until", "bins", "limit", "session", "format"],
1339
1612
  row: z.object({ mesh: text, count: int }),
1340
1613
  columns: {
@@ -1365,7 +1638,25 @@ export const METRIC_REGISTRY = {
1365
1638
  builder: "buildTopMeshesBySource",
1366
1639
  endpoint: { method: "GET", path: "/api/v1/meshes/sources" },
1367
1640
  grain: "mesh",
1368
- dimensions: ["mesh", "source", "scene", "session", "cameraMode"],
1641
+ dimensions: [
1642
+ "mesh",
1643
+ "source",
1644
+ "scene",
1645
+ "session",
1646
+ "cameraMode",
1647
+ "name",
1648
+ "event_type",
1649
+ "device.os",
1650
+ "device.browser",
1651
+ "device.engine",
1652
+ "device.renderer",
1653
+ ],
1654
+ grainDimensions: ["mesh", "source"],
1655
+ genericGroupBy: {
1656
+ eventTypes: ["mesh_interaction", "pointer_click"],
1657
+ scope: ["hasMesh"],
1658
+ measures: [{ column: "count", kind: "count" }],
1659
+ },
1369
1660
  filters: [
1370
1661
  "since",
1371
1662
  "until",
@@ -1407,6 +1698,7 @@ export const METRIC_REGISTRY = {
1407
1698
  endpoint: { method: "GET", path: "/api/v1/meshes/trend" },
1408
1699
  grain: "bucket",
1409
1700
  dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
1701
+ grainDimensions: ["mesh"],
1410
1702
  filters: [
1411
1703
  "since",
1412
1704
  "until",
@@ -1449,6 +1741,7 @@ export const METRIC_REGISTRY = {
1449
1741
  endpoint: { method: "GET", path: "/api/v1/meshes/dwell" },
1450
1742
  grain: "mesh",
1451
1743
  dimensions: ["mesh", "scene", "session"],
1744
+ grainDimensions: ["mesh"],
1452
1745
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
1453
1746
  row: z.object({
1454
1747
  mesh: text,
@@ -1498,6 +1791,7 @@ export const METRIC_REGISTRY = {
1498
1791
  endpoint: { method: "GET", path: "/api/v1/meshes/blind-spots" },
1499
1792
  grain: "mesh",
1500
1793
  dimensions: ["mesh", "scene", "session"],
1794
+ grainDimensions: ["mesh"],
1501
1795
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
1502
1796
  row: z.object({
1503
1797
  mesh: text,
@@ -1542,7 +1836,24 @@ export const METRIC_REGISTRY = {
1542
1836
  builder: "buildMeshInteractionKinds",
1543
1837
  endpoint: { method: "GET", path: "/api/v1/meshes/kinds" },
1544
1838
  grain: "mesh",
1545
- dimensions: ["mesh", "name", "scene", "session", "source", "cameraMode"],
1839
+ dimensions: [
1840
+ "mesh",
1841
+ "name",
1842
+ "scene",
1843
+ "session",
1844
+ "source",
1845
+ "cameraMode",
1846
+ "device.os",
1847
+ "device.browser",
1848
+ "device.engine",
1849
+ "device.renderer",
1850
+ ],
1851
+ grainDimensions: ["mesh", "name"],
1852
+ genericGroupBy: {
1853
+ eventTypes: ["mesh_interaction"],
1854
+ scope: ["hasMesh"],
1855
+ measures: [{ column: "count", kind: "count" }],
1856
+ },
1546
1857
  filters: [
1547
1858
  "since",
1548
1859
  "until",
@@ -1588,6 +1899,7 @@ export const METRIC_REGISTRY = {
1588
1899
  endpoint: { method: "GET", path: "/api/v1/meshes/reachability" },
1589
1900
  grain: "mesh",
1590
1901
  dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
1902
+ grainDimensions: ["mesh"],
1591
1903
  filters: [
1592
1904
  "since",
1593
1905
  "until",
@@ -1637,6 +1949,7 @@ export const METRIC_REGISTRY = {
1637
1949
  endpoint: { method: "GET", path: "/api/v1/clicks/dead" },
1638
1950
  grain: "project",
1639
1951
  dimensions: ["scene", "session", "source", "cameraMode"],
1952
+ grainDimensions: [],
1640
1953
  filters: [
1641
1954
  "since",
1642
1955
  "until",
@@ -1683,6 +1996,7 @@ export const METRIC_REGISTRY = {
1683
1996
  endpoint: { method: "GET", path: "/api/v1/clicks/rage" },
1684
1997
  grain: "row",
1685
1998
  dimensions: ["session", "mesh", "scene", "source", "cameraMode"],
1999
+ grainDimensions: ["session", "mesh"],
1686
2000
  filters: [
1687
2001
  "since",
1688
2002
  "until",
@@ -1730,6 +2044,7 @@ export const METRIC_REGISTRY = {
1730
2044
  endpoint: { method: "GET", path: "/api/v1/hover/dwell" },
1731
2045
  grain: "mesh",
1732
2046
  dimensions: ["mesh", "scene", "session", "source", "cameraMode"],
2047
+ grainDimensions: ["mesh"],
1733
2048
  filters: [
1734
2049
  "since",
1735
2050
  "until",
@@ -1770,7 +2085,38 @@ export const METRIC_REGISTRY = {
1770
2085
  builder: "buildInteractionsBySource",
1771
2086
  endpoint: { method: "GET", path: "/api/v1/interactions/sources" },
1772
2087
  grain: "row",
1773
- dimensions: ["event_type", "source", "scene", "session", "cameraMode"],
2088
+ dimensions: [
2089
+ "event_type",
2090
+ "source",
2091
+ "scene",
2092
+ "session",
2093
+ "cameraMode",
2094
+ "mesh",
2095
+ "name",
2096
+ "device.os",
2097
+ "device.browser",
2098
+ "device.engine",
2099
+ "device.renderer",
2100
+ ],
2101
+ grainDimensions: ["event_type", "source"],
2102
+ genericGroupBy: {
2103
+ // Exactly the event types the canned builder lists — the events that
2104
+ // carry an input source.
2105
+ eventTypes: [
2106
+ "pointer_move",
2107
+ "pointer_click",
2108
+ "pointer_down",
2109
+ "pointer_up",
2110
+ "mesh_interaction",
2111
+ "hover_dwell",
2112
+ "camera_gesture",
2113
+ "input_action",
2114
+ ],
2115
+ measures: [
2116
+ { column: "count", kind: "count" },
2117
+ { column: "sessions", kind: "sessions" },
2118
+ ],
2119
+ },
1774
2120
  filters: [
1775
2121
  "since",
1776
2122
  "until",
@@ -1810,7 +2156,23 @@ export const METRIC_REGISTRY = {
1810
2156
  builder: "buildTopInputActions",
1811
2157
  endpoint: { method: "GET", path: "/api/v1/input-actions/top" },
1812
2158
  grain: "row",
1813
- dimensions: ["name", "source", "scene", "session", "cameraMode"],
2159
+ dimensions: [
2160
+ "name",
2161
+ "source",
2162
+ "scene",
2163
+ "session",
2164
+ "cameraMode",
2165
+ "device.os",
2166
+ "device.browser",
2167
+ "device.engine",
2168
+ "device.renderer",
2169
+ ],
2170
+ grainDimensions: ["name", "source"],
2171
+ genericGroupBy: {
2172
+ eventTypes: ["input_action"],
2173
+ scope: ["hasName"],
2174
+ measures: [{ column: "count", kind: "count" }],
2175
+ },
1814
2176
  filters: [
1815
2177
  "since",
1816
2178
  "until",
@@ -1845,6 +2207,60 @@ export const METRIC_REGISTRY = {
1845
2207
  comparable: { primary: "count", direction: "neutral", minSample: 30 },
1846
2208
  category: "interaction",
1847
2209
  },
2210
+ custom_event_vocabulary: {
2211
+ id: "custom_event_vocabulary",
2212
+ title: "Discovered custom-event vocabulary",
2213
+ description: "Which developer-defined `custom` event names the project actually emits, how often, over " +
2214
+ "how many distinct sessions, and the union of `props` keys observed on each name with a " +
2215
+ "coarse type per key (ADR 0051 §5). One row per custom-event name. This is how an agent " +
2216
+ "learns that `add_to_cart` exists and carries `sku` and `qty` — nothing else in the read " +
2217
+ "surface enumerates an application's own event vocabulary.",
2218
+ builder: "buildCustomEventVocabulary",
2219
+ endpoint: { method: "GET", path: "/api/v1/vocabulary/custom-events" },
2220
+ grain: "row",
2221
+ dimensions: ["name", "scene"],
2222
+ // One row per custom-event name; `scene` is a filter, never a key.
2223
+ grainDimensions: ["name"],
2224
+ filters: ["since", "until", "scene", "limit", "format"],
2225
+ row: z.object({
2226
+ name: text,
2227
+ count: int,
2228
+ sessions: int,
2229
+ props: z.record(z.string(), z.enum(["string", "number", "boolean", "null", "mixed"])),
2230
+ }),
2231
+ columns: {
2232
+ name: {
2233
+ description: "Developer-chosen custom-event name, e.g. `add_to_cart`.",
2234
+ unit: "label",
2235
+ label: true,
2236
+ },
2237
+ count: { description: "Times the event fired over the range.", unit: "count", measure: true },
2238
+ sessions: { description: "Distinct sessions that emitted it.", unit: "sessions" },
2239
+ props: {
2240
+ description: "Observed `props` keys mapped to a coarse JSON type (`string` / `number` / `boolean` / " +
2241
+ "`null` / `mixed`). `mixed` means the key arrived with more than one kind; `null` means " +
2242
+ "every sampled value was null.",
2243
+ unit: "label",
2244
+ },
2245
+ },
2246
+ limits: { maxRows: 200, maxSummaryRows: 20 },
2247
+ interpretation: "A discovery read, not a KPI: use it to find the real event names and prop keys before " +
2248
+ "filtering or funnelling on them. `count` and `sessions` are exact over the range; `props` " +
2249
+ "is a sample.",
2250
+ caveats: [
2251
+ "`props` is discovered from the 20 most recent events per name, not from the whole range: a " +
2252
+ "key that stopped being emitted long ago will be absent, and a rarely-sent optional key " +
2253
+ "may be missed. Treat it as a vocabulary hint, never as a schema.",
2254
+ "Only prop key names and value kinds are reported — never a prop value (ADR 0003).",
2255
+ "Names are ranked by `count` and capped by `limit`, so a long tail of rare custom events may " +
2256
+ "be truncated.",
2257
+ "An empty result means the project emits no `custom` events over the range, not that custom " +
2258
+ "events are unsupported.",
2259
+ ],
2260
+ sourceChannels: ["custom"],
2261
+ related: ["variant_leaderboard", "funnel", "event_counts"],
2262
+ category: "interaction",
2263
+ },
1848
2264
  camera_gestures: {
1849
2265
  id: "camera_gestures",
1850
2266
  title: "Camera navigation gestures",
@@ -1854,7 +2270,30 @@ export const METRIC_REGISTRY = {
1854
2270
  builder: "buildCameraGestures",
1855
2271
  endpoint: { method: "GET", path: "/api/v1/camera-gestures" },
1856
2272
  grain: "row",
1857
- dimensions: ["name", "scene", "session", "source", "cameraMode"],
2273
+ dimensions: [
2274
+ "name",
2275
+ "scene",
2276
+ "session",
2277
+ "source",
2278
+ "cameraMode",
2279
+ "device.os",
2280
+ "device.browser",
2281
+ "device.engine",
2282
+ "device.renderer",
2283
+ ],
2284
+ grainDimensions: ["name"],
2285
+ genericGroupBy: {
2286
+ // The one generic metric whose measures are not all counts: gesture
2287
+ // duration rides in the promoted `visible_ms` column, and sum / avg / max
2288
+ // over a promoted column render the same on every engine.
2289
+ eventTypes: ["camera_gesture"],
2290
+ measures: [
2291
+ { column: "gestures", kind: "count" },
2292
+ { column: "total_ms", kind: "sum", of: "visible_ms" },
2293
+ { column: "avg_ms", kind: "avg", of: "visible_ms" },
2294
+ { column: "max_ms", kind: "max", of: "visible_ms" },
2295
+ ],
2296
+ },
1858
2297
  filters: [
1859
2298
  "since",
1860
2299
  "until",
@@ -1897,6 +2336,7 @@ export const METRIC_REGISTRY = {
1897
2336
  endpoint: { method: "GET", path: "/api/v1/navigation" },
1898
2337
  grain: "session",
1899
2338
  dimensions: ["session", "scene"],
2339
+ grainDimensions: ["session"],
1900
2340
  filters: ["since", "until", "moveThreshold", "limit", "scene", "session", "format"],
1901
2341
  row: z.object({
1902
2342
  session_id: text,
@@ -1942,6 +2382,7 @@ export const METRIC_REGISTRY = {
1942
2382
  endpoint: { method: "GET", path: "/api/v1/backtrack" },
1943
2383
  grain: "scene",
1944
2384
  dimensions: ["scene", "session"],
2385
+ grainDimensions: ["scene"],
1945
2386
  filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
1946
2387
  row: z.object({
1947
2388
  scene: text,
@@ -1999,6 +2440,7 @@ export const METRIC_REGISTRY = {
1999
2440
  endpoint: { method: "GET", path: "/api/v1/perf" },
2000
2441
  grain: "project",
2001
2442
  dimensions: ["session"],
2443
+ grainDimensions: [],
2002
2444
  filters: ["since", "until", "bins", "limit", "session", "format"],
2003
2445
  row: z.object({ samples: int, avg_fps: numOrNull, min_fps: numOrNull, p50_fps: numOrNull }),
2004
2446
  columns: {
@@ -2033,6 +2475,7 @@ export const METRIC_REGISTRY = {
2033
2475
  endpoint: { method: "GET", path: "/api/v1/perf/render-scale" },
2034
2476
  grain: "project",
2035
2477
  dimensions: ["session"],
2478
+ grainDimensions: [],
2036
2479
  filters: ["since", "until", "bins", "limit", "session", "format"],
2037
2480
  row: z.object({
2038
2481
  samples: int,
@@ -2087,6 +2530,7 @@ export const METRIC_REGISTRY = {
2087
2530
  endpoint: { method: "GET", path: "/api/v1/perf/distribution" },
2088
2531
  grain: "project",
2089
2532
  dimensions: ["scene", "session"],
2533
+ grainDimensions: [],
2090
2534
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2091
2535
  row: z.object({
2092
2536
  sessions: int,
@@ -2132,6 +2576,7 @@ export const METRIC_REGISTRY = {
2132
2576
  endpoint: { method: "GET", path: "/api/v1/perf/fps-histogram" },
2133
2577
  grain: "bucket",
2134
2578
  dimensions: ["scene", "session"],
2579
+ grainDimensions: [],
2135
2580
  filters: ["since", "until", "bins", "limit", "scene", "session", "bucket", "format"],
2136
2581
  row: z.object({ bucket: int, sessions: int }),
2137
2582
  columns: {
@@ -2170,6 +2615,7 @@ export const METRIC_REGISTRY = {
2170
2615
  endpoint: { method: "GET", path: "/api/v1/perf/frame-time" },
2171
2616
  grain: "project",
2172
2617
  dimensions: ["scene", "session"],
2618
+ grainDimensions: [],
2173
2619
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2174
2620
  row: z.object({ sessions: int, samples: intOrNull, p50_ms: numOrNull, p95_ms: numOrNull }),
2175
2621
  columns: {
@@ -2209,6 +2655,7 @@ export const METRIC_REGISTRY = {
2209
2655
  endpoint: { method: "GET", path: "/api/v1/perf/jank" },
2210
2656
  grain: "project",
2211
2657
  dimensions: ["scene", "session"],
2658
+ grainDimensions: [],
2212
2659
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2213
2660
  row: z.object({
2214
2661
  sessions: int,
@@ -2256,6 +2703,7 @@ export const METRIC_REGISTRY = {
2256
2703
  endpoint: { method: "GET", path: "/api/v1/perf/churn" },
2257
2704
  grain: "project",
2258
2705
  dimensions: ["scene", "session"],
2706
+ grainDimensions: [],
2259
2707
  filters: [
2260
2708
  "since",
2261
2709
  "until",
@@ -2327,6 +2775,13 @@ export const METRIC_REGISTRY = {
2327
2775
  "scene",
2328
2776
  "session",
2329
2777
  ],
2778
+ grainDimensions: [
2779
+ "device.engine",
2780
+ "device.isMobile",
2781
+ "device.renderer",
2782
+ "device.browser",
2783
+ "device.os",
2784
+ ],
2330
2785
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2331
2786
  row: z.object({
2332
2787
  engine: text,
@@ -2382,6 +2837,7 @@ export const METRIC_REGISTRY = {
2382
2837
  endpoint: { method: "GET", path: "/api/v1/perf/by-scene" },
2383
2838
  grain: "scene",
2384
2839
  dimensions: ["scene", "session"],
2840
+ grainDimensions: ["scene"],
2385
2841
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2386
2842
  row: z.object({ scene_id: text, sessions: int, samples: int, p50_fps: num }),
2387
2843
  columns: {
@@ -2417,6 +2873,7 @@ export const METRIC_REGISTRY = {
2417
2873
  endpoint: { method: "GET", path: "/api/v1/heatmaps/perf" },
2418
2874
  grain: "voxel",
2419
2875
  dimensions: ["scene", "session"],
2876
+ grainDimensions: [],
2420
2877
  filters: ["since", "until", "cellSize", "limit", "scene", "session", "format"],
2421
2878
  row: z.object({ vx: int, vy: int, vz: int, samples: int, avg_fps: num, min_fps: num }),
2422
2879
  columns: {
@@ -2449,6 +2906,7 @@ export const METRIC_REGISTRY = {
2449
2906
  builder: "buildPerfDaily",
2450
2907
  grain: "bucket",
2451
2908
  dimensions: [],
2909
+ grainDimensions: [],
2452
2910
  filters: [],
2453
2911
  row: z.object({ day: day, samples: int, avg_fps: num, min_fps: num, p50_fps: num }),
2454
2912
  columns: {
@@ -2486,6 +2944,7 @@ export const METRIC_REGISTRY = {
2486
2944
  endpoint: { method: "GET", path: "/api/v1/perf/compile-stalls" },
2487
2945
  grain: "row",
2488
2946
  dimensions: ["name", "scene", "session"],
2947
+ grainDimensions: ["name"],
2489
2948
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2490
2949
  row: z.object({ phase: text, stalls: int, total_ms: num, avg_ms: num, max_ms: num }),
2491
2950
  columns: {
@@ -2526,6 +2985,7 @@ export const METRIC_REGISTRY = {
2526
2985
  endpoint: { method: "GET", path: "/api/v1/perf/resources" },
2527
2986
  grain: "project",
2528
2987
  dimensions: ["session"],
2988
+ grainDimensions: [],
2529
2989
  filters: ["since", "until", "bins", "limit", "session", "format"],
2530
2990
  row: z.object({
2531
2991
  samples: int,
@@ -2578,6 +3038,7 @@ export const METRIC_REGISTRY = {
2578
3038
  endpoint: { method: "GET", path: "/api/v1/perf/resource-percentiles" },
2579
3039
  grain: "project",
2580
3040
  dimensions: ["scene", "session"],
3041
+ grainDimensions: [],
2581
3042
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2582
3043
  row: z.object({
2583
3044
  sessions: int,
@@ -2634,6 +3095,7 @@ export const METRIC_REGISTRY = {
2634
3095
  endpoint: { method: "GET", path: "/api/v1/perf/stability" },
2635
3096
  grain: "project",
2636
3097
  dimensions: ["scene", "session"],
3098
+ grainDimensions: [],
2637
3099
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2638
3100
  row: z.object({ context_losses: int, compile_stalls: int, incidents: int }),
2639
3101
  columns: {
@@ -2664,6 +3126,7 @@ export const METRIC_REGISTRY = {
2664
3126
  endpoint: { method: "GET", path: "/api/v1/graphics-diagnostics" },
2665
3127
  grain: "row",
2666
3128
  dimensions: ["scene", "session"],
3129
+ grainDimensions: [],
2667
3130
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2668
3131
  row: z.object({ severity: text, category: text, backend: text, incidents: int }),
2669
3132
  columns: {
@@ -2710,6 +3173,7 @@ export const METRIC_REGISTRY = {
2710
3173
  endpoint: { method: "GET", path: "/api/v1/heatmaps/errors" },
2711
3174
  grain: "voxel",
2712
3175
  dimensions: ["scene", "session"],
3176
+ grainDimensions: [],
2713
3177
  filters: [
2714
3178
  "since",
2715
3179
  "until",
@@ -2754,6 +3218,7 @@ export const METRIC_REGISTRY = {
2754
3218
  endpoint: { method: "GET", path: "/api/v1/rendering-technology" },
2755
3219
  grain: "row",
2756
3220
  dimensions: ["scene", "session"],
3221
+ grainDimensions: [],
2757
3222
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2758
3223
  row: z.object({
2759
3224
  api: text,
@@ -2797,6 +3262,7 @@ export const METRIC_REGISTRY = {
2797
3262
  endpoint: { method: "GET", path: "/api/v1/capabilities" },
2798
3263
  grain: "row",
2799
3264
  dimensions: ["name", "scene", "session"],
3265
+ grainDimensions: ["name"],
2800
3266
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2801
3267
  row: z.object({ kind: text, from: text, to: text, changes: int }),
2802
3268
  columns: {
@@ -2841,6 +3307,7 @@ export const METRIC_REGISTRY = {
2841
3307
  endpoint: { method: "GET", path: "/api/v1/xr/rotation" },
2842
3308
  grain: "session",
2843
3309
  dimensions: ["session", "scene"],
3310
+ grainDimensions: ["session"],
2844
3311
  filters: ["since", "until", "rapidTurn", "limit", "scene", "session", "format"],
2845
3312
  row: z.object({
2846
3313
  session_id: text,
@@ -2893,6 +3360,7 @@ export const METRIC_REGISTRY = {
2893
3360
  endpoint: { method: "GET", path: "/api/v1/xr/sources" },
2894
3361
  grain: "row",
2895
3362
  dimensions: ["source", "scene", "session"],
3363
+ grainDimensions: ["source"],
2896
3364
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2897
3365
  row: z.object({ source: text, interactions: int, sessions: int }),
2898
3366
  columns: {
@@ -2923,6 +3391,7 @@ export const METRIC_REGISTRY = {
2923
3391
  endpoint: { method: "GET", path: "/api/v1/xr/abandonment" },
2924
3392
  grain: "session",
2925
3393
  dimensions: ["session", "scene"],
3394
+ grainDimensions: ["session"],
2926
3395
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2927
3396
  row: z.object({
2928
3397
  session_id: text,
@@ -2966,6 +3435,7 @@ export const METRIC_REGISTRY = {
2966
3435
  endpoint: { method: "GET", path: "/api/v1/xr/locomotion" },
2967
3436
  grain: "session",
2968
3437
  dimensions: ["session", "scene"],
3438
+ grainDimensions: ["session"],
2969
3439
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
2970
3440
  row: z.object({
2971
3441
  session_id: text,
@@ -3021,6 +3491,7 @@ export const METRIC_REGISTRY = {
3021
3491
  endpoint: { method: "GET", path: "/api/v1/xr/tracking" },
3022
3492
  grain: "session",
3023
3493
  dimensions: ["session", "scene", "source"],
3494
+ grainDimensions: ["session"],
3024
3495
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
3025
3496
  row: z.object({
3026
3497
  session_id: text,
@@ -3071,6 +3542,7 @@ export const METRIC_REGISTRY = {
3071
3542
  endpoint: { method: "GET", path: "/api/v1/heatmaps/boundary" },
3072
3543
  grain: "voxel",
3073
3544
  dimensions: ["scene", "session"],
3545
+ grainDimensions: [],
3074
3546
  filters: ["since", "until", "cellSize", "limit", "scene", "session", "region", "format"],
3075
3547
  row: voxelCountRow,
3076
3548
  columns: {
@@ -3108,6 +3580,7 @@ export const METRIC_REGISTRY = {
3108
3580
  endpoint: { method: "GET", path: "/api/v1/heatmaps/boundary/stats" },
3109
3581
  grain: "project",
3110
3582
  dimensions: ["scene", "session"],
3583
+ grainDimensions: [],
3111
3584
  filters: ["since", "until", "cellSize", "scene", "session", "region", "format"],
3112
3585
  row: spatialStatsRow,
3113
3586
  columns: {
@@ -3136,6 +3609,7 @@ export const METRIC_REGISTRY = {
3136
3609
  endpoint: { method: "GET", path: "/api/v1/xr/boundary-contacts" },
3137
3610
  grain: "session",
3138
3611
  dimensions: ["session", "scene"],
3612
+ grainDimensions: ["session"],
3139
3613
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
3140
3614
  row: z.object({ session_id: text, contacts: int, near_ms: num }),
3141
3615
  columns: {
@@ -3170,6 +3644,7 @@ export const METRIC_REGISTRY = {
3170
3644
  endpoint: { method: "GET", path: "/api/v1/ar/placement/time-to-place" },
3171
3645
  grain: "bucket",
3172
3646
  dimensions: ["scene", "session"],
3647
+ grainDimensions: [],
3173
3648
  filters: ["since", "until", "bins", "limit", "scene", "session", "bucketMs", "format"],
3174
3649
  row: z.object({ bucket: int, placements: int }),
3175
3650
  columns: {
@@ -3204,6 +3679,7 @@ export const METRIC_REGISTRY = {
3204
3679
  endpoint: { method: "GET", path: "/api/v1/ar/placement/attempts" },
3205
3680
  grain: "bucket",
3206
3681
  dimensions: ["scene", "session"],
3682
+ grainDimensions: [],
3207
3683
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
3208
3684
  row: z.object({ attempts: int, placements: int }),
3209
3685
  columns: {
@@ -3242,6 +3718,7 @@ export const METRIC_REGISTRY = {
3242
3718
  endpoint: { method: "GET", path: "/api/v1/ar/placement/surfaces" },
3243
3719
  grain: "row",
3244
3720
  dimensions: ["scene", "session"],
3721
+ grainDimensions: [],
3245
3722
  filters: ["since", "until", "bins", "limit", "scene", "session", "format"],
3246
3723
  row: z.object({ surface: text, placements: int, avg_scale: num }),
3247
3724
  columns: {
@@ -3282,6 +3759,7 @@ export const METRIC_REGISTRY = {
3282
3759
  endpoint: { method: "GET", path: "/api/v1/funnel" },
3283
3760
  grain: "bucket",
3284
3761
  dimensions: ["scene", "cameraMode"],
3762
+ grainDimensions: [],
3285
3763
  filters: ["since", "until", "scene", "cameraMode", "steps", "format"],
3286
3764
  row: z.object({ step: int, sessions: int }),
3287
3765
  columns: {
@@ -3321,6 +3799,7 @@ export const METRIC_REGISTRY = {
3321
3799
  endpoint: { method: "GET", path: "/api/v1/scene-retention" },
3322
3800
  grain: "row",
3323
3801
  dimensions: ["scene"],
3802
+ grainDimensions: [],
3324
3803
  filters: ["since", "until", "limit", "format"],
3325
3804
  row: z.object({ from_scene: text, to_scene: text, sessions: int }),
3326
3805
  columns: {
@@ -3356,6 +3835,7 @@ export const METRIC_REGISTRY = {
3356
3835
  endpoint: { method: "GET", path: "/api/v1/load-bounce" },
3357
3836
  grain: "bucket",
3358
3837
  dimensions: ["scene"],
3838
+ grainDimensions: [],
3359
3839
  filters: ["since", "until", "scene", "bands", "format"],
3360
3840
  row: z.object({ band: int, sessions: int, bounced: int }),
3361
3841
  columns: {
@@ -3397,6 +3877,7 @@ export const METRIC_REGISTRY = {
3397
3877
  endpoint: { method: "GET", path: "/api/v1/variant-leaderboard" },
3398
3878
  grain: "row",
3399
3879
  dimensions: ["name", "scene", "cameraMode"],
3880
+ grainDimensions: [],
3400
3881
  filters: ["since", "until", "scene", "cameraMode", "variant", "conversion", "limit", "format"],
3401
3882
  row: z.object({
3402
3883
  variant: text,
@@ -3437,6 +3918,451 @@ export const METRIC_REGISTRY = {
3437
3918
  comparable: { primary: "conversions", direction: "up", minSample: 50 },
3438
3919
  category: "conversion",
3439
3920
  },
3921
+ // =========================================================================
3922
+ // Insights (ADR 0051 §4, design sketch §D)
3923
+ //
3924
+ // Derived metrics: computed in pure TypeScript over *another* metric's
3925
+ // portable bucket series (`@uptimizr/db`'s `src/insights/`) rather than by a
3926
+ // `build*` aggregation of their own. They are the answer to the two questions
3927
+ // an agent otherwise re-derives from raw rows on every turn — "what is normal
3928
+ // here" and "what changed" — computed once, the same way, on every engine.
3929
+ // =========================================================================
3930
+ insight_baseline: {
3931
+ id: "insight_baseline",
3932
+ title: "Metric baseline",
3933
+ description: "What is normal for one metric in one scene. Buckets a comparable metric's headline column " +
3934
+ "into days or hours over a trailing window and reduces the series to its centre (mean, " +
3935
+ "median), its ordinary spread (MAD, p10, p90) and its drift (least-squares slope per " +
3936
+ "bucket). One row per request: the reference distribution a single later observation should " +
3937
+ "be judged against, so 'is 42 FPS bad here?' has an answer that does not depend on the " +
3938
+ "reader's memory.",
3939
+ derived: "insight",
3940
+ endpoint: { method: "GET", path: "/api/v1/insights/baseline" },
3941
+ grain: "project",
3942
+ dimensions: ["scene"],
3943
+ // One row per request; `scene` is both the filter and the key the row
3944
+ // carries, so it is the grain (#304).
3945
+ grainDimensions: ["scene"],
3946
+ filters: ["metric", "scene", "window", "bucket", "since", "until", "format"],
3947
+ row: z.object({
3948
+ metric: text,
3949
+ scene: text,
3950
+ sampleSize: int,
3951
+ buckets: int,
3952
+ mean: numOrNull,
3953
+ median: numOrNull,
3954
+ mad: numOrNull,
3955
+ p10: numOrNull,
3956
+ p90: numOrNull,
3957
+ slope: numOrNull,
3958
+ }),
3959
+ columns: {
3960
+ metric: { description: "The registry metric the baseline is of.", unit: "id" },
3961
+ scene: {
3962
+ description: "The scene it was scoped to; empty when the baseline spans every scene.",
3963
+ unit: "id",
3964
+ },
3965
+ sampleSize: {
3966
+ description: "Events (or distinct sessions, for a session-valued metric) behind the whole series.",
3967
+ unit: "count",
3968
+ },
3969
+ buckets: { description: "How many buckets carried a value.", unit: "count" },
3970
+ mean: { description: "Arithmetic mean of the bucket values." },
3971
+ median: { description: "Median bucket value — the headline 'normal'.", measure: true },
3972
+ mad: {
3973
+ description: "Median absolute deviation: the typical bucket-to-bucket swing, and the unit a robust " +
3974
+ "z-score is measured in.",
3975
+ },
3976
+ p10: { description: "10th percentile of the bucket values — the ordinary floor." },
3977
+ p90: { description: "90th percentile of the bucket values — the ordinary ceiling." },
3978
+ slope: {
3979
+ description: "Least-squares slope against the bucket index: change per bucket over the window.",
3980
+ },
3981
+ },
3982
+ limits: { maxRows: 1, maxSummaryRows: 1 },
3983
+ interpretation: "Read `median` as the centre and `mad` as the tolerance: a later observation more than a few " +
3984
+ "MADs from the median is unusual for this scene, one inside p10..p90 is ordinary. `slope` is " +
3985
+ "the drift *within* the window — a baseline with a steep slope is not a stable reference and " +
3986
+ "should be re-read over a shorter window before it is used to judge anything.",
3987
+ caveats: [
3988
+ "Only `comparable` metrics with a portable bucket series can be baselined; a metric whose value is defined by a join, a window function or a caller-supplied funnel predicate is rejected rather than approximated, and the error names every id that is available.",
3989
+ "Window bounds are snapped down to whole buckets, so the current (incomplete) day or hour is excluded — otherwise every morning would look like a collapse. The snapped window is echoed in the `table` / `summary` envelopes.",
3990
+ "Buckets with no matching events are absent from the series rather than zero: a metric that was silent for a week has a narrower baseline, not a lower one.",
3991
+ "Every statistic is `null` when the window carried no values, and `slope` is `null` under two buckets. Read `null` as 'no data', never as 0.",
3992
+ "The series reproduces the metric's headline column per bucket; where the metric's own endpoint aggregates per session first (ADR 0028 §1), a bucket value and that endpoint's headline can differ slightly.",
3993
+ ],
3994
+ sourceChannels: [],
3995
+ related: ["insight_movers", "timeseries", "perf_summary", "events_daily"],
3996
+ category: "insights",
3997
+ },
3998
+ insight_movers: {
3999
+ id: "insight_movers",
4000
+ title: "What changed",
4001
+ description: "What moved, ranked. For every comparable metric in scope, compares the current range with a " +
4002
+ "reference range (the previous equal window by default) and ranks the differences by a " +
4003
+ "robust z-score — the change divided by how much that metric normally swings, so a metric " +
4004
+ "that is always volatile has to move much further than a steady one before it is called a " +
4005
+ "mover. One row per metric: the top risers, the top fallers, and the ones that did not move.",
4006
+ derived: "insight",
4007
+ endpoint: { method: "GET", path: "/api/v1/insights/movers" },
4008
+ grain: "row",
4009
+ dimensions: ["scene"],
4010
+ // One row per metric. Its rows carry no registry dimension column at all —
4011
+ // the key is the metric id, which is not one — so there is nothing for the
4012
+ // generic group-by tier to regroup by (#304).
4013
+ grainDimensions: [],
4014
+ filters: [
4015
+ "scene",
4016
+ "metrics",
4017
+ "bucket",
4018
+ "limit",
4019
+ "since",
4020
+ "until",
4021
+ "refSince",
4022
+ "refUntil",
4023
+ "format",
4024
+ ],
4025
+ row: z.object({
4026
+ metric: text,
4027
+ dimensionValue: z.string().nullable(),
4028
+ current: numOrNull,
4029
+ previous: numOrNull,
4030
+ delta: numOrNull,
4031
+ deltaPct: numOrNull,
4032
+ z: numOrNull,
4033
+ direction: text,
4034
+ aboveMinSample: z.boolean(),
4035
+ sampleSize: num,
4036
+ }),
4037
+ columns: {
4038
+ metric: { description: "The registry metric that moved.", unit: "id", label: true },
4039
+ dimensionValue: {
4040
+ description: "The dimension value the move is attributed to. Always null in v1 — movers are computed " +
4041
+ "at the scene (or project) level; per-dimension attribution arrives with `anomalies`.",
4042
+ unit: "id",
4043
+ },
4044
+ current: { description: "The metric's headline column over the current range." },
4045
+ previous: { description: "The same over the reference range." },
4046
+ delta: { description: "`current - previous`, in the metric's own unit." },
4047
+ deltaPct: {
4048
+ description: "Relative change; null when the reference value was zero or missing.",
4049
+ unit: "ratio",
4050
+ },
4051
+ z: {
4052
+ description: "Robust z-score: `delta` divided by the median absolute deviation of the reference " +
4053
+ "bucket series. The ranking key.",
4054
+ unit: "ratio",
4055
+ measure: true,
4056
+ },
4057
+ direction: {
4058
+ description: "Whether a rise is good (`up`), bad (`down`) or merely a fact (`neutral`), from the " +
4059
+ "moved metric's own registry entry. It qualifies the sign of `delta`; it is not the " +
4060
+ "direction this metric actually moved in.",
4061
+ unit: "label",
4062
+ },
4063
+ aboveMinSample: {
4064
+ description: "Whether the current range cleared the metric's declared minimum denominator. False " +
4065
+ "means the delta is real arithmetic but not evidence.",
4066
+ },
4067
+ sampleSize: {
4068
+ description: "The current range's denominator — events, or distinct sessions for a session-valued " +
4069
+ "metric — that `aboveMinSample` was decided on.",
4070
+ },
4071
+ },
4072
+ limits: { maxRows: 150, maxSummaryRows: 10 },
4073
+ interpretation: "Read the rows in order: risers first, then fallers, then the ones that did not move. |z| is " +
4074
+ "'how many typical swings': under ~2 the move is inside this metric's ordinary noise, over " +
4075
+ "~3 it is worth explaining. Combine `direction` with the sign of `delta` to know whether a " +
4076
+ "move is good or bad — a rise in a `down` metric (errors, dead clicks, jank) is a " +
4077
+ "regression. Rows with `aboveMinSample: false` are sorted last and must not be reported as " +
4078
+ "findings.",
4079
+ caveats: [
4080
+ "Cost is bounded by a per-request cap on how many metrics are scanned (one grouped scan each); without a `metrics` allowlist a curated default set is used, so a move in a metric outside that set is not reported. Name it explicitly to include it.",
4081
+ "Only metrics with a portable bucket series participate. Funnels, cohort metrics and anything defined by the relationship between consecutive events are outside the scan by construction.",
4082
+ "`aboveMinSample: false` means the denominator is too small for the delta to be evidence — the row is kept rather than dropped so 'we cannot tell' stays distinguishable from 'nothing changed', but it must not be read as a finding.",
4083
+ "The spread is taken over the *reference* window's buckets, floored at 1% of that window's level — so a one-bucket or perfectly flat reference cannot make every metric score in the billions and turn the ranking into a comparison of raw deltas across incompatible units. A reference that is flat *at zero* has no level to floor against, so the first data of its kind scores a very large |z|: correct (it is unprecedented) but only meaningful once `aboveMinSample` is true.",
4084
+ "Window bounds are snapped down to whole buckets, so the current incomplete day or hour is excluded from both ranges and the two stay exactly comparable.",
4085
+ "`deltaPct` is null when the reference value was zero: a percentage change from nothing is undefined. Read `delta` in that case.",
4086
+ "A metric is compared with itself over time, never across projects or scenes: sampling rates (ADR 0012) make absolute counts incomparable between them.",
4087
+ ],
4088
+ sourceChannels: [],
4089
+ related: ["insight_baseline", "events_daily", "perf_daily", "event_counts"],
4090
+ category: "insights",
4091
+ },
4092
+ // =========================================================================
4093
+ // --- anomalies (#306) ---
4094
+ // =========================================================================
4095
+ insight_anomalies: {
4096
+ id: "insight_anomalies",
4097
+ title: "Anomalous buckets",
4098
+ description: "When one metric stopped behaving, and what inside it accounts for that. Walks a comparable " +
4099
+ "metric's day or hour bucket series and returns only the buckets that do not belong in it: " +
4100
+ "a `spike` or a `drop` when a single bucket sits more than `sensitivity` median absolute " +
4101
+ "deviations from the buckets just before it, and a `shift` at the bucket where a CUSUM " +
4102
+ "change-point says the level moved and stayed moved. Where the metric declares a dimension " +
4103
+ "it can be split by, the row also names the dimension value holding the largest share of " +
4104
+ "the excess — the difference between 'errors tripled on the 14th' and 'graphics " +
4105
+ "diagnostics tripled on the 14th'.",
4106
+ derived: "insight",
4107
+ endpoint: { method: "GET", path: "/api/v1/insights/anomalies" },
4108
+ grain: "row",
4109
+ dimensions: ["scene"],
4110
+ // One row per anomalous bucket; `scene` is the only registry dimension the
4111
+ // row carries (the metric id and the bucket start are not) (#304).
4112
+ grainDimensions: ["scene"],
4113
+ filters: ["metric", "scene", "window", "bucket", "sensitivity", "since", "until", "format"],
4114
+ row: z.object({
4115
+ metric: text,
4116
+ scene: text,
4117
+ bucketStart: num,
4118
+ value: numOrNull,
4119
+ expected: numOrNull,
4120
+ z: numOrNull,
4121
+ kind: text,
4122
+ contributor: z.object({ dimension: text, value: text, share: numOrNull }).nullable(),
4123
+ sampleSize: num,
4124
+ }),
4125
+ columns: {
4126
+ metric: { description: "The registry metric the series is of.", unit: "id", label: true },
4127
+ scene: {
4128
+ description: "The scene it was scoped to; empty when the series spans every scene.",
4129
+ unit: "id",
4130
+ },
4131
+ bucketStart: {
4132
+ description: "Start of the anomalous bucket, epoch milliseconds (UTC, aligned to the grain). For a " +
4133
+ "`shift` this is the bucket the level changed at, not the bucket the change was detected in.",
4134
+ unit: "timestamp",
4135
+ },
4136
+ value: {
4137
+ description: "The observed value. For a `shift`, the level that held after the change-point rather " +
4138
+ "than one bucket's reading.",
4139
+ },
4140
+ expected: {
4141
+ description: "What the bucket should have read: the median of the trailing window, or for a " +
4142
+ "`shift` the level that held before the change-point.",
4143
+ },
4144
+ z: {
4145
+ description: "Signed robust z-score: `(value - expected)` divided by the trailing window's median " +
4146
+ "absolute deviation rescaled to a standard deviation (x1.4826) and floored at 1% of the " +
4147
+ "level. It is measured in standard deviations, so `sensitivity: 3` means 'beyond three " +
4148
+ "sigma'. `insight_movers` reports the same ratio *unscaled* because it ranks rather " +
4149
+ "than thresholds, so the two columns differ by that constant factor.",
4150
+ unit: "ratio",
4151
+ measure: true,
4152
+ },
4153
+ kind: {
4154
+ description: "`spike` (one bucket far above the trailing window), `drop` (far below) or `shift` (the " +
4155
+ "level moved and stayed moved).",
4156
+ unit: "label",
4157
+ },
4158
+ contributor: {
4159
+ description: "The dimension value accounting for the excess: `{ dimension, value, share }`, where " +
4160
+ "`share` is its fraction of the total same-signed excess. Null when the metric declares " +
4161
+ "no split dimension, when the split would be degenerate, or when no value moved with the " +
4162
+ "finding.",
4163
+ },
4164
+ sampleSize: {
4165
+ description: "The anomalous bucket's own denominator — events, or distinct sessions for a " +
4166
+ "session-valued metric.",
4167
+ unit: "count",
4168
+ },
4169
+ },
4170
+ limits: { maxRows: 200, maxSummaryRows: 8 },
4171
+ interpretation: "Rows are ordered by bucket, oldest first, so the result reads as a timeline. |z| is 'how " +
4172
+ "many typical swings out': at the default `sensitivity` of 3 nothing under 3 appears at all, " +
4173
+ "and a `shift` is usually the more important finding than a `spike` because it is still " +
4174
+ "true today. Read `contributor.share`: above ~0.8 the anomaly *is* that dimension value and " +
4175
+ "the investigation has one place to start; near the reciprocal of the number of values it is " +
4176
+ "spread evenly and the cause is more likely to be global. An empty result means the series " +
4177
+ "held together over the window, not that there was no data - check `insight_baseline`'s " +
4178
+ "`buckets` for that.",
4179
+ caveats: [
4180
+ "Only `comparable` metrics with a portable bucket series can be scored; a metric whose value is defined by a join, a window function or a caller-supplied funnel predicate is rejected with the list of ids that are available, rather than approximated.",
4181
+ "A bucket is judged against the buckets before it (14 at day grain, 168 at hour grain), never against itself, and a bucket with fewer than 5 buckets of history behind it is not judged at all. The first days of a project are therefore silent by construction rather than a wall of findings.",
4182
+ "The spread is floored at 1% of the trailing level, so a perfectly flat metric does not report every subsequent bucket as an infinite z. A series that is flat at *zero* has no level to floor against, so the first data of its kind can read as a very large spike.",
4183
+ "Buckets with no matching events are absent from the series rather than zero, so a gap in capture is not reported as a drop. A metric that genuinely fell to zero *did* record events, so it is.",
4184
+ "Change-points are detected sequentially, so a shift is reported at the bucket the level moved at but can only be found once enough buckets after it have accumulated. The most recent few buckets of a window are therefore under-covered for `shift` - re-read after more data lands.",
4185
+ "Contributor attribution costs at most 3 extra grouped scans per request whatever the data looks like: adjacent findings share one window, and only the three most extreme windows are re-read. Quieter findings carry `contributor: null` - narrow `since` / `until` around one to attribute it.",
4186
+ "The contributor is one dimension, chosen per metric from the promoted columns (mesh, source, name, event type, scene). It is not a cause: it is where the excess sits. A metric whose real explanation is the device or the release has no column for it and reports null.",
4187
+ "Window bounds are snapped down to whole buckets, so the incomplete day or hour in progress is never scored - otherwise every morning would report a drop.",
4188
+ "A sustained level change is reported twice over: once as the `shift` at its change-point, and again as `drop`/`spike` rows for the buckets right after it, until the trailing window has caught up with the new level. Those are two true statements about one event ('it moved on the 14th' and 'the 15th was far below what the fortnight before it predicted'), not a duplicate row - read the `shift` as the finding and the points as its first days.",
4189
+ "`z` is measured in standard deviations (the MAD rescaled by 1.4826), so it is directly comparable with a normal-distribution intuition but **not** byte-comparable with `insight_movers`' `z`, which is the same ratio unscaled.",
4190
+ ],
4191
+ sourceChannels: [],
4192
+ related: ["insight_baseline", "insight_movers", "timeseries", "error_heatmap"],
4193
+ category: "insights",
4194
+ },
4195
+ // --- significance / scene health (#307) ---------------------------------
4196
+ //
4197
+ // The other two §4 primitives. `insight_significance` answers the question a
4198
+ // robust z deliberately does not — *could this difference be chance?* — and
4199
+ // `insight_scene_health` collapses six of those readings into one comparable
4200
+ // score per scene, with every factor traceable back to the metric behind it.
4201
+ insight_significance: {
4202
+ id: "insight_significance",
4203
+ title: "Statistical significance",
4204
+ description: "Is that difference real? Compares one comparable metric across two windows and reports the " +
4205
+ "effect, its 95% confidence interval and a two-sided p-value, with the test chosen from what " +
4206
+ "the measure *is*: a two-proportion z with Wilson intervals for a declared rate, Welch's t " +
4207
+ "over the per-bucket values for a level, an exact Poisson rate test for a bare count. One " +
4208
+ "row per request, and a `powerNote` saying what these sample sizes could and could not have " +
4209
+ "detected.",
4210
+ derived: "insight",
4211
+ endpoint: { method: "GET", path: "/api/v1/insights/significance" },
4212
+ grain: "project",
4213
+ dimensions: ["scene"],
4214
+ // One row per request; `scene` is the only registry dimension the row
4215
+ // carries, so it is the grain (#304).
4216
+ grainDimensions: ["scene"],
4217
+ filters: ["metric", "scene", "bucket", "since", "until", "refSince", "refUntil", "format"],
4218
+ row: z.object({
4219
+ metric: text,
4220
+ scene: text,
4221
+ a: z.object({ value: numOrNull, n: num }),
4222
+ b: z.object({ value: numOrNull, n: num }),
4223
+ effect: numOrNull,
4224
+ ci95: z.tuple([numOrNull, numOrNull]),
4225
+ p: numOrNull,
4226
+ test: text,
4227
+ effectUnit: text,
4228
+ significant: z.boolean(),
4229
+ powerNote: text,
4230
+ }),
4231
+ columns: {
4232
+ metric: { description: "The registry metric being compared.", unit: "id", label: true },
4233
+ scene: {
4234
+ description: "The scene it was scoped to; empty when the comparison spans every scene.",
4235
+ unit: "id",
4236
+ },
4237
+ a: {
4238
+ description: "The current window: `value` in the metric's own unit (a proportion for a rate, a " +
4239
+ "per-bucket rate for a count, a mean of bucket values for a level) and `n`, the " +
4240
+ "denominator it rests on — trials for a proportion, buckets otherwise. `value * n` " +
4241
+ "recovers the total the arm was computed from, whichever test ran.",
4242
+ },
4243
+ b: { description: "The reference window, in the same shape as `a`." },
4244
+ effect: {
4245
+ description: "`a.value - b.value`, in the unit named by `effectUnit`.",
4246
+ measure: true,
4247
+ },
4248
+ ci95: {
4249
+ description: "The 95% confidence interval for `effect`, as `[lo, hi]`. An interval that straddles 0 " +
4250
+ "is the finding, whatever `p` says.",
4251
+ },
4252
+ p: {
4253
+ description: "Two-sided p-value for the null hypothesis of no difference.",
4254
+ unit: "ratio",
4255
+ },
4256
+ test: {
4257
+ description: "Which test produced the row: `two_proportion_z`, `welch_t` or `poisson_rate`.",
4258
+ unit: "label",
4259
+ },
4260
+ effectUnit: { description: "What one unit of `effect` means.", unit: "label" },
4261
+ significant: {
4262
+ description: "Whether `p` cleared alpha = 0.05. A convenience, never a substitute for `ci95`.",
4263
+ },
4264
+ powerNote: {
4265
+ description: "The smallest difference these sample sizes could have detected at 80% power, plus any " +
4266
+ "assumption the data strained (overdispersed counts, too few buckets).",
4267
+ unit: "label",
4268
+ },
4269
+ },
4270
+ limits: { maxRows: 1, maxSummaryRows: 1 },
4271
+ interpretation: "Read `ci95` before `p`. A narrow interval around a small effect is evidence that nothing " +
4272
+ "much changed; a wide interval containing 0 is evidence of nothing at all, and `powerNote` " +
4273
+ "says which of the two you are looking at. `p` is two-sided throughout: it answers 'is there " +
4274
+ "a difference', not 'is it an improvement' — combine it with the sign of `effect` and the " +
4275
+ "compared metric's own `direction` for that.",
4276
+ caveats: [
4277
+ "v1 compares two **time windows**, not two segments. A variant-versus-variant or device-versus-device contrast needs the bucket series split by a promoted dimension, which is not available yet; asking for one is rejected rather than answered with the wrong contrast.",
4278
+ "The test is chosen from the measure, not from the caller: a metric whose comparable primary declares a `rateOf` denominator gets the two-proportion z, a bare count gets the Poisson rate test, and everything else gets Welch's t. A caller cannot ask for a different one.",
4279
+ "Welch's t treats each **bucket** as one observation, so `n` is the number of days or hours compared, never the number of events. Consecutive frame samples inside a day are not independent, and counting them would manufacture a p-value of 1e-40 for ordinary day-to-day drift.",
4280
+ "For a Welch comparison `a.value` is the **mean** of the bucket values even where `movers` would report the window sum, so that `effect` and `value` can be read together; `n` is the bucket count, so the sum is one multiplication away.",
4281
+ "The Poisson test assumes counts that do not vary more than a Poisson process would. When the buckets say otherwise, the p-value is optimistic and `powerNote` says so — treat it as an upper bound on the evidence.",
4282
+ "The Poisson interval is the normal approximation on the rate difference (there is no closed-form exact one); only the p-value is exact.",
4283
+ "The two-proportion p-value uses the pooled variance under the null while the interval is unpooled and Newcombe-hybrid. In a borderline case the two can disagree about whether 0 is excluded; that is the textbook procedure, and the interval is the one to believe.",
4284
+ "Window bounds are snapped down to whole buckets, so the current incomplete day or hour is excluded from both windows and the two stay exactly comparable.",
4285
+ "Statistical significance is not importance. A large enough sample makes a difference of no consequence significant; `effect` and `effectUnit` are what say whether it matters.",
4286
+ ],
4287
+ sourceChannels: [],
4288
+ related: ["insight_movers", "insight_baseline", "insight_scene_health"],
4289
+ category: "insights",
4290
+ },
4291
+ insight_scene_health: {
4292
+ id: "insight_scene_health",
4293
+ title: "Scene health score",
4294
+ description: "Which scene is in trouble, and why. Scores each scene 0-100 over six weighted factors — " +
4295
+ "perf stability, jank, errors, dead clicks, exploration coverage and XR abandonment — each " +
4296
+ "normalised against the project's own baseline over the preceding equal window. One row per " +
4297
+ "scene, least healthy first, and every factor carries the metric id, the raw value, the " +
4298
+ "baseline it was compared with and the weight it contributed, so the score can always be " +
4299
+ "taken apart.",
4300
+ derived: "insight",
4301
+ endpoint: { method: "GET", path: "/api/v1/insights/scene-health" },
4302
+ grain: "scene",
4303
+ dimensions: ["scene"],
4304
+ // One row per scene — the grain is the scene itself (#304).
4305
+ grainDimensions: ["scene"],
4306
+ filters: ["scene", "window", "since", "until", "bucket", "limit", "weights", "format"],
4307
+ row: z.object({
4308
+ scene: text,
4309
+ score: numOrNull,
4310
+ factors: z.array(z.object({
4311
+ id: text,
4312
+ metric: text,
4313
+ raw: numOrNull,
4314
+ baseline: numOrNull,
4315
+ score: numOrNull,
4316
+ weight: num,
4317
+ unit: text,
4318
+ note: text,
4319
+ })),
4320
+ sampleSize: int,
4321
+ since: int,
4322
+ until: int,
4323
+ }),
4324
+ columns: {
4325
+ scene: { description: "The scene scored.", unit: "id", label: true },
4326
+ score: {
4327
+ description: "Weighted mean of the available factors, 0-100. 50 is exactly the project norm for the " +
4328
+ "preceding window; higher is healthier. Null when no factor could be scored.",
4329
+ measure: true,
4330
+ },
4331
+ factors: {
4332
+ description: "One entry per factor: `id`, the `metric` behind it, its `raw` value in `unit`, the " +
4333
+ "project `baseline` it was compared with, its normalised `score`, the `weight` it " +
4334
+ "carried, and a `note` saying what the raw number measures (or why it was not scored).",
4335
+ },
4336
+ sampleSize: {
4337
+ description: "Sessions started in the scene over the window — what the score rests on.",
4338
+ unit: "sessions",
4339
+ },
4340
+ since: { description: "Start of the scored window, epoch ms.", unit: "epoch-ms" },
4341
+ until: { description: "End of the scored window, epoch ms.", unit: "epoch-ms" },
4342
+ },
4343
+ limits: { maxRows: 10, maxSummaryRows: 10 },
4344
+ interpretation: "The score is a **comparison, not a grade**: it says how this scene is doing against the " +
4345
+ "rest of this project's recent past, so a project where everything is equally bad reads 50 " +
4346
+ "everywhere. Read the lowest-scoring scene first, then the factor whose own score is " +
4347
+ "furthest below 50 — that factor's `metric` is the endpoint to open next, and its `raw` " +
4348
+ "versus `baseline` is the sentence to write. A factor with `score: null` was not counted; " +
4349
+ "its `note` says why.",
4350
+ caveats: [
4351
+ "The default weights are a judgement, declared in this entry so they can be argued with: perf stability 0.25, error rate 0.25, jank rate 0.2, dead-click rate 0.15, coverage 0.1, XR abandonment 0.05. Pass `weights` as a JSON object to override any of them; unnamed factors keep their default.",
4352
+ "Normalisation is against the **project as a whole** over the preceding equal window, not against an absolute target. A new project with one week of data has no baseline and scores null.",
4353
+ "Unlike `insight_baseline` and `insight_movers`, the window **includes the bucket in progress** rather than snapping down to the last complete one: every factor is a rate or a percentile, neither of which a partial bucket distorts, and excluding today would make the score answer about yesterday. The window actually measured is echoed as `since` / `until` on every row.",
4354
+ "A factor with no data in the window, or no project baseline to compare against, is reported with `score: null` and excluded from the weighted mean — the remaining weights are renormalised. The row still lists it, so a missing factor is visible rather than silently folded in.",
4355
+ "XR abandonment is absent in a project with no XR traffic: a scene nobody visited in VR is not an unhealthy VR scene.",
4356
+ "Coverage is positioned camera samples per session, not a voxel-coverage percentage: the true percentage needs the registered scene bounds and has no portable per-bucket form. It is only ever read against the project's own baseline.",
4357
+ "The jank factor is the *pooled* long-frames-per-sampled-window rate, while the `jank_rate` metric's own endpoint reports a per-session median (ADR 0028 Section 1). They agree in direction, not in value.",
4358
+ "Rage clicks are not folded into the dead-click factor: they are defined by the gap between consecutive clicks and have no portable per-bucket form.",
4359
+ "Fewer than about 20 sessions makes a scene's score noise. The row is returned anyway, with its `sampleSize`, so 'we cannot tell' stays distinguishable from 'this scene is fine'.",
4360
+ "Without a `scene` the scan is bounded to the busiest scenes by event volume, so a quiet scene can be missing from the list entirely. Name it explicitly to score it.",
4361
+ ],
4362
+ sourceChannels: [],
4363
+ related: ["insight_movers", "insight_baseline", "insight_significance"],
4364
+ category: "insights",
4365
+ },
3440
4366
  };
3441
4367
  /** Every registry id, in declaration order. */
3442
4368
  export const METRIC_IDS = Object.keys(METRIC_REGISTRY);
@@ -3479,6 +4405,21 @@ export function metricForBuilder(builder) {
3479
4405
  * catalog stays a superset of the hand-written one (design sketch §A.4).
3480
4406
  */
3481
4407
  export function isResourceMetric(metric) {
3482
- return metric.builder === undefined;
4408
+ return metric.builder === undefined && metric.derived === undefined;
4409
+ }
4410
+ /**
4411
+ * A **derived** entry is computed in TypeScript over another metric's data
4412
+ * rather than by a `build*` aggregation (ADR 0051 §4): the insight primitives.
4413
+ * It has no builder, but — unlike a {@link isResourceMetric resource} — it is a
4414
+ * real aggregate with a querystring, a time range and the `format` envelope, so
4415
+ * every consumer that treats `builder != null` as "is an aggregate" should ask
4416
+ * this instead.
4417
+ */
4418
+ export function isDerivedMetric(metric) {
4419
+ return metric.derived !== undefined;
4420
+ }
4421
+ /** Whether a metric is served as an aggregate over a querystring (not a resource read). */
4422
+ export function isAggregateMetric(metric) {
4423
+ return metric.builder !== undefined || metric.derived !== undefined;
3483
4424
  }
3484
4425
  //# sourceMappingURL=registry.js.map