@policyengine/calibration-tree 0.0.0-stage → 0.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/package.json +71 -4
  4. package/src/calibration-build-comparison.ts +77 -0
  5. package/src/calibration-comparison-blob.ts +367 -0
  6. package/src/calibration-comparison-bundle.ts +149 -0
  7. package/src/calibration-explorer.ts +202 -0
  8. package/src/calibration-release-locator.ts +353 -0
  9. package/src/calibration-tree-artifact.ts +1974 -0
  10. package/src/calibration-tree-blob.ts +403 -0
  11. package/src/calibration-tree-bundle.ts +569 -0
  12. package/src/calibration-tree-manifest.ts +349 -0
  13. package/src/calibration-tree-worker.ts +445 -0
  14. package/src/calibration-tree.ts +1284 -0
  15. package/src/chronicle-metadata.ts +42 -0
  16. package/src/countries.ts +355 -0
  17. package/src/geography-labels.ts +59 -0
  18. package/src/hierarchy-catalog-reader.ts +297 -0
  19. package/src/hierarchy-target-reader.ts +243 -0
  20. package/src/historical-weight-summary.ts +53 -0
  21. package/src/latest-artifact.ts +3767 -0
  22. package/src/legacy-target-reader.ts +337 -0
  23. package/src/program-label.ts +64 -0
  24. package/src/publish-calibration-tree.ts +1984 -0
  25. package/src/reforms.ts +224 -0
  26. package/src/release-cut.ts +32 -0
  27. package/src/release-record.ts +277 -0
  28. package/src/source-label.ts +5 -0
  29. package/src/source-labels.ts +71 -0
  30. package/src/staging-artifact.ts +1411 -0
  31. package/src/staging-contract.ts +916 -0
  32. package/src/structured-dimension-reader.ts +137 -0
  33. package/src/structured-target-reader.ts +76 -0
  34. package/src/target-change.ts +421 -0
  35. package/src/target-loss-attribution-manifest.ts +436 -0
  36. package/src/target-loss-attribution.ts +1012 -0
  37. package/src/target-representation.ts +69 -0
  38. package/src/target-surface-matcher.ts +266 -0
  39. package/types/next-fetch-cache.d.ts +8 -0
@@ -0,0 +1,1284 @@
1
+ import type {
2
+ CalibrationStatus,
3
+ ComparisonFit,
4
+ ExplorerNodeSelection,
5
+ ExplorerState,
6
+ FitBand,
7
+ } from "./calibration-explorer";
8
+ import { COMPARISON_FITS } from "./calibration-explorer";
9
+ import { canonicalLabel, programLabel } from "./program-label";
10
+ import { sourceLabel } from "./source-label";
11
+ import type { CalibrationProvenance } from "./target-loss-attribution";
12
+
13
+ const DEFAULT_GEOGRAPHY = "United States";
14
+ const DEFAULT_GEOGRAPHY_LEVEL = "national";
15
+
16
+ export interface CalibrationTreeDimension {
17
+ key: string;
18
+ label: string;
19
+ value: string;
20
+ value_id?: string;
21
+ source_key?: string;
22
+ raw_value?: string;
23
+ rank?: number;
24
+ }
25
+
26
+ export interface CalibrationTreeTarget {
27
+ name?: string | null;
28
+ base_name?: string | null;
29
+ comparison_id?: string | null;
30
+ source?: string | null;
31
+ source_label?: string | null;
32
+ variable?: string | null;
33
+ variable_label?: string | null;
34
+ variable_key?: string | null;
35
+ measure?: string | null;
36
+ source_measure_id?: string | null;
37
+ target_label?: string | null;
38
+ target_representation?: string | null;
39
+ level?: string | null;
40
+ geography?: string | null;
41
+ abs_relative_error?: number | null;
42
+ target_loss_weight_share?: number | null;
43
+ final_capped_scaled_error?: number | null;
44
+ final_loss_contribution?: number | null;
45
+ target_change?: number | null;
46
+ comparison_status?: "shared" | "added" | "removed" | null;
47
+ comparison_fit?: ComparisonFit | null;
48
+ calibration_status?: CalibrationStatus | "not_materialized" | null;
49
+ target_dimensions?: CalibrationTreeDimension[] | null;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ export interface CalibrationTreeChangeMetrics {
54
+ increasedError: number;
55
+ reducedError: number;
56
+ netChange: number;
57
+ changedTargets: number;
58
+ unchangedTargets: number;
59
+ sharedTargets: number;
60
+ addedTargets: number;
61
+ removedTargets: number;
62
+ }
63
+
64
+ export interface CalibrationTreeMetrics {
65
+ nTargets: number;
66
+ scored: number;
67
+ within10Pct: number;
68
+ loss: number;
69
+ targetLossWeightShare: number;
70
+ weightedAverageCappedError: number | null;
71
+ meanAbsRelativeError: number | null;
72
+ medianAbsRelativeError: number | null;
73
+ change?: CalibrationTreeChangeMetrics;
74
+ }
75
+
76
+ export interface CalibrationTreeMetricInput {
77
+ absRelativeError: number | null;
78
+ targetLossWeightShare: number | null;
79
+ finalLossContribution: number | null;
80
+ targetChange: number | null;
81
+ comparisonStatus: "shared" | "added" | "removed" | null;
82
+ }
83
+
84
+ export interface CalibrationTreeNode {
85
+ id: string;
86
+ label: string;
87
+ kind: ExplorerNodeSelection["kind"];
88
+ selection: ExplorerNodeSelection;
89
+ metrics: CalibrationTreeMetrics;
90
+ target?: CalibrationTreeTarget;
91
+ authored_label?: boolean;
92
+ }
93
+
94
+ export interface CalibrationTreeGroup {
95
+ id: string;
96
+ label: string;
97
+ nodes: CalibrationTreeNode[];
98
+ metrics: CalibrationTreeMetrics;
99
+ }
100
+
101
+ export interface CalibrationTreeResponse {
102
+ releaseId?: string;
103
+ calibrationProvenance?: CalibrationProvenance;
104
+ lossAttributionAvailable: boolean;
105
+ path: ExplorerState["path"];
106
+ pathLabels: {
107
+ source?: string;
108
+ program?: string;
109
+ geography?: string;
110
+ dimensions?: string[];
111
+ };
112
+ currentLevel:
113
+ | { kind: "overview"; label: string }
114
+ | { kind: "geography"; label: string }
115
+ | { kind: "dimension"; key: string; label: string }
116
+ | { kind: "mixed"; label: string }
117
+ | { kind: "target"; label: string };
118
+ groups: CalibrationTreeGroup[];
119
+ dimensionOrder: Array<{ key: string; label: string }>;
120
+ filterOptions: {
121
+ geographyLevels: string[];
122
+ geographies: string[];
123
+ fitBands: string[];
124
+ comparisonFits: string[];
125
+ calibrationStatuses: string[];
126
+ };
127
+ filteredMetrics: CalibrationTreeMetrics;
128
+ }
129
+
130
+ export type CalibrationTreeSizeMode = "targets" | "weight" | "loss";
131
+
132
+ function finiteNumber(value: unknown): number | null {
133
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
134
+ }
135
+
136
+ function finiteError(row: CalibrationTreeTarget): number | null {
137
+ const value = row.abs_relative_error;
138
+ return typeof value === "number" && Number.isFinite(value) ? Math.abs(value) : null;
139
+ }
140
+
141
+ function finiteLossContribution(row: CalibrationTreeTarget): number | null {
142
+ const value = row.final_loss_contribution;
143
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
144
+ }
145
+
146
+ function finiteTargetLossWeightShare(row: CalibrationTreeTarget): number | null {
147
+ const value = row.target_loss_weight_share;
148
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
149
+ }
150
+
151
+ export function fitBandForTarget(row: CalibrationTreeTarget): FitBand {
152
+ const error = finiteError(row);
153
+ if (error == null) return "unscored";
154
+ if (error <= 0.05) return "0_5";
155
+ if (error <= 0.1) return "5_10";
156
+ if (error <= 0.2) return "10_20";
157
+ if (error <= 0.4) return "20_40";
158
+ return "40_plus";
159
+ }
160
+
161
+ /**
162
+ * Returns the rows that every selected filter keeps. The Geography filter
163
+ * selects geography labels: pass the labeller of all the rows a tree is built
164
+ * from when `rows` are only some of them, so that a row's label does not
165
+ * depend on which other rows are filtered. Without one, the labeller of
166
+ * `rows` is used.
167
+ */
168
+ export function applyExplorerFilters(
169
+ rows: CalibrationTreeTarget[],
170
+ filters: ExplorerState["filters"],
171
+ geographyLabel?: GeographyLabeller,
172
+ ): CalibrationTreeTarget[] {
173
+ const selectedGeographyLabel = filters.geographies.length
174
+ ? geographyLabel ?? geographyLabeller(rows)
175
+ : null;
176
+ return rows.filter((row) => {
177
+ const geographyLevel = String(row.level ?? "").trim() || DEFAULT_GEOGRAPHY_LEVEL;
178
+ const status =
179
+ row.calibration_status === "not_materialized"
180
+ ? "skipped"
181
+ : row.calibration_status ?? null;
182
+ return (
183
+ (!filters.geographyLevels.length || filters.geographyLevels.includes(geographyLevel)) &&
184
+ (selectedGeographyLabel == null ||
185
+ filters.geographies.includes(selectedGeographyLabel(row))) &&
186
+ (!filters.fitBands.length || filters.fitBands.includes(fitBandForTarget(row))) &&
187
+ (!filters.comparisonFits.length ||
188
+ (row.comparison_fit != null && filters.comparisonFits.includes(row.comparison_fit))) &&
189
+ (!filters.calibrationStatuses.length ||
190
+ (status != null && filters.calibrationStatuses.includes(status)))
191
+ );
192
+ });
193
+ }
194
+
195
+ function median(values: number[]): number | null {
196
+ if (!values.length) return null;
197
+ const sorted = [...values].sort((a, b) => a - b);
198
+ const middle = Math.floor(sorted.length / 2);
199
+ return sorted.length % 2
200
+ ? sorted[middle]
201
+ : (sorted[middle - 1] + sorted[middle]) / 2;
202
+ }
203
+
204
+ export function calibrationTreeMetricsFromInputs(
205
+ inputs: CalibrationTreeMetricInput[],
206
+ ): CalibrationTreeMetrics {
207
+ const errors = inputs
208
+ .map((input) => input.absRelativeError)
209
+ .filter((value): value is number => value != null);
210
+ const loss = inputs.reduce(
211
+ (sum, input) => sum + (input.finalLossContribution ?? 0),
212
+ 0,
213
+ );
214
+ const targetLossWeightShare = inputs.reduce(
215
+ (sum, input) => sum + (input.targetLossWeightShare ?? 0),
216
+ 0,
217
+ );
218
+ const changes = inputs
219
+ .map((input) => input.targetChange)
220
+ .filter((value): value is number => value != null);
221
+ const increasedError = changes.reduce((sum, value) => sum + Math.max(value, 0), 0);
222
+ const reducedError = changes.reduce((sum, value) => sum + Math.max(-value, 0), 0);
223
+ const change = changes.length
224
+ ? {
225
+ increasedError,
226
+ reducedError,
227
+ netChange: changes.reduce((sum, value) => sum + value, 0),
228
+ changedTargets: changes.filter((value) => Math.abs(value) > 1e-12).length,
229
+ unchangedTargets: changes.filter((value) => Math.abs(value) <= 1e-12).length,
230
+ sharedTargets: inputs.filter((input) => input.comparisonStatus === "shared").length,
231
+ addedTargets: inputs.filter((input) => input.comparisonStatus === "added").length,
232
+ removedTargets: inputs.filter((input) => input.comparisonStatus === "removed").length,
233
+ }
234
+ : undefined;
235
+ return {
236
+ nTargets: inputs.length,
237
+ scored: errors.length,
238
+ within10Pct: errors.filter((error) => error <= 0.1).length,
239
+ loss,
240
+ targetLossWeightShare,
241
+ weightedAverageCappedError: targetLossWeightShare > 0
242
+ ? loss / targetLossWeightShare
243
+ : null,
244
+ meanAbsRelativeError: errors.length
245
+ ? errors.reduce((sum, error) => sum + error, 0) / errors.length
246
+ : null,
247
+ medianAbsRelativeError: median(errors),
248
+ change,
249
+ };
250
+ }
251
+
252
+ export function calibrationTreeMetrics(
253
+ rows: CalibrationTreeTarget[],
254
+ ): CalibrationTreeMetrics {
255
+ return calibrationTreeMetricsFromInputs(rows.map((row) => ({
256
+ absRelativeError: finiteError(row),
257
+ targetLossWeightShare: finiteTargetLossWeightShare(row),
258
+ finalLossContribution: finiteLossContribution(row),
259
+ targetChange: finiteNumber(row.target_change),
260
+ comparisonStatus:
261
+ row.comparison_status === "shared" ||
262
+ row.comparison_status === "added" ||
263
+ row.comparison_status === "removed"
264
+ ? row.comparison_status
265
+ : null,
266
+ })));
267
+ }
268
+
269
+ function humanize(value: string): string {
270
+ return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
271
+ }
272
+
273
+ function programId(row: CalibrationTreeTarget): string {
274
+ const variable = String(row.variable ?? "").trim();
275
+ if (variable) return variable;
276
+ const source = String(row.source ?? "").trim();
277
+ const variableKey = String(row.variable_key ?? "").trim();
278
+ if (variableKey) {
279
+ const prefix = source ? `${source} / ` : "";
280
+ return (prefix && variableKey.startsWith(prefix)
281
+ ? variableKey.slice(prefix.length)
282
+ : variableKey
283
+ ).replace(/\s+·\s+(count|total|amount|mean)$/i, "");
284
+ }
285
+ return String(row.name ?? row.base_name ?? "unknown");
286
+ }
287
+
288
+ interface BreakdownDimension {
289
+ key: string;
290
+ label: string;
291
+ }
292
+
293
+ // These concepts are already represented elsewhere in the explorer or were
294
+ // explicitly rejected as navigation levels. Match exact semantic keys so a
295
+ // legitimate dimension such as `bd_amount_basis` remains explorable.
296
+ const NON_NAVIGABLE_BREAKDOWN_DIMENSIONS = new Set([
297
+ "bd_amount",
298
+ "bd_count",
299
+ "bd_geography",
300
+ "bd_geography_level",
301
+ "bd_level",
302
+ "bd_measure",
303
+ "bd_state",
304
+ "bd_state_abbreviation",
305
+ ]);
306
+
307
+ function isNavigableBreakdownDimension(key: string): boolean {
308
+ return /^bd_[a-z0-9_]+$/.test(key) && !NON_NAVIGABLE_BREAKDOWN_DIMENSIONS.has(key);
309
+ }
310
+
311
+ function rowDimensionValue(
312
+ row: CalibrationTreeTarget,
313
+ key: string,
314
+ ): string | null {
315
+ const values = [
316
+ ...new Set(
317
+ (row.target_dimensions ?? [])
318
+ .filter((dimension) => dimension.key === key)
319
+ .map((dimension) => String(dimension.value ?? "").trim())
320
+ .filter(Boolean),
321
+ ),
322
+ ];
323
+ return values.length === 1 ? values[0] : null;
324
+ }
325
+
326
+ export function orderedBreakdownDimensions(
327
+ rows: CalibrationTreeTarget[],
328
+ ): Array<{ key: string; label: string }> {
329
+ const labelsByKey = new Map<string, Map<string, number>>();
330
+ for (const row of rows) {
331
+ for (const dimension of row.target_dimensions ?? []) {
332
+ if (!isNavigableBreakdownDimension(dimension.key)) continue;
333
+ const labels = labelsByKey.get(dimension.key) ?? new Map<string, number>();
334
+ const label = String(dimension.label ?? "").trim();
335
+ if (label) labels.set(label, (labels.get(label) ?? 0) + 1);
336
+ labelsByKey.set(dimension.key, labels);
337
+ }
338
+ }
339
+
340
+ return [...labelsByKey.entries()]
341
+ .map(([key, labels]) => ({
342
+ key,
343
+ label: canonicalLabel(
344
+ [...labels.entries()].sort(
345
+ ([leftLabel, leftCount], [rightLabel, rightCount]) =>
346
+ rightCount - leftCount || leftLabel.localeCompare(rightLabel),
347
+ )[0]?.[0] ?? key.slice(3),
348
+ ),
349
+ coverage: rows.filter((row) => rowDimensionValue(row, key) != null).length,
350
+ distinctValues: new Set(
351
+ rows
352
+ .map((row) => rowDimensionValue(row, key))
353
+ .filter((value): value is string => value != null),
354
+ ).size,
355
+ }))
356
+ .sort((left, right) => {
357
+ return (
358
+ Number(right.distinctValues > 1) - Number(left.distinctValues > 1) ||
359
+ right.coverage - left.coverage ||
360
+ left.distinctValues - right.distinctValues ||
361
+ left.label.localeCompare(right.label) ||
362
+ left.key.localeCompare(right.key)
363
+ );
364
+ })
365
+ .map(({ key, label }) => ({ key, label }));
366
+ }
367
+
368
+ function rowGeographyLabel(row: CalibrationTreeTarget): string {
369
+ return String(row.geography ?? "").trim() || DEFAULT_GEOGRAPHY;
370
+ }
371
+
372
+ // A row names its geography by level and `geography_id`, and spells the
373
+ // geography's label in `geography`. Rows from different publishers can spell
374
+ // one geography differently (a state's full name on one target, its postal
375
+ // code on another), and the reader (validateHierarchyTargets) reports the
376
+ // spellings without rewriting the rows. So that the tree lists a geography
377
+ // once, every row of a geography ID takes the first spelling of that ID in
378
+ // row order, which is the spelling the reader keeps; when the rows spell it
379
+ // one way, it is that spelling, so trees built from rows without variants
380
+ // do not change. Rows reach the builder after buildCalibration has added the
381
+ // level to a label that another geography level also uses
382
+ // (levelQualifiedGeographyLabels), so the kept spelling can carry its level,
383
+ // such as "Wrexham (local authority)" where the reader reports "Wrexham".
384
+ //
385
+ // The label is read from every row the tree is built from, before filters,
386
+ // because it is also the geography's selection value: a geography must have
387
+ // the same label in both breakdowns, at every level and under any filter, as
388
+ // in a published tree, which is compiled without filters. Distinct geography
389
+ // IDs that share a label still form one node, as before.
390
+ //
391
+ // The Geography filter uses the same label: it offers one option per node
392
+ // label (filterOptions), and an option selects every row with that label
393
+ // (applyExplorerFilters), whichever way the row spells the geography. The
394
+ // published filter index stores one posting per label (compiledTarget in
395
+ // calibration-tree-artifact.ts). A spelling that is not a label, such as
396
+ // "ME", selects no row.
397
+ //
398
+ // Rows without a geography ID (comparison rows carry none) keep their own
399
+ // spelling.
400
+ export interface GeographyLabeller {
401
+ (row: CalibrationTreeTarget): string;
402
+ /** The labels of all the rows, one per geography node. */
403
+ readonly labels: ReadonlySet<string>;
404
+ }
405
+
406
+ function geographyKey(row: CalibrationTreeTarget): string | null {
407
+ const geographyIdentifier = row.geography_id;
408
+ const trimmedIdentifier =
409
+ typeof geographyIdentifier === "string" ? geographyIdentifier.trim() : "";
410
+ if (!trimmedIdentifier) return null;
411
+ return `${String(row.level ?? "").trim()}\0${trimmedIdentifier}`;
412
+ }
413
+
414
+ // Labels every row in one pass. A compile builds one labeller and every level
415
+ // of the tree from the same rows (buildCalibrationTree's
416
+ // allRowsGeographyLabel), so the label of each of those rows is stored and
417
+ // looked up by the row instead of being worked out again at every level.
418
+ export function geographyLabeller(rows: CalibrationTreeTarget[]): GeographyLabeller {
419
+ const firstSpellings = new Map<string, string>();
420
+ const rowLabels = new Map<CalibrationTreeTarget, string>();
421
+ const labels = new Set<string>();
422
+ for (const row of rows) {
423
+ const key = geographyKey(row);
424
+ let label = key == null ? undefined : firstSpellings.get(key);
425
+ if (label === undefined) {
426
+ label = rowGeographyLabel(row);
427
+ if (key != null) firstSpellings.set(key, label);
428
+ labels.add(label);
429
+ }
430
+ rowLabels.set(row, label);
431
+ }
432
+ const labelOf = (row: CalibrationTreeTarget) => {
433
+ const rowLabel = rowLabels.get(row);
434
+ if (rowLabel !== undefined) return rowLabel;
435
+ const key = geographyKey(row);
436
+ return (key == null ? undefined : firstSpellings.get(key)) ??
437
+ rowGeographyLabel(row);
438
+ };
439
+ return Object.assign(labelOf, { labels });
440
+ }
441
+
442
+ function targetLabel(row: CalibrationTreeTarget): string {
443
+ if (row.target_representation === "hierarchy") {
444
+ return String(row.target_label);
445
+ }
446
+ const sourceMeasureId = String(row.source_measure_id ?? "").trim();
447
+ if (sourceMeasureId) return humanize(sourceMeasureId);
448
+ const id = String(row.name ?? row.base_name ?? "Target");
449
+ const leaf = id.replace(/@[^@]+$/, "").split(/[./]/).filter(Boolean).at(-1);
450
+ return humanize(leaf ?? id);
451
+ }
452
+
453
+ interface DimensionPartition {
454
+ dimension: BreakdownDimension;
455
+ rows: CalibrationTreeTarget[];
456
+ }
457
+
458
+ interface HierarchyEntry {
459
+ row: CalibrationTreeTarget;
460
+ cursor: number;
461
+ }
462
+
463
+ interface HierarchyDimensionPartition {
464
+ dimension: BreakdownDimension;
465
+ entries: HierarchyEntry[];
466
+ }
467
+
468
+ function hierarchyDimension(
469
+ entry: HierarchyEntry,
470
+ ): CalibrationTreeDimension | null {
471
+ return entry.row.target_dimensions?.[entry.cursor] ?? null;
472
+ }
473
+
474
+ function hierarchyDimensionValueId(
475
+ dimension: CalibrationTreeDimension,
476
+ ): string {
477
+ return String(
478
+ dimension.value_id ?? dimension.raw_value ?? dimension.value,
479
+ ).trim();
480
+ }
481
+
482
+ // The reader (validateHierarchyTargets) keeps the first spelling, in row
483
+ // order across the whole file, of a label that rows spell more than one way,
484
+ // and reports the others without rewriting the rows, so each row reaches the
485
+ // builder with its own spelling. The builder labels a dimension key from the
486
+ // rows of the program and geography being built (hierarchyDimensionOrder),
487
+ // and a dimension value from the rows of its node (hierarchyValueLabel):
488
+ // - when those rows spell it one way, it keeps that spelling, even if the
489
+ // reader keeps another one. Trees built before label variants were
490
+ // accepted did so, and they must rebuild unchanged.
491
+ // - otherwise, where the builder used to refuse, it uses the spelling the
492
+ // reader keeps (readerKeptLabels).
493
+ interface ReaderKeptLabels {
494
+ dimensions: ReadonlyMap<string, string>;
495
+ values: ReadonlyMap<string, string>;
496
+ }
497
+
498
+ function hierarchyValueKey(key: string, valueId: string): string {
499
+ return `${key}\0${valueId}`;
500
+ }
501
+
502
+ function readerKeptLabels(rows: CalibrationTreeTarget[]): ReaderKeptLabels {
503
+ const dimensions = new Map<string, string>();
504
+ const values = new Map<string, string>();
505
+ for (const row of rows) {
506
+ if (row.target_representation !== "hierarchy") continue;
507
+ for (const dimension of row.target_dimensions ?? []) {
508
+ if (!dimensions.has(dimension.key)) {
509
+ dimensions.set(dimension.key, dimension.label);
510
+ }
511
+ const valueKey = hierarchyValueKey(
512
+ dimension.key,
513
+ hierarchyDimensionValueId(dimension),
514
+ );
515
+ if (!values.has(valueKey)) values.set(valueKey, dimension.value);
516
+ }
517
+ }
518
+ return { dimensions, values };
519
+ }
520
+
521
+ function hierarchyValueLabel(
522
+ entries: HierarchyEntry[],
523
+ keptLabels: () => ReaderKeptLabels,
524
+ ): string {
525
+ const dimension = hierarchyDimension(entries[0])!;
526
+ const spellings = new Set(
527
+ entries.map((entry) => hierarchyDimension(entry)!.value),
528
+ );
529
+ if (spellings.size === 1) return dimension.value;
530
+ return keptLabels().values.get(
531
+ hierarchyValueKey(dimension.key, hierarchyDimensionValueId(dimension)),
532
+ ) ?? dimension.value;
533
+ }
534
+
535
+ function entriesByValueId(
536
+ entries: HierarchyEntry[],
537
+ ): Map<string, HierarchyEntry[]> {
538
+ const byValue = new Map<string, HierarchyEntry[]>();
539
+ for (const entry of entries) {
540
+ const valueId = hierarchyDimensionValueId(hierarchyDimension(entry)!);
541
+ const valueEntries = byValue.get(valueId);
542
+ if (valueEntries) valueEntries.push(entry);
543
+ else byValue.set(valueId, [entry]);
544
+ }
545
+ return byValue;
546
+ }
547
+
548
+ function mergeHierarchyPartitions(
549
+ partitions: HierarchyDimensionPartition[],
550
+ ): HierarchyDimensionPartition[] {
551
+ const merged = new Map<string, HierarchyDimensionPartition>();
552
+ for (const partition of partitions) {
553
+ const existing = merged.get(partition.dimension.key);
554
+ if (existing) {
555
+ existing.entries.push(...partition.entries);
556
+ } else {
557
+ merged.set(partition.dimension.key, {
558
+ dimension: partition.dimension,
559
+ entries: [...partition.entries],
560
+ });
561
+ }
562
+ }
563
+ return [...merged.values()];
564
+ }
565
+
566
+ function hierarchyPartitions(
567
+ entries: HierarchyEntry[],
568
+ dimensionLabels: ReadonlyMap<string, string>,
569
+ ): {
570
+ dimensions: HierarchyDimensionPartition[];
571
+ targets: HierarchyEntry[];
572
+ } {
573
+ const targets: HierarchyEntry[] = [];
574
+ const grouped = new Map<string, HierarchyDimensionPartition>();
575
+ for (const entry of entries) {
576
+ const dimension = hierarchyDimension(entry);
577
+ if (!dimension) {
578
+ targets.push(entry);
579
+ continue;
580
+ }
581
+ const existing = grouped.get(dimension.key);
582
+ if (existing) {
583
+ existing.entries.push(entry);
584
+ } else {
585
+ grouped.set(dimension.key, {
586
+ dimension: {
587
+ key: dimension.key,
588
+ label: dimensionLabels.get(dimension.key) ?? dimension.label,
589
+ },
590
+ entries: [entry],
591
+ });
592
+ }
593
+ }
594
+
595
+ const visible: HierarchyDimensionPartition[] = [];
596
+ for (const partition of grouped.values()) {
597
+ const values = new Set(
598
+ partition.entries.map((entry) =>
599
+ hierarchyDimensionValueId(hierarchyDimension(entry)!),
600
+ ),
601
+ );
602
+ if (values.size > 1) {
603
+ visible.push(partition);
604
+ continue;
605
+ }
606
+ const nested = hierarchyPartitions(
607
+ partition.entries.map((entry) => ({ ...entry, cursor: entry.cursor + 1 })),
608
+ dimensionLabels,
609
+ );
610
+ visible.push(...nested.dimensions);
611
+ targets.push(...nested.targets);
612
+ }
613
+ return {
614
+ dimensions: mergeHierarchyPartitions(visible),
615
+ targets,
616
+ };
617
+ }
618
+
619
+ function hierarchyDimensionOrder(
620
+ rows: CalibrationTreeTarget[],
621
+ keptLabels: () => ReaderKeptLabels,
622
+ ): BreakdownDimension[] {
623
+ const order = new Map<string, BreakdownDimension>();
624
+ const spelledMoreThanOneWay = new Set<string>();
625
+ for (const row of rows) {
626
+ for (const dimension of row.target_dimensions ?? []) {
627
+ const existing = order.get(dimension.key);
628
+ if (!existing) {
629
+ order.set(dimension.key, {
630
+ key: dimension.key,
631
+ label: dimension.label,
632
+ });
633
+ } else if (existing.label !== dimension.label) {
634
+ spelledMoreThanOneWay.add(dimension.key);
635
+ }
636
+ }
637
+ }
638
+ for (const key of spelledMoreThanOneWay) {
639
+ const dimension = order.get(key)!;
640
+ dimension.label = keptLabels().dimensions.get(key) ?? dimension.label;
641
+ }
642
+ return [...order.values()];
643
+ }
644
+
645
+ function partitionRowsByDimension(
646
+ rows: CalibrationTreeTarget[],
647
+ selectedKeys: Set<string>,
648
+ ): { dimensions: DimensionPartition[]; targets: CalibrationTreeTarget[] } {
649
+ const remaining = new Set(rows);
650
+ const rankedDimensions = orderedBreakdownDimensions(rows).filter(
651
+ (dimension) => !selectedKeys.has(dimension.key),
652
+ );
653
+ const displayRank = new Map(
654
+ rankedDimensions.map((dimension, index) => [dimension.key, index]),
655
+ );
656
+ const candidates = rankedDimensions.flatMap((dimension) => {
657
+ const eligibleRows = rows.filter(
658
+ (row) => rowDimensionValue(row, dimension.key) != null,
659
+ );
660
+ const values = new Set(
661
+ eligibleRows
662
+ .map((row) => rowDimensionValue(row, dimension.key))
663
+ .filter((value): value is string => value != null),
664
+ );
665
+ return values.size > 1 ? [{ dimension, rows: eligibleRows }] : [];
666
+ });
667
+
668
+ // Allocate narrower sparse dimensions first so a broader overlapping
669
+ // dimension cannot consume every one of their rows. Display order remains
670
+ // independently ranked by branch coverage and cardinality.
671
+ const partitionsByKey = new Map<string, DimensionPartition>();
672
+ for (const candidate of [...candidates].sort(
673
+ (left, right) =>
674
+ left.rows.length - right.rows.length ||
675
+ (displayRank.get(left.dimension.key) ?? 0) -
676
+ (displayRank.get(right.dimension.key) ?? 0),
677
+ )) {
678
+ const assigned = candidate.rows.filter((row) => remaining.has(row));
679
+ const values = new Set(
680
+ assigned
681
+ .map((row) => rowDimensionValue(row, candidate.dimension.key))
682
+ .filter((value): value is string => value != null),
683
+ );
684
+ if (values.size <= 1) continue;
685
+ partitionsByKey.set(candidate.dimension.key, {
686
+ dimension: candidate.dimension,
687
+ rows: assigned,
688
+ });
689
+ for (const row of assigned) remaining.delete(row);
690
+ }
691
+
692
+ return {
693
+ dimensions: rankedDimensions.flatMap((dimension) => {
694
+ const partition = partitionsByKey.get(dimension.key);
695
+ return partition ? [partition] : [];
696
+ }),
697
+ targets: [...remaining],
698
+ };
699
+ }
700
+
701
+ function groupRows(
702
+ rows: CalibrationTreeTarget[],
703
+ keyOf: (row: CalibrationTreeTarget) => string,
704
+ ): Map<string, CalibrationTreeTarget[]> {
705
+ const groups = new Map<string, CalibrationTreeTarget[]>();
706
+ for (const row of rows) {
707
+ const key = keyOf(row);
708
+ const group = groups.get(key) ?? [];
709
+ group.push(row);
710
+ groups.set(key, group);
711
+ }
712
+ return groups;
713
+ }
714
+
715
+ function node(
716
+ id: string,
717
+ label: string,
718
+ kind: CalibrationTreeNode["kind"],
719
+ selection: ExplorerNodeSelection,
720
+ rows: CalibrationTreeTarget[],
721
+ target?: CalibrationTreeTarget,
722
+ authoredLabel = false,
723
+ ): CalibrationTreeNode {
724
+ return {
725
+ id,
726
+ label,
727
+ kind,
728
+ selection,
729
+ metrics: calibrationTreeMetrics(rows),
730
+ target,
731
+ authored_label: authoredLabel || undefined,
732
+ };
733
+ }
734
+
735
+ function sortNodes(nodes: CalibrationTreeNode[]): CalibrationTreeNode[] {
736
+ return nodes.sort(
737
+ (left, right) =>
738
+ right.metrics.nTargets - left.metrics.nTargets ||
739
+ left.label.localeCompare(right.label),
740
+ );
741
+ }
742
+
743
+ function filterOptions(
744
+ rows: CalibrationTreeTarget[],
745
+ geographyLabel: GeographyLabeller,
746
+ ) {
747
+ const unique = (values: Iterable<string>) => [...new Set(values)].sort((left, right) =>
748
+ left.localeCompare(right),
749
+ );
750
+ return {
751
+ geographyLevels: unique(rows.map(
752
+ (row) => String(row.level ?? "").trim() || DEFAULT_GEOGRAPHY_LEVEL,
753
+ )),
754
+ geographies: unique(geographyLabel.labels),
755
+ fitBands: ["0_5", "5_10", "10_20", "20_40", "40_plus", "unscored"],
756
+ comparisonFits: rows.some((row) => row.comparison_fit != null)
757
+ ? [...COMPARISON_FITS]
758
+ : [],
759
+ calibrationStatuses: ["included", "skipped"],
760
+ };
761
+ }
762
+
763
+ function normalizeChartCalibrationStatus(
764
+ row: CalibrationTreeTarget,
765
+ ): CalibrationTreeTarget {
766
+ if (row.calibration_status !== "not_materialized") return row;
767
+ return {
768
+ ...row,
769
+ calibration_status: "skipped",
770
+ calibration_status_label: "Skipped",
771
+ };
772
+ }
773
+
774
+ export function normalizeCalibrationTreeTargets(
775
+ rows: CalibrationTreeTarget[],
776
+ ): CalibrationTreeTarget[] {
777
+ return rows.map(normalizeChartCalibrationStatus);
778
+ }
779
+
780
+ function programGroups(rows: CalibrationTreeTarget[]): CalibrationTreeGroup[] {
781
+ const bySource = groupRows(
782
+ rows,
783
+ (row) => String(row.source ?? "other") || "other",
784
+ );
785
+ return [...bySource.entries()]
786
+ .map(([source, sourceRows]) => {
787
+ const artifactSourceLabel = sourceRows
788
+ .map((row) => row.source_label?.trim())
789
+ .find((label): label is string => Boolean(label));
790
+ const byProgram = groupRows(sourceRows, programId);
791
+ const nodes = sortNodes(
792
+ [...byProgram.entries()].map(([program, programRows]) => {
793
+ const labels = [
794
+ ...new Set(
795
+ programRows
796
+ .map((row) => row.variable_label?.trim())
797
+ .filter((label): label is string => Boolean(label)),
798
+ ),
799
+ ];
800
+ return node(
801
+ program,
802
+ programLabel(program, labels.length === 1 ? labels[0] : null),
803
+ "program",
804
+ { kind: "program", source, value: program },
805
+ programRows,
806
+ );
807
+ }),
808
+ );
809
+ return {
810
+ id: source,
811
+ label: artifactSourceLabel ?? sourceLabel(source),
812
+ nodes,
813
+ metrics: calibrationTreeMetrics(sourceRows),
814
+ };
815
+ })
816
+ .sort(
817
+ (left, right) =>
818
+ right.metrics.nTargets - left.metrics.nTargets ||
819
+ left.id.localeCompare(right.id),
820
+ );
821
+ }
822
+
823
+ function pathLabels(
824
+ rows: CalibrationTreeTarget[],
825
+ path: ExplorerState["path"],
826
+ geographyLabel: GeographyLabeller,
827
+ ): CalibrationTreeResponse["pathLabels"] {
828
+ const labels: CalibrationTreeResponse["pathLabels"] = {};
829
+ if (path.geography) {
830
+ const geographyLabels = [
831
+ ...new Set(
832
+ rows
833
+ .filter(
834
+ (row) =>
835
+ row.target_representation === "hierarchy" &&
836
+ Boolean(row.geography?.trim()) &&
837
+ geographyLabel(row) === path.geography,
838
+ )
839
+ .map(geographyLabel),
840
+ ),
841
+ ];
842
+ if (geographyLabels.length === 1) labels.geography = geographyLabels[0];
843
+ }
844
+ if (!path.source) return labels;
845
+ const sourceRows = rows.filter(
846
+ (row) => String(row.source ?? "other") === path.source,
847
+ );
848
+ const artifactSourceLabel = sourceRows
849
+ .map((row) => row.source_label?.trim())
850
+ .find((label): label is string => Boolean(label));
851
+ labels.source = artifactSourceLabel ?? sourceLabel(path.source);
852
+ if (!path.program) return labels;
853
+ const programRows = sourceRows.filter((row) => programId(row) === path.program);
854
+ const artifactProgramLabels = [
855
+ ...new Set(
856
+ programRows
857
+ .map((row) => row.variable_label?.trim())
858
+ .filter((label): label is string => Boolean(label)),
859
+ ),
860
+ ];
861
+ labels.program = programLabel(
862
+ path.program,
863
+ artifactProgramLabels.length === 1 ? artifactProgramLabels[0] : null,
864
+ );
865
+ return labels;
866
+ }
867
+
868
+ function geographyNodes(
869
+ rows: CalibrationTreeTarget[],
870
+ geographyLabel: GeographyLabeller,
871
+ ): CalibrationTreeNode[] {
872
+ const byGeography = groupRows(rows, geographyLabel);
873
+ return sortNodes(
874
+ [...byGeography.entries()].map(([geography, geographyRows]) =>
875
+ node(
876
+ geography,
877
+ geography,
878
+ "geography",
879
+ { kind: "geography", value: geography },
880
+ geographyRows,
881
+ ),
882
+ ),
883
+ );
884
+ }
885
+
886
+ function hierarchyDimensionTree(
887
+ allRows: CalibrationTreeTarget[],
888
+ geographyRows: CalibrationTreeTarget[],
889
+ state: ExplorerState,
890
+ releaseId: string | undefined,
891
+ lossAttributionAvailable: boolean,
892
+ calibrationProvenance: CalibrationProvenance | undefined,
893
+ selectedPathLabels: CalibrationTreeResponse["pathLabels"],
894
+ options: CalibrationTreeResponse["filterOptions"],
895
+ geographyLabel: GeographyLabeller,
896
+ ): CalibrationTreeResponse {
897
+ // Only rows that spell a label more than one way need the reader's
898
+ // spellings, so they are read from all rows only then.
899
+ let labelsKeptByReader: ReaderKeptLabels | undefined;
900
+ const keptLabels = () => (labelsKeptByReader ??= readerKeptLabels(allRows));
901
+ const dimensionOrder = hierarchyDimensionOrder(geographyRows, keptLabels);
902
+ const dimensionLabels = new Map(
903
+ dimensionOrder.map(({ key, label }) => [key, label]),
904
+ );
905
+ let entries: HierarchyEntry[] = geographyRows.map((row) => ({
906
+ row,
907
+ cursor: 0,
908
+ }));
909
+ const selectedValueLabels: string[] = [];
910
+ for (const selection of state.path.dimensions) {
911
+ const partition = hierarchyPartitions(entries, dimensionLabels).dimensions.find(
912
+ ({ dimension }) => dimension.key === selection.key,
913
+ );
914
+ if (!partition) {
915
+ entries = [];
916
+ break;
917
+ }
918
+ const matching = partition.entries.filter((entry) => {
919
+ const dimension = hierarchyDimension(entry);
920
+ return (
921
+ dimension != null &&
922
+ hierarchyDimensionValueId(dimension) === selection.value
923
+ );
924
+ });
925
+ selectedValueLabels.push(
926
+ matching.length
927
+ ? hierarchyValueLabel(matching, keptLabels)
928
+ : selection.value,
929
+ );
930
+ entries = matching.map((entry) => ({
931
+ ...entry,
932
+ cursor: entry.cursor + 1,
933
+ }));
934
+ }
935
+
936
+ const scopedRows = entries.map((entry) => entry.row);
937
+ const filteredScopedRows = applyExplorerFilters(
938
+ scopedRows,
939
+ state.filters,
940
+ geographyLabel,
941
+ );
942
+ const visibleRows = new Set(filteredScopedRows);
943
+ const partition = hierarchyPartitions(entries, dimensionLabels);
944
+ const groups: CalibrationTreeGroup[] = partition.dimensions.flatMap(
945
+ ({ dimension, entries: dimensionEntries }) => {
946
+ const visibleEntries = dimensionEntries.filter((entry) =>
947
+ visibleRows.has(entry.row),
948
+ );
949
+ if (!visibleEntries.length) return [];
950
+ // A value is labelled from all of its rows, not only the visible ones,
951
+ // so filters do not change its label: its selected path and the
952
+ // published tree, which is compiled without filters, use all its rows.
953
+ const allEntriesByValue = entriesByValueId(dimensionEntries);
954
+ const byValue = entriesByValueId(visibleEntries);
955
+ const nodes = sortNodes(
956
+ [...byValue.entries()].map(([valueId, valueEntries]) =>
957
+ node(
958
+ valueId,
959
+ hierarchyValueLabel(allEntriesByValue.get(valueId)!, keptLabels),
960
+ "dimension_value",
961
+ {
962
+ kind: "dimension_value",
963
+ key: dimension.key,
964
+ label: dimension.label,
965
+ value: valueId,
966
+ },
967
+ valueEntries.map((entry) => entry.row),
968
+ undefined,
969
+ true,
970
+ ),
971
+ ),
972
+ );
973
+ return [{
974
+ id: dimension.key,
975
+ label: dimension.label,
976
+ nodes,
977
+ metrics: calibrationTreeMetrics(visibleEntries.map((entry) => entry.row)),
978
+ }];
979
+ },
980
+ );
981
+
982
+ const visibleTargets = partition.targets
983
+ .map((entry) => entry.row)
984
+ .filter((row) => visibleRows.has(row));
985
+ const targetNodes = visibleTargets
986
+ .map((row, index) => {
987
+ const id = String(
988
+ row.comparison_id ?? row.name ?? row.base_name ?? `target-${index}`,
989
+ );
990
+ return node(
991
+ id,
992
+ targetLabel(row),
993
+ "target",
994
+ { kind: "target", value: id },
995
+ [row],
996
+ row,
997
+ );
998
+ })
999
+ .sort(
1000
+ (left, right) =>
1001
+ left.label.localeCompare(right.label) || left.id.localeCompare(right.id),
1002
+ );
1003
+ if (targetNodes.length) {
1004
+ groups.push({
1005
+ id: "targets",
1006
+ label: "Targets",
1007
+ nodes: targetNodes,
1008
+ metrics: calibrationTreeMetrics(visibleTargets),
1009
+ });
1010
+ }
1011
+
1012
+ const currentLevel =
1013
+ partition.dimensions.length === 1 && partition.targets.length === 0
1014
+ ? { kind: "dimension" as const, ...partition.dimensions[0].dimension }
1015
+ : partition.dimensions.length === 0
1016
+ ? { kind: "target" as const, label: "Targets" }
1017
+ : { kind: "mixed" as const, label: "Breakdowns and targets" };
1018
+ return {
1019
+ releaseId,
1020
+ calibrationProvenance,
1021
+ lossAttributionAvailable,
1022
+ path: state.path,
1023
+ pathLabels: {
1024
+ ...selectedPathLabels,
1025
+ dimensions: selectedValueLabels,
1026
+ },
1027
+ currentLevel,
1028
+ groups,
1029
+ dimensionOrder,
1030
+ filterOptions: options,
1031
+ filteredMetrics: calibrationTreeMetrics(filteredScopedRows),
1032
+ };
1033
+ }
1034
+
1035
+ /**
1036
+ * Builds one level of the calibration tree from all of its rows.
1037
+ *
1038
+ * `allRowsGeographyLabel` is `geographyLabeller(allRows)`, for a caller that
1039
+ * builds many levels from the same rows (compileCalibrationTreeBundleDraft):
1040
+ * it is then built once rather than at every level. Without it, the labeller
1041
+ * of `allRows` is built here.
1042
+ */
1043
+ export function buildCalibrationTree(
1044
+ allRows: CalibrationTreeTarget[],
1045
+ state: ExplorerState,
1046
+ releaseId?: string,
1047
+ lossAttributionAvailable =
1048
+ allRows.length > 0 && allRows.every((row) => finiteLossContribution(row) != null),
1049
+ calibrationProvenance?: CalibrationProvenance,
1050
+ allRowsGeographyLabel?: GeographyLabeller,
1051
+ ): CalibrationTreeResponse {
1052
+ const chartRows = normalizeCalibrationTreeTargets(allRows);
1053
+ const { path } = state;
1054
+ const geographyLabel = allRowsGeographyLabel ?? geographyLabeller(chartRows);
1055
+ const selectedPathLabels = pathLabels(chartRows, path, geographyLabel);
1056
+ const options = filterOptions(chartRows, geographyLabel);
1057
+ const filteredRows = applyExplorerFilters(
1058
+ chartRows,
1059
+ state.filters,
1060
+ geographyLabel,
1061
+ );
1062
+
1063
+ if (state.breakdown === "geography" && !path.geography) {
1064
+ const nodes = geographyNodes(filteredRows, geographyLabel);
1065
+ return {
1066
+ releaseId,
1067
+ calibrationProvenance,
1068
+ lossAttributionAvailable,
1069
+ path,
1070
+ pathLabels: selectedPathLabels,
1071
+ currentLevel: { kind: "geography", label: "Geography" },
1072
+ groups: [{
1073
+ id: "geography",
1074
+ label: "Geography",
1075
+ nodes,
1076
+ metrics: calibrationTreeMetrics(filteredRows),
1077
+ }],
1078
+ dimensionOrder: [],
1079
+ filterOptions: options,
1080
+ filteredMetrics: calibrationTreeMetrics(filteredRows),
1081
+ };
1082
+ }
1083
+
1084
+ if (state.breakdown === "geography" && (!path.source || !path.program)) {
1085
+ const geographyRows = chartRows.filter(
1086
+ (row) => geographyLabel(row) === path.geography,
1087
+ );
1088
+ const filteredGeographyRows = applyExplorerFilters(
1089
+ geographyRows,
1090
+ state.filters,
1091
+ geographyLabel,
1092
+ );
1093
+ return {
1094
+ releaseId,
1095
+ calibrationProvenance,
1096
+ lossAttributionAvailable,
1097
+ path,
1098
+ pathLabels: selectedPathLabels,
1099
+ currentLevel: { kind: "overview", label: "Programs" },
1100
+ groups: programGroups(filteredGeographyRows),
1101
+ dimensionOrder: [],
1102
+ filterOptions: options,
1103
+ filteredMetrics: calibrationTreeMetrics(filteredGeographyRows),
1104
+ };
1105
+ }
1106
+
1107
+ if (!path.source || !path.program) {
1108
+ return {
1109
+ releaseId,
1110
+ calibrationProvenance,
1111
+ lossAttributionAvailable,
1112
+ path,
1113
+ pathLabels: selectedPathLabels,
1114
+ currentLevel: { kind: "overview", label: "Programs" },
1115
+ groups: programGroups(filteredRows),
1116
+ dimensionOrder: [],
1117
+ filterOptions: options,
1118
+ filteredMetrics: calibrationTreeMetrics(filteredRows),
1119
+ };
1120
+ }
1121
+
1122
+ const programRows = chartRows.filter(
1123
+ (row) =>
1124
+ String(row.source ?? "other") === path.source &&
1125
+ programId(row) === path.program,
1126
+ );
1127
+ const filteredProgramRows = applyExplorerFilters(
1128
+ programRows,
1129
+ state.filters,
1130
+ geographyLabel,
1131
+ );
1132
+ if (!path.geography) {
1133
+ const nodes = geographyNodes(filteredProgramRows, geographyLabel);
1134
+ return {
1135
+ releaseId,
1136
+ calibrationProvenance,
1137
+ lossAttributionAvailable,
1138
+ path,
1139
+ pathLabels: selectedPathLabels,
1140
+ currentLevel: { kind: "geography", label: "Geography" },
1141
+ groups: [{
1142
+ id: path.program,
1143
+ label: selectedPathLabels.program ?? programLabel(path.program),
1144
+ nodes,
1145
+ metrics: calibrationTreeMetrics(filteredProgramRows),
1146
+ }],
1147
+ dimensionOrder: [],
1148
+ filterOptions: options,
1149
+ filteredMetrics: calibrationTreeMetrics(filteredProgramRows),
1150
+ };
1151
+ }
1152
+
1153
+ const geographyRows = path.geography
1154
+ ? programRows.filter((row) => geographyLabel(row) === path.geography)
1155
+ : programRows;
1156
+ if (
1157
+ geographyRows.length > 0 &&
1158
+ geographyRows.every((row) => row.target_representation === "hierarchy")
1159
+ ) {
1160
+ return hierarchyDimensionTree(
1161
+ chartRows,
1162
+ geographyRows,
1163
+ state,
1164
+ releaseId,
1165
+ lossAttributionAvailable,
1166
+ calibrationProvenance,
1167
+ selectedPathLabels,
1168
+ options,
1169
+ geographyLabel,
1170
+ );
1171
+ }
1172
+ const dimensionOrder = orderedBreakdownDimensions(geographyRows);
1173
+ let scopedRows = geographyRows;
1174
+ const selectedKeys = new Set<string>();
1175
+ for (const selection of path.dimensions) {
1176
+ const selectedPartition = partitionRowsByDimension(scopedRows, selectedKeys).dimensions.find(
1177
+ ({ dimension }) => dimension.key === selection.key,
1178
+ );
1179
+ if (!selectedPartition) {
1180
+ scopedRows = [];
1181
+ break;
1182
+ }
1183
+ scopedRows = selectedPartition.rows.filter(
1184
+ (row) => rowDimensionValue(row, selection.key) === selection.value,
1185
+ );
1186
+ selectedKeys.add(selection.key);
1187
+ }
1188
+ const filteredScopedRows = applyExplorerFilters(
1189
+ scopedRows,
1190
+ state.filters,
1191
+ geographyLabel,
1192
+ );
1193
+ const visibleRows = new Set(filteredScopedRows);
1194
+ const partition = partitionRowsByDimension(scopedRows, selectedKeys);
1195
+
1196
+ const groups: CalibrationTreeGroup[] = partition.dimensions.flatMap(
1197
+ ({ dimension, rows: dimensionRows }) => {
1198
+ const visibleDimensionRows = dimensionRows.filter((row) => visibleRows.has(row));
1199
+ if (!visibleDimensionRows.length) return [];
1200
+ const byValue = groupRows(
1201
+ visibleDimensionRows,
1202
+ (row) => rowDimensionValue(row, dimension.key) ?? "",
1203
+ );
1204
+ const nodes = sortNodes(
1205
+ [...byValue.entries()].map(([value, rows]) =>
1206
+ node(
1207
+ value,
1208
+ value,
1209
+ "dimension_value",
1210
+ {
1211
+ kind: "dimension_value",
1212
+ key: dimension.key,
1213
+ label: dimension.label,
1214
+ value,
1215
+ },
1216
+ rows,
1217
+ ),
1218
+ ),
1219
+ );
1220
+ return [{
1221
+ id: dimension.key,
1222
+ label: dimension.label,
1223
+ nodes,
1224
+ metrics: calibrationTreeMetrics(visibleDimensionRows),
1225
+ }];
1226
+ },
1227
+ );
1228
+
1229
+ const visibleTargets = partition.targets.filter((row) => visibleRows.has(row));
1230
+ const targetNodes = visibleTargets
1231
+ .map((row, index) => {
1232
+ const id = String(row.comparison_id ?? row.name ?? row.base_name ?? `target-${index}`);
1233
+ return node(
1234
+ id,
1235
+ targetLabel(row),
1236
+ "target",
1237
+ { kind: "target", value: id },
1238
+ [row],
1239
+ row,
1240
+ );
1241
+ })
1242
+ .sort((left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
1243
+ if (targetNodes.length) {
1244
+ groups.push({
1245
+ id: "targets",
1246
+ label: "Targets",
1247
+ nodes: targetNodes,
1248
+ metrics: calibrationTreeMetrics(visibleTargets),
1249
+ });
1250
+ }
1251
+
1252
+ const currentLevel =
1253
+ partition.dimensions.length === 1 && partition.targets.length === 0
1254
+ ? { kind: "dimension" as const, ...partition.dimensions[0].dimension }
1255
+ : partition.dimensions.length === 0
1256
+ ? { kind: "target" as const, label: "Targets" }
1257
+ : { kind: "mixed" as const, label: "Breakdowns and targets" };
1258
+ return {
1259
+ releaseId,
1260
+ calibrationProvenance,
1261
+ lossAttributionAvailable,
1262
+ path,
1263
+ pathLabels: selectedPathLabels,
1264
+ currentLevel,
1265
+ groups,
1266
+ dimensionOrder,
1267
+ filterOptions: options,
1268
+ filteredMetrics: calibrationTreeMetrics(filteredScopedRows),
1269
+ };
1270
+ }
1271
+
1272
+ export function effectiveNodeMetric(
1273
+ nodes: CalibrationTreeNode[],
1274
+ mode: CalibrationTreeSizeMode,
1275
+ ): number[] {
1276
+ const values = nodes.map((item) => {
1277
+ if (mode === "targets") return item.metrics.nTargets;
1278
+ if (mode === "weight") return item.metrics.targetLossWeightShare;
1279
+ return item.metrics.loss;
1280
+ });
1281
+ return values.some((value) => value > 0)
1282
+ ? values
1283
+ : nodes.map((item) => item.metrics.nTargets);
1284
+ }