@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,137 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface StructuredDimensionDefinition {
|
|
4
|
+
label: string;
|
|
5
|
+
role?: "geography";
|
|
6
|
+
level?: string;
|
|
7
|
+
values?: Record<string, string>;
|
|
8
|
+
order?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StructuredTargetDimension {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
value: string;
|
|
15
|
+
source_key: string;
|
|
16
|
+
raw_value: string;
|
|
17
|
+
rank?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StructuredDimensions {
|
|
21
|
+
geography: string | null;
|
|
22
|
+
geographyId: string | null;
|
|
23
|
+
geographyDimensionId: string | null;
|
|
24
|
+
geographyRank: number | null;
|
|
25
|
+
level: string | null;
|
|
26
|
+
dimensions: StructuredTargetDimension[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stringValue(value: unknown): string | null {
|
|
30
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function titleCase(value: string): string {
|
|
34
|
+
return value
|
|
35
|
+
.replace(/[_:./#-]+/g, " ")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dimensionLabel(value: string): string {
|
|
42
|
+
if (value === "us:statutes/26/62#adjusted_gross_income") return "Income band";
|
|
43
|
+
if (value === "census_stc.item") return "Item";
|
|
44
|
+
if (value === "hhs_acf_tanf.spending_category") return "Spending category";
|
|
45
|
+
if (value.startsWith("cms_medicaid.")) {
|
|
46
|
+
return titleCase(value.replace(/^cms_medicaid\./, ""));
|
|
47
|
+
}
|
|
48
|
+
const hash = value.split("#").at(-1);
|
|
49
|
+
const last = hash?.split(".").at(-1) ?? value;
|
|
50
|
+
return titleCase(last);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function utf8Hex(value: string): string {
|
|
54
|
+
return [...new TextEncoder().encode(value)]
|
|
55
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
56
|
+
.join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Simple lowercase identifiers retain their established keys. Identifiers
|
|
60
|
+
// containing punctuation, uppercase characters, or the reserved escape prefix
|
|
61
|
+
// use an injective UTF-8 encoding that remains safe in facet query parameters.
|
|
62
|
+
export function structuredDimensionKey(id: string): string {
|
|
63
|
+
if (/^[a-z0-9_]+$/.test(id) && !id.startsWith("x0_")) return `bd_${id}`;
|
|
64
|
+
return `bd_x0_${utf8Hex(id)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dimensionValue(
|
|
68
|
+
label: string,
|
|
69
|
+
rawValue: string,
|
|
70
|
+
valueLabels?: Readonly<Record<string, string>>,
|
|
71
|
+
): string {
|
|
72
|
+
const explicit = valueLabels && Object.hasOwn(valueLabels, rawValue)
|
|
73
|
+
? valueLabels[rawValue]
|
|
74
|
+
: undefined;
|
|
75
|
+
if (explicit) return explicit;
|
|
76
|
+
if (label.toLowerCase() === "age band") {
|
|
77
|
+
const range = /^(\d+)_(\d+)$/.exec(rawValue);
|
|
78
|
+
if (range) return `${range[1]}–${range[2]}`;
|
|
79
|
+
const openEnded = /^(\d+)_plus$/.exec(rawValue);
|
|
80
|
+
if (openEnded) return `${openEnded[1]}+`;
|
|
81
|
+
}
|
|
82
|
+
return titleCase(rawValue);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readStructuredDimensions(
|
|
86
|
+
values: JsonObject,
|
|
87
|
+
definitions: Record<string, StructuredDimensionDefinition>,
|
|
88
|
+
): StructuredDimensions {
|
|
89
|
+
let geography: string | null = null;
|
|
90
|
+
let geographyId: string | null = null;
|
|
91
|
+
let geographyDimensionId: string | null = null;
|
|
92
|
+
let geographyRank: number | null = null;
|
|
93
|
+
let level: string | null = null;
|
|
94
|
+
const dimensions: StructuredTargetDimension[] = [];
|
|
95
|
+
|
|
96
|
+
for (const [id, raw] of Object.entries(values)) {
|
|
97
|
+
if (!id) continue;
|
|
98
|
+
const rawValue = stringValue(raw);
|
|
99
|
+
if (!rawValue) continue;
|
|
100
|
+
const definition = definitions[id];
|
|
101
|
+
const label = definition?.label ?? dimensionLabel(id);
|
|
102
|
+
const value = dimensionValue(label, rawValue, definition?.values);
|
|
103
|
+
const rankOrder = definition?.order ??
|
|
104
|
+
(definition?.values ? Object.keys(definition.values) : undefined);
|
|
105
|
+
const rank = rankOrder?.indexOf(rawValue) ?? -1;
|
|
106
|
+
if (definition?.role === "geography") {
|
|
107
|
+
if (geography != null) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Structured target dimensions must contain at most one populated geography-role dimension.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
geography = value;
|
|
113
|
+
geographyId = rawValue;
|
|
114
|
+
geographyDimensionId = id;
|
|
115
|
+
geographyRank = rank >= 0 ? rank : null;
|
|
116
|
+
level = definition.level ?? "region";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
dimensions.push({
|
|
120
|
+
key: structuredDimensionKey(id),
|
|
121
|
+
label,
|
|
122
|
+
value,
|
|
123
|
+
source_key: id,
|
|
124
|
+
raw_value: rawValue,
|
|
125
|
+
...(rank >= 0 ? { rank } : {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
geography,
|
|
131
|
+
geographyId,
|
|
132
|
+
geographyDimensionId,
|
|
133
|
+
geographyRank,
|
|
134
|
+
level,
|
|
135
|
+
dimensions,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readStructuredDimensions,
|
|
3
|
+
type StructuredDimensionDefinition,
|
|
4
|
+
type StructuredTargetDimension,
|
|
5
|
+
} from "./structured-dimension-reader";
|
|
6
|
+
|
|
7
|
+
type JsonObject = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export interface StructuredTargetIdentity {
|
|
10
|
+
source: string;
|
|
11
|
+
sourceLabel: string | null;
|
|
12
|
+
sourceCitation: string | null;
|
|
13
|
+
sourceUrl: string | null;
|
|
14
|
+
variable: string;
|
|
15
|
+
variableLabel: string | null;
|
|
16
|
+
measure: string | null;
|
|
17
|
+
geography: string;
|
|
18
|
+
geographyId: string | null;
|
|
19
|
+
geographyDimensionId: string | null;
|
|
20
|
+
geographyRank: number | null;
|
|
21
|
+
level: string;
|
|
22
|
+
dimensions: StructuredTargetDimension[];
|
|
23
|
+
breakdown: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
28
|
+
const prototype = Object.getPrototypeOf(value);
|
|
29
|
+
return prototype === Object.prototype || prototype === null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stringValue(value: unknown): string | null {
|
|
33
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readStructuredTarget(
|
|
37
|
+
row: JsonObject,
|
|
38
|
+
definitions: Record<string, StructuredDimensionDefinition>,
|
|
39
|
+
nationalGeography: string,
|
|
40
|
+
): StructuredTargetIdentity {
|
|
41
|
+
if (!isPlainObject(row.source) || !stringValue(row.source.id)) {
|
|
42
|
+
throw new Error("Schema 7 target source.id must be a non-empty string.");
|
|
43
|
+
}
|
|
44
|
+
if (!isPlainObject(row.variable) || !stringValue(row.variable.id)) {
|
|
45
|
+
throw new Error("Schema 7 target variable.id must be a non-empty string.");
|
|
46
|
+
}
|
|
47
|
+
if (!isPlainObject(row.dimensions)) {
|
|
48
|
+
throw new Error("Schema 7 target dimensions must be an object.");
|
|
49
|
+
}
|
|
50
|
+
const source = row.source;
|
|
51
|
+
const variable = row.variable;
|
|
52
|
+
const values = row.dimensions;
|
|
53
|
+
const sourceId = stringValue(source.id)!;
|
|
54
|
+
const variableId = stringValue(variable.id)!;
|
|
55
|
+
const structured = readStructuredDimensions(values, definitions);
|
|
56
|
+
const geography = structured.geography ?? nationalGeography;
|
|
57
|
+
const level = structured.level ?? "national";
|
|
58
|
+
const { dimensions } = structured;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
source: sourceId,
|
|
62
|
+
sourceLabel: stringValue(source.label),
|
|
63
|
+
sourceCitation: stringValue(source.citation),
|
|
64
|
+
sourceUrl: stringValue(source.url),
|
|
65
|
+
variable: variableId,
|
|
66
|
+
variableLabel: stringValue(variable.label),
|
|
67
|
+
measure: stringValue(variable.measure),
|
|
68
|
+
geography,
|
|
69
|
+
geographyId: structured.geographyId,
|
|
70
|
+
geographyDimensionId: structured.geographyDimensionId,
|
|
71
|
+
geographyRank: structured.geographyRank,
|
|
72
|
+
level,
|
|
73
|
+
dimensions,
|
|
74
|
+
breakdown: dimensions.map((dimension) => dimension.value).join(" · "),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import type { Calibration } from "./latest-artifact";
|
|
2
|
+
import type { ComparisonFit } from "./calibration-explorer";
|
|
3
|
+
import {
|
|
4
|
+
matchTargetSurfaces,
|
|
5
|
+
type TargetMatchingSummary,
|
|
6
|
+
type TargetSurfaceStatus,
|
|
7
|
+
} from "./target-surface-matcher";
|
|
8
|
+
import type { TargetRowRepresentation } from "./target-representation";
|
|
9
|
+
|
|
10
|
+
type TargetRow = Calibration["rows"][number];
|
|
11
|
+
export type TargetChangeDetailRow = TargetRow;
|
|
12
|
+
|
|
13
|
+
export const TARGET_CHANGE_EPSILON = 1e-12;
|
|
14
|
+
const COMPARISON_FIT_EPSILON = 1e-9;
|
|
15
|
+
|
|
16
|
+
export type TargetChangeMode = "reported" | "shared";
|
|
17
|
+
export type { TargetSurfaceStatus } from "./target-surface-matcher";
|
|
18
|
+
|
|
19
|
+
export interface TargetChangeSide {
|
|
20
|
+
target: number | null;
|
|
21
|
+
finalEstimate: number | null;
|
|
22
|
+
weight: number;
|
|
23
|
+
weightShare: number;
|
|
24
|
+
scale: number;
|
|
25
|
+
cappedError: number;
|
|
26
|
+
contribution: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TargetChangeRow extends Record<string, unknown> {
|
|
30
|
+
name: string;
|
|
31
|
+
base_name: string;
|
|
32
|
+
comparison_id: string;
|
|
33
|
+
match_kind: "base_name" | "chronicle_fact_key" | "structured_identity" | null;
|
|
34
|
+
current_name: string | null;
|
|
35
|
+
candidate_name: string | null;
|
|
36
|
+
current_representation: TargetRowRepresentation | null;
|
|
37
|
+
candidate_representation: TargetRowRepresentation | null;
|
|
38
|
+
current_target_ordinal: number | null;
|
|
39
|
+
candidate_target_ordinal: number | null;
|
|
40
|
+
comparison_status: TargetSurfaceStatus;
|
|
41
|
+
comparison_fit: ComparisonFit;
|
|
42
|
+
current: TargetChangeSide | null;
|
|
43
|
+
candidate: TargetChangeSide | null;
|
|
44
|
+
currentDetail: TargetChangeDetailRow | null;
|
|
45
|
+
candidateDetail: TargetChangeDetailRow | null;
|
|
46
|
+
reported_change: number;
|
|
47
|
+
pooled_weight_share: number | null;
|
|
48
|
+
shared_current_contribution: number | null;
|
|
49
|
+
shared_candidate_contribution: number | null;
|
|
50
|
+
shared_change: number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TargetChangeAttributionSide {
|
|
54
|
+
releaseId: string;
|
|
55
|
+
calibrationProvenance: Calibration["calibration_provenance"];
|
|
56
|
+
status: Calibration["target_loss_attribution"]["status"];
|
|
57
|
+
aggregate: number | null;
|
|
58
|
+
cap: number | null;
|
|
59
|
+
basisIdentifier: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TargetChangeMethodology {
|
|
63
|
+
comparable: boolean;
|
|
64
|
+
warning: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TargetChangeSummary {
|
|
68
|
+
mode: TargetChangeMode;
|
|
69
|
+
currentScore: number;
|
|
70
|
+
candidateScore: number;
|
|
71
|
+
netChange: number;
|
|
72
|
+
grossIncrease: number;
|
|
73
|
+
grossReduction: number;
|
|
74
|
+
reconciliationDifference: number;
|
|
75
|
+
comparisonTargets: number;
|
|
76
|
+
shared: number;
|
|
77
|
+
added: number;
|
|
78
|
+
removed: number;
|
|
79
|
+
changed: number;
|
|
80
|
+
unchanged: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TargetChangeDataset {
|
|
84
|
+
available: boolean;
|
|
85
|
+
reason: string | null;
|
|
86
|
+
current: TargetChangeAttributionSide;
|
|
87
|
+
candidate: TargetChangeAttributionSide;
|
|
88
|
+
methodology: TargetChangeMethodology;
|
|
89
|
+
matching: TargetMatchingSummary;
|
|
90
|
+
rows: TargetChangeRow[];
|
|
91
|
+
summaries: Record<TargetChangeMode, TargetChangeSummary | null>;
|
|
92
|
+
modeReasons: Record<TargetChangeMode, string | null>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function finiteNumber(value: unknown): number | null {
|
|
96
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hierarchyFields(row: TargetRow): Record<string, unknown> {
|
|
100
|
+
return {
|
|
101
|
+
source: row.source ?? null,
|
|
102
|
+
source_label: row.source_label ?? null,
|
|
103
|
+
variable: row.variable ?? null,
|
|
104
|
+
variable_label: row.variable_label ?? null,
|
|
105
|
+
target_label: row.target_label ?? null,
|
|
106
|
+
target_representation: row.target_representation ?? null,
|
|
107
|
+
variable_key: row.variable_key ?? null,
|
|
108
|
+
measure: row.measure ?? null,
|
|
109
|
+
source_measure_id: row.source_measure_id ?? null,
|
|
110
|
+
level: row.level ?? null,
|
|
111
|
+
geography: row.geography ?? null,
|
|
112
|
+
family: row.family ?? null,
|
|
113
|
+
breakdown: row.breakdown ?? null,
|
|
114
|
+
dims: row.dims ?? null,
|
|
115
|
+
target_dimensions: row.target_dimensions ?? null,
|
|
116
|
+
calibration_status: row.calibration_status ?? null,
|
|
117
|
+
abs_relative_error: row.abs_relative_error ?? null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function attributionSide(calibration: Calibration): TargetChangeAttributionSide {
|
|
122
|
+
const attribution = calibration.target_loss_attribution;
|
|
123
|
+
return {
|
|
124
|
+
releaseId: calibration.release_id,
|
|
125
|
+
calibrationProvenance: calibration.calibration_provenance,
|
|
126
|
+
status: attribution.status,
|
|
127
|
+
aggregate: finiteNumber(attribution.aggregate),
|
|
128
|
+
cap: finiteNumber(attribution.cap),
|
|
129
|
+
basisIdentifier: attribution.basis_identifier,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function targetSide(row: TargetRow | undefined): TargetChangeSide | null {
|
|
134
|
+
if (!row) return null;
|
|
135
|
+
const weight = finiteNumber(row.target_loss_weight);
|
|
136
|
+
const weightShare = finiteNumber(row.target_loss_weight_share);
|
|
137
|
+
const scale = finiteNumber(row.target_loss_scale);
|
|
138
|
+
const cappedError = finiteNumber(row.final_capped_scaled_error);
|
|
139
|
+
const contribution = finiteNumber(row.final_loss_contribution);
|
|
140
|
+
if (
|
|
141
|
+
weight == null || weight < 0 ||
|
|
142
|
+
weightShare == null || weightShare < 0 ||
|
|
143
|
+
scale == null || scale <= 0 ||
|
|
144
|
+
cappedError == null || cappedError < 0 ||
|
|
145
|
+
contribution == null || contribution < 0
|
|
146
|
+
) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
target: finiteNumber(row.target ?? row.value),
|
|
151
|
+
finalEstimate: finiteNumber(row.final_estimate ?? row.estimate),
|
|
152
|
+
weight,
|
|
153
|
+
weightShare,
|
|
154
|
+
scale,
|
|
155
|
+
cappedError,
|
|
156
|
+
contribution,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function comparisonFit(
|
|
161
|
+
currentRow: TargetRow | undefined,
|
|
162
|
+
candidateRow: TargetRow | undefined,
|
|
163
|
+
current: TargetChangeSide | null,
|
|
164
|
+
candidate: TargetChangeSide | null,
|
|
165
|
+
): ComparisonFit {
|
|
166
|
+
if (!currentRow || !candidateRow || !current || !candidate) {
|
|
167
|
+
return "not_applicable";
|
|
168
|
+
}
|
|
169
|
+
const currentReportedError = finiteNumber(currentRow.abs_relative_error);
|
|
170
|
+
const candidateReportedError = finiteNumber(candidateRow.abs_relative_error);
|
|
171
|
+
const hasReportedErrors = currentReportedError != null && candidateReportedError != null;
|
|
172
|
+
const currentError = hasReportedErrors ? currentReportedError : current.cappedError;
|
|
173
|
+
const candidateError = hasReportedErrors ? candidateReportedError : candidate.cappedError;
|
|
174
|
+
const change = Math.abs(candidateError) - Math.abs(currentError);
|
|
175
|
+
if (change < -COMPARISON_FIT_EPSILON) return "improved";
|
|
176
|
+
if (change > COMPARISON_FIT_EPSILON) return "regressed";
|
|
177
|
+
return "unchanged";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function close(left: number | null, right: number | null): boolean {
|
|
181
|
+
if (left == null || right == null) return left === right;
|
|
182
|
+
return Math.abs(left - right) <= TARGET_CHANGE_EPSILON;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function methodology(
|
|
186
|
+
current: TargetChangeAttributionSide,
|
|
187
|
+
candidate: TargetChangeAttributionSide,
|
|
188
|
+
): TargetChangeMethodology {
|
|
189
|
+
const capMatches = close(current.cap, candidate.cap);
|
|
190
|
+
const basisMatches = current.basisIdentifier === candidate.basisIdentifier;
|
|
191
|
+
const comparable = capMatches && basisMatches;
|
|
192
|
+
return {
|
|
193
|
+
comparable,
|
|
194
|
+
warning: comparable
|
|
195
|
+
? null
|
|
196
|
+
: "The current release and candidate use different target-loss caps or weighting methods. The comparison still uses each artifact's verified values, but it includes that methodology difference.",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function summarize(
|
|
201
|
+
rows: TargetChangeRow[],
|
|
202
|
+
mode: TargetChangeMode,
|
|
203
|
+
currentScore: number,
|
|
204
|
+
candidateScore: number,
|
|
205
|
+
): TargetChangeSummary {
|
|
206
|
+
const included = mode === "reported"
|
|
207
|
+
? rows
|
|
208
|
+
: rows.filter((row) => row.comparison_status === "shared");
|
|
209
|
+
const changeOf = (row: TargetChangeRow) =>
|
|
210
|
+
mode === "reported" ? row.reported_change : row.shared_change ?? 0;
|
|
211
|
+
const rowTotal = included.reduce((sum, row) => sum + changeOf(row), 0);
|
|
212
|
+
const grossIncrease = included.reduce(
|
|
213
|
+
(sum, row) => sum + Math.max(changeOf(row), 0),
|
|
214
|
+
0,
|
|
215
|
+
);
|
|
216
|
+
const grossReduction = included.reduce(
|
|
217
|
+
(sum, row) => sum + Math.max(-changeOf(row), 0),
|
|
218
|
+
0,
|
|
219
|
+
);
|
|
220
|
+
const shared = rows.filter((row) => row.comparison_status === "shared").length;
|
|
221
|
+
const added = rows.filter((row) => row.comparison_status === "added").length;
|
|
222
|
+
const removed = rows.filter((row) => row.comparison_status === "removed").length;
|
|
223
|
+
const changed = included.filter(
|
|
224
|
+
(row) => Math.abs(changeOf(row)) > TARGET_CHANGE_EPSILON,
|
|
225
|
+
).length;
|
|
226
|
+
return {
|
|
227
|
+
mode,
|
|
228
|
+
currentScore,
|
|
229
|
+
candidateScore,
|
|
230
|
+
netChange: candidateScore - currentScore,
|
|
231
|
+
grossIncrease,
|
|
232
|
+
grossReduction,
|
|
233
|
+
reconciliationDifference: rowTotal - (candidateScore - currentScore),
|
|
234
|
+
comparisonTargets: included.length,
|
|
235
|
+
shared,
|
|
236
|
+
added,
|
|
237
|
+
removed,
|
|
238
|
+
changed,
|
|
239
|
+
unchanged: included.length - changed,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function unavailableDataset(
|
|
244
|
+
current: TargetChangeAttributionSide,
|
|
245
|
+
candidate: TargetChangeAttributionSide,
|
|
246
|
+
matching: TargetMatchingSummary,
|
|
247
|
+
reason: string,
|
|
248
|
+
): TargetChangeDataset {
|
|
249
|
+
return {
|
|
250
|
+
available: false,
|
|
251
|
+
reason,
|
|
252
|
+
current,
|
|
253
|
+
candidate,
|
|
254
|
+
methodology: methodology(current, candidate),
|
|
255
|
+
matching,
|
|
256
|
+
rows: [],
|
|
257
|
+
summaries: { reported: null, shared: null },
|
|
258
|
+
modeReasons: { reported: reason, shared: reason },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function targetChangeForMode(
|
|
263
|
+
row: TargetChangeRow,
|
|
264
|
+
mode: TargetChangeMode,
|
|
265
|
+
): number | null {
|
|
266
|
+
return mode === "reported" ? row.reported_change : row.shared_change;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function buildTargetChangeDataset(
|
|
270
|
+
currentCalibration: Calibration,
|
|
271
|
+
candidateCalibration: Calibration,
|
|
272
|
+
): TargetChangeDataset {
|
|
273
|
+
const current = attributionSide(currentCalibration);
|
|
274
|
+
const candidate = attributionSide(candidateCalibration);
|
|
275
|
+
const matched = matchTargetSurfaces(currentCalibration, candidateCalibration);
|
|
276
|
+
if (current.status === "unavailable" || current.aggregate == null) {
|
|
277
|
+
return unavailableDataset(
|
|
278
|
+
current,
|
|
279
|
+
candidate,
|
|
280
|
+
matched.matching,
|
|
281
|
+
"Weighted target-error attribution is unavailable for the current release.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (candidate.status === "unavailable" || candidate.aggregate == null) {
|
|
285
|
+
return unavailableDataset(
|
|
286
|
+
current,
|
|
287
|
+
candidate,
|
|
288
|
+
matched.matching,
|
|
289
|
+
"Weighted target-error attribution is unavailable for the candidate.",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rows: TargetChangeRow[] = [];
|
|
294
|
+
|
|
295
|
+
for (const match of matched.matches) {
|
|
296
|
+
const currentRow = match.current ?? undefined;
|
|
297
|
+
const candidateRow = match.candidate ?? undefined;
|
|
298
|
+
const name = String(
|
|
299
|
+
candidateRow?.base_name ?? candidateRow?.name ??
|
|
300
|
+
currentRow?.base_name ?? currentRow?.name ??
|
|
301
|
+
match.comparison_id,
|
|
302
|
+
);
|
|
303
|
+
const currentTarget = targetSide(currentRow);
|
|
304
|
+
const candidateTarget = targetSide(candidateRow);
|
|
305
|
+
if ((currentRow && !currentTarget) || (candidateRow && !candidateTarget)) {
|
|
306
|
+
return unavailableDataset(
|
|
307
|
+
current,
|
|
308
|
+
candidate,
|
|
309
|
+
matched.matching,
|
|
310
|
+
`Weighted target-error attribution is incomplete for target ${name}.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const categoryRow = candidateRow ?? currentRow;
|
|
314
|
+
rows.push({
|
|
315
|
+
...(categoryRow ? hierarchyFields(categoryRow) : {}),
|
|
316
|
+
name,
|
|
317
|
+
base_name: name,
|
|
318
|
+
comparison_id: match.comparison_id,
|
|
319
|
+
match_kind: match.match_kind,
|
|
320
|
+
current_name: match.current_name,
|
|
321
|
+
candidate_name: match.candidate_name,
|
|
322
|
+
current_representation: match.current_representation,
|
|
323
|
+
candidate_representation: match.candidate_representation,
|
|
324
|
+
current_target_ordinal: match.current_target_ordinal,
|
|
325
|
+
candidate_target_ordinal: match.candidate_target_ordinal,
|
|
326
|
+
comparison_status: match.comparison_status,
|
|
327
|
+
comparison_fit: comparisonFit(
|
|
328
|
+
currentRow,
|
|
329
|
+
candidateRow,
|
|
330
|
+
currentTarget,
|
|
331
|
+
candidateTarget,
|
|
332
|
+
),
|
|
333
|
+
current: currentTarget,
|
|
334
|
+
candidate: candidateTarget,
|
|
335
|
+
currentDetail: currentRow ?? null,
|
|
336
|
+
candidateDetail: candidateRow ?? null,
|
|
337
|
+
reported_change:
|
|
338
|
+
(candidateTarget?.contribution ?? 0) -
|
|
339
|
+
(currentTarget?.contribution ?? 0),
|
|
340
|
+
pooled_weight_share: null,
|
|
341
|
+
shared_current_contribution: null,
|
|
342
|
+
shared_candidate_contribution: null,
|
|
343
|
+
shared_change: null,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const sharedRows = rows.filter(
|
|
348
|
+
(row) => row.comparison_status === "shared" && row.current && row.candidate,
|
|
349
|
+
);
|
|
350
|
+
const reportedSummary = summarize(
|
|
351
|
+
rows,
|
|
352
|
+
"reported",
|
|
353
|
+
current.aggregate,
|
|
354
|
+
candidate.aggregate,
|
|
355
|
+
);
|
|
356
|
+
if (Math.abs(reportedSummary.reconciliationDifference) > TARGET_CHANGE_EPSILON) {
|
|
357
|
+
return unavailableDataset(
|
|
358
|
+
current,
|
|
359
|
+
candidate,
|
|
360
|
+
matched.matching,
|
|
361
|
+
"Per-target weighted error changes do not reconcile to the two attribution aggregates.",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const currentSharedWeight = sharedRows.reduce(
|
|
365
|
+
(sum, row) => sum + (row.current?.weightShare ?? 0),
|
|
366
|
+
0,
|
|
367
|
+
);
|
|
368
|
+
const candidateSharedWeight = sharedRows.reduce(
|
|
369
|
+
(sum, row) => sum + (row.candidate?.weightShare ?? 0),
|
|
370
|
+
0,
|
|
371
|
+
);
|
|
372
|
+
let sharedReason: string | null = null;
|
|
373
|
+
let sharedSummary: TargetChangeSummary | null = null;
|
|
374
|
+
if (!sharedRows.length) {
|
|
375
|
+
sharedReason = "The current release and candidate have no shared targets.";
|
|
376
|
+
} else if (currentSharedWeight <= 0 || candidateSharedWeight <= 0) {
|
|
377
|
+
sharedReason = "Shared targets do not have positive total weight on both sides.";
|
|
378
|
+
} else {
|
|
379
|
+
for (const row of sharedRows) {
|
|
380
|
+
const pooledShare = (
|
|
381
|
+
(row.current?.weightShare ?? 0) / currentSharedWeight +
|
|
382
|
+
(row.candidate?.weightShare ?? 0) / candidateSharedWeight
|
|
383
|
+
) / 2;
|
|
384
|
+
const currentContribution = pooledShare * (row.current?.cappedError ?? 0);
|
|
385
|
+
const candidateContribution = pooledShare * (row.candidate?.cappedError ?? 0);
|
|
386
|
+
row.pooled_weight_share = pooledShare;
|
|
387
|
+
row.shared_current_contribution = currentContribution;
|
|
388
|
+
row.shared_candidate_contribution = candidateContribution;
|
|
389
|
+
row.shared_change = candidateContribution - currentContribution;
|
|
390
|
+
}
|
|
391
|
+
const currentSharedScore = sharedRows.reduce(
|
|
392
|
+
(sum, row) => sum + (row.shared_current_contribution ?? 0),
|
|
393
|
+
0,
|
|
394
|
+
);
|
|
395
|
+
const candidateSharedScore = sharedRows.reduce(
|
|
396
|
+
(sum, row) => sum + (row.shared_candidate_contribution ?? 0),
|
|
397
|
+
0,
|
|
398
|
+
);
|
|
399
|
+
sharedSummary = summarize(
|
|
400
|
+
rows,
|
|
401
|
+
"shared",
|
|
402
|
+
currentSharedScore,
|
|
403
|
+
candidateSharedScore,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
available: true,
|
|
409
|
+
reason: null,
|
|
410
|
+
current,
|
|
411
|
+
candidate,
|
|
412
|
+
methodology: methodology(current, candidate),
|
|
413
|
+
matching: matched.matching,
|
|
414
|
+
rows,
|
|
415
|
+
summaries: {
|
|
416
|
+
reported: reportedSummary,
|
|
417
|
+
shared: sharedSummary,
|
|
418
|
+
},
|
|
419
|
+
modeReasons: { reported: null, shared: sharedReason },
|
|
420
|
+
};
|
|
421
|
+
}
|