@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.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/package.json +71 -4
- package/src/calibration-build-comparison.ts +77 -0
- package/src/calibration-comparison-blob.ts +367 -0
- package/src/calibration-comparison-bundle.ts +149 -0
- package/src/calibration-explorer.ts +202 -0
- package/src/calibration-release-locator.ts +353 -0
- package/src/calibration-tree-artifact.ts +1974 -0
- package/src/calibration-tree-blob.ts +403 -0
- package/src/calibration-tree-bundle.ts +569 -0
- package/src/calibration-tree-manifest.ts +349 -0
- package/src/calibration-tree-worker.ts +445 -0
- package/src/calibration-tree.ts +1284 -0
- package/src/chronicle-metadata.ts +42 -0
- package/src/countries.ts +355 -0
- package/src/geography-labels.ts +59 -0
- package/src/hierarchy-catalog-reader.ts +297 -0
- package/src/hierarchy-target-reader.ts +243 -0
- package/src/historical-weight-summary.ts +53 -0
- package/src/latest-artifact.ts +3767 -0
- package/src/legacy-target-reader.ts +337 -0
- package/src/program-label.ts +64 -0
- package/src/publish-calibration-tree.ts +1984 -0
- package/src/reforms.ts +224 -0
- package/src/release-cut.ts +32 -0
- package/src/release-record.ts +277 -0
- package/src/source-label.ts +5 -0
- package/src/source-labels.ts +71 -0
- package/src/staging-artifact.ts +1411 -0
- package/src/staging-contract.ts +916 -0
- package/src/structured-dimension-reader.ts +137 -0
- package/src/structured-target-reader.ts +76 -0
- package/src/target-change.ts +421 -0
- package/src/target-loss-attribution-manifest.ts +436 -0
- package/src/target-loss-attribution.ts +1012 -0
- package/src/target-representation.ts +69 -0
- package/src/target-surface-matcher.ts +266 -0
- 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
|
+
}
|